1079 lines
33 KiB
Python
Executable file
1079 lines
33 KiB
Python
Executable file
# imports - standard imports
|
|
import os
|
|
import sys
|
|
import shutil
|
|
|
|
# imports - third party imports
|
|
import click
|
|
|
|
# imports - module imports
|
|
import frappe
|
|
from frappe.commands import get_site, pass_context
|
|
from frappe.exceptions import SiteNotSpecifiedError
|
|
|
|
|
|
@click.command('new-site')
|
|
@click.argument('site')
|
|
@click.option('--db-name', help='Database name')
|
|
@click.option('--db-password', help='Database password')
|
|
@click.option('--db-type', default='mariadb', type=click.Choice(['mariadb', 'postgres']), help='Optional "postgres" or "mariadb". Default is "mariadb"')
|
|
@click.option('--db-host', help='Database Host')
|
|
@click.option('--db-port', type=int, help='Database Port')
|
|
@click.option('--mariadb-root-username', default='root', help='Root username for MariaDB')
|
|
@click.option('--mariadb-root-password', help='Root password for MariaDB')
|
|
@click.option('--no-mariadb-socket', is_flag=True, default=False, help='Set MariaDB host to % and use TCP/IP Socket instead of using the UNIX Socket')
|
|
@click.option('--admin-password', help='Administrator password for new site', default=None)
|
|
@click.option('--verbose', is_flag=True, default=False, help='Verbose')
|
|
@click.option('--force', help='Force restore if site/database already exists', is_flag=True, default=False)
|
|
@click.option('--source_sql', help='Initiate database with a SQL file')
|
|
@click.option('--install-app', multiple=True, help='Install app after installation')
|
|
def new_site(site, mariadb_root_username=None, mariadb_root_password=None, admin_password=None,
|
|
verbose=False, install_apps=None, source_sql=None, force=None, no_mariadb_socket=False,
|
|
install_app=None, db_name=None, db_password=None, db_type=None, db_host=None, db_port=None):
|
|
"Create a new site"
|
|
from frappe.installer import _new_site
|
|
|
|
frappe.init(site=site, new_site=True)
|
|
|
|
_new_site(db_name, site, mariadb_root_username=mariadb_root_username,
|
|
mariadb_root_password=mariadb_root_password, admin_password=admin_password,
|
|
verbose=verbose, install_apps=install_app, source_sql=source_sql, force=force,
|
|
no_mariadb_socket=no_mariadb_socket, db_password=db_password, db_type=db_type, db_host=db_host,
|
|
db_port=db_port, new_site=True)
|
|
|
|
if len(frappe.utils.get_sites()) == 1:
|
|
use(site)
|
|
|
|
|
|
@click.command('restore')
|
|
@click.argument('sql-file-path')
|
|
@click.option('--mariadb-root-username', default='root', help='Root username for MariaDB')
|
|
@click.option('--mariadb-root-password', help='Root password for MariaDB')
|
|
@click.option('--db-name', help='Database name for site in case it is a new one')
|
|
@click.option('--admin-password', help='Administrator password for new site')
|
|
@click.option('--install-app', multiple=True, help='Install app after installation')
|
|
@click.option('--with-public-files', help='Restores the public files of the site, given path to its tar file')
|
|
@click.option('--with-private-files', help='Restores the private files of the site, given path to its tar file')
|
|
@click.option('--force', is_flag=True, default=False, help='Ignore the validations and downgrade warnings. This action is not recommended')
|
|
@click.option('--encryption-key', help='Backup encryption key')
|
|
@pass_context
|
|
def restore(context, sql_file_path, encryption_key=None, mariadb_root_username=None, mariadb_root_password=None,
|
|
db_name=None, verbose=None, install_app=None, admin_password=None, force=None, with_public_files=None,
|
|
with_private_files=None):
|
|
"Restore site database from an sql file"
|
|
from frappe.installer import (
|
|
_new_site,
|
|
extract_sql_from_archive,
|
|
extract_files,
|
|
is_downgrade,
|
|
is_partial,
|
|
validate_database_sql
|
|
)
|
|
from frappe.utils.backups import Backup
|
|
if not os.path.exists(sql_file_path):
|
|
print("Invalid path", sql_file_path)
|
|
sys.exit(1)
|
|
|
|
_backup = Backup(sql_file_path)
|
|
|
|
site = get_site(context)
|
|
frappe.init(site=site)
|
|
force = context.force or force
|
|
|
|
try:
|
|
decompressed_file_name = extract_sql_from_archive(sql_file_path)
|
|
if is_partial(decompressed_file_name):
|
|
click.secho(
|
|
"Partial Backup file detected. You cannot use a partial file to restore a Frappe site.",
|
|
fg="red"
|
|
)
|
|
click.secho(
|
|
"Use `bench partial-restore` to restore a partial backup to an existing site.",
|
|
fg="yellow"
|
|
)
|
|
_backup.decryption_rollback()
|
|
sys.exit(1)
|
|
|
|
except UnicodeDecodeError:
|
|
_backup.decryption_rollback()
|
|
if encryption_key:
|
|
click.secho(
|
|
"Encrypted backup file detected. Decrypting using provided key.",
|
|
fg="yellow"
|
|
)
|
|
_backup.backup_decryption(encryption_key)
|
|
|
|
else:
|
|
click.secho(
|
|
"Encrypted backup file detected. Decrypting using site config.",
|
|
fg="yellow"
|
|
)
|
|
encryption_key = frappe.get_site_config().encryption_key
|
|
_backup.backup_decryption(encryption_key)
|
|
|
|
# Rollback on unsuccessful decryrption
|
|
if not os.path.exists(sql_file_path):
|
|
click.secho(
|
|
"Decryption failed. Please provide a valid key and try again.",
|
|
fg="red"
|
|
)
|
|
|
|
_backup.decryption_rollback()
|
|
sys.exit(1)
|
|
|
|
decompressed_file_name = extract_sql_from_archive(sql_file_path)
|
|
|
|
if is_partial(decompressed_file_name):
|
|
click.secho(
|
|
"Partial Backup file detected. You cannot use a partial file to restore a Frappe site.",
|
|
fg="red"
|
|
)
|
|
click.secho(
|
|
"Use `bench partial-restore` to restore a partial backup to an existing site.",
|
|
fg="yellow"
|
|
)
|
|
_backup.decryption_rollback()
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
validate_database_sql(decompressed_file_name, _raise=not force)
|
|
|
|
# dont allow downgrading to older versions of frappe without force
|
|
if not force and is_downgrade(decompressed_file_name, verbose=True):
|
|
warn_message = (
|
|
"This is not recommended and may lead to unexpected behaviour. "
|
|
"Do you want to continue anyway?"
|
|
)
|
|
click.confirm(warn_message, abort=True)
|
|
|
|
|
|
|
|
try:
|
|
_new_site(frappe.conf.db_name, site, mariadb_root_username=mariadb_root_username,
|
|
mariadb_root_password=mariadb_root_password, admin_password=admin_password,
|
|
verbose=context.verbose, install_apps=install_app, source_sql=decompressed_file_name,
|
|
force=True, db_type=frappe.conf.db_type)
|
|
|
|
except Exception as err:
|
|
print(err.args[1])
|
|
_backup.decryption_rollback()
|
|
sys.exit(1)
|
|
|
|
# Removing temporarily created file
|
|
if decompressed_file_name != sql_file_path:
|
|
os.remove(decompressed_file_name)
|
|
_backup.decryption_rollback()
|
|
|
|
# Extract public and/or private files to the restored site, if user has given the path
|
|
if with_public_files:
|
|
# Decrypt data if there is a Key
|
|
if encryption_key:
|
|
_backup = Backup(with_public_files)
|
|
_backup.backup_decryption(encryption_key)
|
|
if not os.path.exists(with_public_files):
|
|
_backup.decryption_rollback()
|
|
public = extract_files(site, with_public_files)
|
|
|
|
# Removing temporarily created file
|
|
os.remove(public)
|
|
_backup.decryption_rollback()
|
|
|
|
|
|
if with_private_files:
|
|
# Decrypt data if there is a Key
|
|
if encryption_key:
|
|
_backup = Backup(with_private_files)
|
|
_backup.backup_decryption(encryption_key)
|
|
if not os.path.exists(with_private_files):
|
|
_backup.decryption_rollback()
|
|
private = extract_files(site, with_private_files)
|
|
|
|
# Removing temporarily created file
|
|
os.remove(private)
|
|
_backup.decryption_rollback()
|
|
|
|
success_message = "Site {0} has been restored{1}".format(
|
|
site,
|
|
" with files" if (with_public_files or with_private_files) else ""
|
|
)
|
|
click.secho(success_message, fg="green")
|
|
|
|
@click.command('partial-restore')
|
|
@click.argument('sql-file-path')
|
|
@click.option("--verbose", "-v", is_flag=True)
|
|
@click.option('--encryption-key', help='Backup encryption key')
|
|
@pass_context
|
|
def partial_restore(context, sql_file_path, verbose, encryption_key=None):
|
|
from frappe.installer import partial_restore, extract_sql_from_archive
|
|
from frappe.utils.backups import Backup
|
|
|
|
if not os.path.exists(sql_file_path):
|
|
print("Invalid path", sql_file_path)
|
|
sys.exit(1)
|
|
|
|
site = get_site(context)
|
|
frappe.init(site=site)
|
|
|
|
_backup = Backup(sql_file_path)
|
|
|
|
verbose = context.verbose or verbose
|
|
|
|
frappe.connect(site=site)
|
|
try:
|
|
decompressed_file_name = extract_sql_from_archive(sql_file_path)
|
|
|
|
with open(decompressed_file_name) as f:
|
|
header = " ".join(f.readline() for _ in range(5))
|
|
|
|
#Check for full backup file
|
|
if "Partial Backup" not in header:
|
|
click.secho(
|
|
"Full backup file detected.Use `bench restore` to restore a Frappe Site.",
|
|
fg="red"
|
|
)
|
|
_backup.decryption_rollback()
|
|
sys.exit(1)
|
|
|
|
|
|
except UnicodeDecodeError:
|
|
_backup.decryption_rollback()
|
|
if encryption_key:
|
|
click.secho(
|
|
"Encrypted backup file detected. Decrypting using provided key.",
|
|
fg="yellow"
|
|
)
|
|
key = encryption_key
|
|
|
|
else:
|
|
click.secho(
|
|
"Encrypted backup file detected. Decrypting using site config.",
|
|
fg="yellow"
|
|
)
|
|
key = frappe.get_site_config().encryption_key
|
|
|
|
_backup.backup_decryption(key)
|
|
|
|
# Rollback on unsuccessful decryrption
|
|
if not os.path.exists(sql_file_path):
|
|
click.secho(
|
|
"Decryption failed. Please provide a valid key and try again.",
|
|
fg="red"
|
|
)
|
|
_backup.decryption_rollback()
|
|
sys.exit(1)
|
|
|
|
decompressed_file_name = extract_sql_from_archive(sql_file_path)
|
|
|
|
with open(decompressed_file_name) as f:
|
|
header = " ".join(f.readline() for _ in range(5))
|
|
|
|
#Check for Full backup file.
|
|
if "Partial Backup" not in header:
|
|
click.secho(
|
|
"Full Backup file detected.Use `bench restore` to restore a Frappe Site.",
|
|
fg="red"
|
|
)
|
|
_backup.decryption_rollback()
|
|
sys.exit(1)
|
|
|
|
|
|
partial_restore(sql_file_path, verbose)
|
|
|
|
# Removing temporarily created file
|
|
_backup.decryption_rollback()
|
|
if os.path.exists(sql_file_path.rstrip(".gz")):
|
|
os.remove(sql_file_path.rstrip(".gz"))
|
|
|
|
frappe.destroy()
|
|
|
|
|
|
@click.command('reinstall')
|
|
@click.option('--admin-password', help='Administrator Password for reinstalled site')
|
|
@click.option('--mariadb-root-username', help='Root username for MariaDB')
|
|
@click.option('--mariadb-root-password', help='Root password for MariaDB')
|
|
@click.option('--yes', is_flag=True, default=False, help='Pass --yes to skip confirmation')
|
|
@pass_context
|
|
def reinstall(context, admin_password=None, mariadb_root_username=None, mariadb_root_password=None, yes=False):
|
|
"Reinstall site ie. wipe all data and start over"
|
|
site = get_site(context)
|
|
_reinstall(site, admin_password, mariadb_root_username, mariadb_root_password, yes, verbose=context.verbose)
|
|
|
|
def _reinstall(site, admin_password=None, mariadb_root_username=None, mariadb_root_password=None, yes=False, verbose=False):
|
|
from frappe.installer import _new_site
|
|
|
|
if not yes:
|
|
click.confirm('This will wipe your database. Are you sure you want to reinstall?', abort=True)
|
|
try:
|
|
frappe.init(site=site)
|
|
frappe.connect()
|
|
frappe.clear_cache()
|
|
installed = frappe.get_installed_apps()
|
|
frappe.clear_cache()
|
|
except Exception:
|
|
installed = []
|
|
finally:
|
|
if frappe.db:
|
|
frappe.db.close()
|
|
frappe.destroy()
|
|
|
|
frappe.init(site=site)
|
|
_new_site(frappe.conf.db_name, site, verbose=verbose, force=True, reinstall=True, install_apps=installed,
|
|
mariadb_root_username=mariadb_root_username, mariadb_root_password=mariadb_root_password,
|
|
admin_password=admin_password)
|
|
|
|
@click.command('install-app')
|
|
@click.argument('apps', nargs=-1)
|
|
@pass_context
|
|
def install_app(context, apps):
|
|
"Install a new app to site, supports multiple apps"
|
|
from frappe.installer import install_app as _install_app
|
|
exit_code = 0
|
|
|
|
if not context.sites:
|
|
raise SiteNotSpecifiedError
|
|
|
|
for site in context.sites:
|
|
frappe.init(site=site)
|
|
frappe.connect()
|
|
|
|
for app in apps:
|
|
try:
|
|
_install_app(app, verbose=context.verbose)
|
|
except frappe.IncompatibleApp as err:
|
|
err_msg = ":\n{}".format(err) if str(err) else ""
|
|
print("App {} is Incompatible with Site {}{}".format(app, site, err_msg))
|
|
exit_code = 1
|
|
except Exception as err:
|
|
err_msg = ": {}\n{}".format(str(err), frappe.get_traceback())
|
|
print("An error occurred while installing {}{}".format(app, err_msg))
|
|
exit_code = 1
|
|
|
|
frappe.destroy()
|
|
|
|
sys.exit(exit_code)
|
|
|
|
|
|
@click.command("list-apps")
|
|
@click.option("--format", "-f", type=click.Choice(["text", "json"]), default="text")
|
|
@pass_context
|
|
def list_apps(context, format):
|
|
"List apps in site"
|
|
|
|
summary_dict = {}
|
|
|
|
def fix_whitespaces(text):
|
|
if site == context.sites[-1]:
|
|
text = text.rstrip()
|
|
if len(context.sites) == 1:
|
|
text = text.lstrip()
|
|
return text
|
|
|
|
for site in context.sites:
|
|
frappe.init(site=site)
|
|
frappe.connect()
|
|
site_title = (
|
|
click.style(f"{site}", fg="green") if len(context.sites) > 1 else ""
|
|
)
|
|
apps = frappe.get_single("Installed Applications").installed_applications
|
|
|
|
if apps:
|
|
name_len, ver_len = [
|
|
max([len(x.get(y)) for x in apps])
|
|
for y in ["app_name", "app_version"]
|
|
]
|
|
template = "{{0:{0}}} {{1:{1}}} {{2}}".format(name_len, ver_len)
|
|
|
|
installed_applications = [
|
|
template.format(app.app_name, app.app_version, app.git_branch)
|
|
for app in apps
|
|
]
|
|
applications_summary = "\n".join(installed_applications)
|
|
summary = f"{site_title}\n{applications_summary}\n"
|
|
summary_dict[site] = [app.app_name for app in apps]
|
|
|
|
else:
|
|
installed_applications = frappe.get_installed_apps()
|
|
applications_summary = "\n".join(installed_applications)
|
|
summary = f"{site_title}\n{applications_summary}\n"
|
|
summary_dict[site] = installed_applications
|
|
|
|
summary = fix_whitespaces(summary)
|
|
|
|
if format == "text" and applications_summary and summary:
|
|
print(summary)
|
|
|
|
frappe.destroy()
|
|
|
|
if format == "json":
|
|
click.echo(frappe.as_json(summary_dict))
|
|
|
|
@click.command('add-system-manager')
|
|
@click.argument('email')
|
|
@click.option('--first-name')
|
|
@click.option('--last-name')
|
|
@click.option('--password')
|
|
@click.option('--send-welcome-email', default=False, is_flag=True)
|
|
@pass_context
|
|
def add_system_manager(context, email, first_name, last_name, send_welcome_email, password):
|
|
"Add a new system manager to a site"
|
|
import frappe.utils.user
|
|
for site in context.sites:
|
|
frappe.connect(site=site)
|
|
try:
|
|
frappe.utils.user.add_system_manager(email, first_name, last_name,
|
|
send_welcome_email, password)
|
|
frappe.db.commit()
|
|
finally:
|
|
frappe.destroy()
|
|
if not context.sites:
|
|
raise SiteNotSpecifiedError
|
|
|
|
@click.command('disable-user')
|
|
@click.argument('email')
|
|
@pass_context
|
|
def disable_user(context, email):
|
|
site = get_site(context)
|
|
with frappe.init_site(site):
|
|
frappe.connect()
|
|
user = frappe.get_doc("User", email)
|
|
user.enabled = 0
|
|
user.save(ignore_permissions=True)
|
|
frappe.db.commit()
|
|
|
|
@click.command('migrate')
|
|
@click.option('--skip-failing', is_flag=True, help="Skip patches that fail to run")
|
|
@click.option('--skip-search-index', is_flag=True, help="Skip search indexing for web documents")
|
|
@pass_context
|
|
def migrate(context, skip_failing=False, skip_search_index=False):
|
|
"Run patches, sync schema and rebuild files/translations"
|
|
from frappe.migrate import migrate
|
|
|
|
for site in context.sites:
|
|
click.secho(f"Migrating {site}", fg="green")
|
|
frappe.init(site=site)
|
|
frappe.connect()
|
|
try:
|
|
migrate(
|
|
context.verbose,
|
|
skip_failing=skip_failing,
|
|
skip_search_index=skip_search_index
|
|
)
|
|
finally:
|
|
print()
|
|
frappe.destroy()
|
|
if not context.sites:
|
|
raise SiteNotSpecifiedError
|
|
|
|
@click.command('migrate-to')
|
|
@click.argument('frappe_provider')
|
|
@pass_context
|
|
def migrate_to(context, frappe_provider):
|
|
"Migrates site to the specified provider"
|
|
from frappe.integrations.frappe_providers import migrate_to
|
|
for site in context.sites:
|
|
frappe.init(site=site)
|
|
frappe.connect()
|
|
migrate_to(site, frappe_provider)
|
|
frappe.destroy()
|
|
if not context.sites:
|
|
raise SiteNotSpecifiedError
|
|
|
|
@click.command('run-patch')
|
|
@click.argument('module')
|
|
@click.option('--force', is_flag=True)
|
|
@pass_context
|
|
def run_patch(context, module, force):
|
|
"Run a particular patch"
|
|
import frappe.modules.patch_handler
|
|
for site in context.sites:
|
|
frappe.init(site=site)
|
|
try:
|
|
frappe.connect()
|
|
frappe.modules.patch_handler.run_single(module, force=force or context.force)
|
|
finally:
|
|
frappe.destroy()
|
|
if not context.sites:
|
|
raise SiteNotSpecifiedError
|
|
|
|
@click.command('reload-doc')
|
|
@click.argument('module')
|
|
@click.argument('doctype')
|
|
@click.argument('docname')
|
|
@pass_context
|
|
def reload_doc(context, module, doctype, docname):
|
|
"Reload schema for a DocType"
|
|
for site in context.sites:
|
|
try:
|
|
frappe.init(site=site)
|
|
frappe.connect()
|
|
frappe.reload_doc(module, doctype, docname, force=context.force)
|
|
frappe.db.commit()
|
|
finally:
|
|
frappe.destroy()
|
|
if not context.sites:
|
|
raise SiteNotSpecifiedError
|
|
|
|
@click.command('reload-doctype')
|
|
@click.argument('doctype')
|
|
@pass_context
|
|
def reload_doctype(context, doctype):
|
|
"Reload schema for a DocType"
|
|
for site in context.sites:
|
|
try:
|
|
frappe.init(site=site)
|
|
frappe.connect()
|
|
frappe.reload_doctype(doctype, force=context.force)
|
|
frappe.db.commit()
|
|
finally:
|
|
frappe.destroy()
|
|
if not context.sites:
|
|
raise SiteNotSpecifiedError
|
|
|
|
@click.command('add-to-hosts')
|
|
@pass_context
|
|
def add_to_hosts(context):
|
|
"Add site to hosts"
|
|
for site in context.sites:
|
|
frappe.commands.popen('echo 127.0.0.1\t{0} | sudo tee -a /etc/hosts'.format(site))
|
|
if not context.sites:
|
|
raise SiteNotSpecifiedError
|
|
|
|
@click.command('use')
|
|
@click.argument('site')
|
|
def _use(site, sites_path='.'):
|
|
"Set a default site"
|
|
use(site, sites_path=sites_path)
|
|
|
|
def use(site, sites_path='.'):
|
|
if os.path.exists(os.path.join(sites_path, site)):
|
|
with open(os.path.join(sites_path, "currentsite.txt"), "w") as sitefile:
|
|
sitefile.write(site)
|
|
print("Current Site set to {}".format(site))
|
|
else:
|
|
print("Site {} does not exist".format(site))
|
|
|
|
@click.command('backup')
|
|
@click.option('--with-files', default=False, is_flag=True, help="Take backup with files")
|
|
@click.option('--include', '--only', '-i', default="", type=str, help="Specify the DocTypes to backup seperated by commas")
|
|
@click.option('--exclude', '-e', default="", type=str, help="Specify the DocTypes to not backup seperated by commas")
|
|
@click.option('--backup-path', default=None, help="Set path for saving all the files in this operation")
|
|
@click.option('--backup-path-db', default=None, help="Set path for saving database file")
|
|
@click.option('--backup-path-files', default=None, help="Set path for saving public file")
|
|
@click.option('--backup-path-private-files', default=None, help="Set path for saving private file")
|
|
@click.option('--backup-path-conf', default=None, help="Set path for saving config file")
|
|
@click.option('--ignore-backup-conf', default=False, is_flag=True, help="Ignore excludes/includes set in config")
|
|
@click.option('--verbose', default=False, is_flag=True, help="Add verbosity")
|
|
@click.option('--compress', default=False, is_flag=True, help="Compress private and public files")
|
|
@pass_context
|
|
def backup(context, with_files=False, backup_path=None, backup_path_db=None, backup_path_files=None,
|
|
backup_path_private_files=None, backup_path_conf=None, ignore_backup_conf=False, verbose=False,
|
|
compress=False, include="", exclude=""):
|
|
"Backup"
|
|
|
|
from frappe.utils.backups import scheduled_backup
|
|
verbose = verbose or context.verbose
|
|
exit_code = 0
|
|
|
|
for site in context.sites:
|
|
try:
|
|
frappe.init(site=site)
|
|
frappe.connect()
|
|
odb = scheduled_backup(
|
|
ignore_files=not with_files,
|
|
backup_path=backup_path,
|
|
backup_path_db=backup_path_db,
|
|
backup_path_files=backup_path_files,
|
|
backup_path_private_files=backup_path_private_files,
|
|
backup_path_conf=backup_path_conf,
|
|
ignore_conf=ignore_backup_conf,
|
|
include_doctypes=include,
|
|
exclude_doctypes=exclude,
|
|
compress=compress,
|
|
verbose=verbose,
|
|
force=True
|
|
)
|
|
except Exception:
|
|
click.secho(
|
|
"Backup failed for Site {0}. Database or site_config.json may be corrupted".format(site),
|
|
fg="red"
|
|
)
|
|
if verbose:
|
|
print(frappe.get_traceback())
|
|
exit_code = 1
|
|
continue
|
|
if frappe.get_system_settings("encrypt_backup") and frappe.get_site_config().encryption_key:
|
|
click.secho(
|
|
"Backup encryption is turned on. Please note the backup encryption key.",
|
|
fg="yellow"
|
|
)
|
|
|
|
odb.print_summary()
|
|
click.secho(
|
|
"Backup for Site {0} has been successfully completed{1}".format(site, " with files" if with_files else ""),
|
|
fg="green"
|
|
)
|
|
frappe.destroy()
|
|
|
|
if not context.sites:
|
|
raise SiteNotSpecifiedError
|
|
|
|
sys.exit(exit_code)
|
|
|
|
|
|
@click.command('remove-from-installed-apps')
|
|
@click.argument('app')
|
|
@pass_context
|
|
def remove_from_installed_apps(context, app):
|
|
"Remove app from site's installed-apps list"
|
|
from frappe.installer import remove_from_installed_apps
|
|
for site in context.sites:
|
|
try:
|
|
frappe.init(site=site)
|
|
frappe.connect()
|
|
remove_from_installed_apps(app)
|
|
finally:
|
|
frappe.destroy()
|
|
if not context.sites:
|
|
raise SiteNotSpecifiedError
|
|
|
|
@click.command('uninstall-app')
|
|
@click.argument('app')
|
|
@click.option('--yes', '-y', help='To bypass confirmation prompt for uninstalling the app', is_flag=True, default=False)
|
|
@click.option('--dry-run', help='List all doctypes that will be deleted', is_flag=True, default=False)
|
|
@click.option('--no-backup', help='Do not backup the site', is_flag=True, default=False)
|
|
@click.option('--force', help='Force remove app from site', is_flag=True, default=False)
|
|
@pass_context
|
|
def uninstall(context, app, dry_run, yes, no_backup, force):
|
|
"Remove app and linked modules from site"
|
|
from frappe.installer import remove_app
|
|
for site in context.sites:
|
|
try:
|
|
frappe.init(site=site)
|
|
frappe.connect()
|
|
remove_app(app_name=app, dry_run=dry_run, yes=yes, no_backup=no_backup, force=force)
|
|
finally:
|
|
frappe.destroy()
|
|
if not context.sites:
|
|
raise SiteNotSpecifiedError
|
|
|
|
|
|
@click.command('drop-site')
|
|
@click.argument('site')
|
|
@click.option('--root-login', default='root')
|
|
@click.option('--root-password')
|
|
@click.option('--archived-sites-path')
|
|
@click.option('--no-backup', is_flag=True, default=False)
|
|
@click.option('--force', help='Force drop-site even if an error is encountered', is_flag=True, default=False)
|
|
def drop_site(site, root_login='root', root_password=None, archived_sites_path=None, force=False, no_backup=False):
|
|
_drop_site(site, root_login, root_password, archived_sites_path, force, no_backup)
|
|
|
|
|
|
def _drop_site(site, root_login='root', root_password=None, archived_sites_path=None, force=False, no_backup=False):
|
|
"Remove site from database and filesystem"
|
|
from frappe.database import drop_user_and_database
|
|
from frappe.utils.backups import scheduled_backup
|
|
|
|
frappe.init(site=site)
|
|
frappe.connect()
|
|
|
|
try:
|
|
if not no_backup:
|
|
scheduled_backup(ignore_files=False, force=True)
|
|
except Exception as err:
|
|
if force:
|
|
pass
|
|
else:
|
|
messages = [
|
|
"=" * 80,
|
|
"Error: The operation has stopped because backup of {0}'s database failed.".format(site),
|
|
"Reason: {0}\n".format(str(err)),
|
|
"Fix the issue and try again.",
|
|
"Hint: Use 'bench drop-site {0} --force' to force the removal of {0}".format(site)
|
|
]
|
|
click.echo("\n".join(messages))
|
|
sys.exit(1)
|
|
|
|
drop_user_and_database(frappe.conf.db_name, root_login, root_password)
|
|
|
|
archived_sites_path = archived_sites_path or os.path.join(frappe.get_app_path('frappe'), '..', '..', '..', 'archived', 'sites')
|
|
|
|
os.makedirs(archived_sites_path, exist_ok=True)
|
|
|
|
move(archived_sites_path, site)
|
|
|
|
|
|
def move(dest_dir, site):
|
|
if not os.path.isdir(dest_dir):
|
|
raise Exception("destination is not a directory or does not exist")
|
|
|
|
frappe.init(site)
|
|
old_path = frappe.utils.get_site_path()
|
|
new_path = os.path.join(dest_dir, site)
|
|
|
|
# check if site dump of same name already exists
|
|
site_dump_exists = True
|
|
count = 0
|
|
while site_dump_exists:
|
|
final_new_path = new_path + (count and str(count) or "")
|
|
site_dump_exists = os.path.exists(final_new_path)
|
|
count = int(count or 0) + 1
|
|
|
|
shutil.move(old_path, final_new_path)
|
|
frappe.destroy()
|
|
return final_new_path
|
|
|
|
|
|
@click.command('set-password')
|
|
@click.argument('user')
|
|
@click.argument('password', required=False)
|
|
@click.option('--logout-all-sessions', help='Logout from all sessions', is_flag=True, default=False)
|
|
@pass_context
|
|
def set_password(context, user, password=None, logout_all_sessions=False):
|
|
"Set password for a user on a site"
|
|
if not context.sites:
|
|
raise SiteNotSpecifiedError
|
|
|
|
for site in context.sites:
|
|
set_user_password(site, user, password, logout_all_sessions)
|
|
|
|
|
|
@click.command('set-admin-password')
|
|
@click.argument('admin-password', required=False)
|
|
@click.option('--logout-all-sessions', help='Logout from all sessions', is_flag=True, default=False)
|
|
@pass_context
|
|
def set_admin_password(context, admin_password=None, logout_all_sessions=False):
|
|
"Set Administrator password for a site"
|
|
if not context.sites:
|
|
raise SiteNotSpecifiedError
|
|
|
|
for site in context.sites:
|
|
set_user_password(site, "Administrator", admin_password, logout_all_sessions)
|
|
|
|
|
|
def set_user_password(site, user, password, logout_all_sessions=False):
|
|
import getpass
|
|
from frappe.utils.password import update_password
|
|
|
|
try:
|
|
frappe.init(site=site)
|
|
|
|
while not password:
|
|
password = getpass.getpass(f"{user}'s password for {site}: ")
|
|
|
|
frappe.connect()
|
|
if not frappe.db.exists("User", user):
|
|
print(f"User {user} does not exist")
|
|
sys.exit(1)
|
|
|
|
update_password(user=user, pwd=password, logout_all_sessions=logout_all_sessions)
|
|
frappe.db.commit()
|
|
password = None
|
|
finally:
|
|
frappe.destroy()
|
|
|
|
|
|
@click.command('set-last-active-for-user')
|
|
@click.option('--user', help="Setup last active date for user")
|
|
@pass_context
|
|
def set_last_active_for_user(context, user=None):
|
|
"Set users last active date to current datetime"
|
|
|
|
from frappe.core.doctype.user.user import get_system_users
|
|
from frappe.utils.user import set_last_active_to_now
|
|
|
|
site = get_site(context)
|
|
|
|
with frappe.init_site(site):
|
|
frappe.connect()
|
|
if not user:
|
|
user = get_system_users(limit=1)
|
|
if len(user) > 0:
|
|
user = user[0]
|
|
else:
|
|
return
|
|
|
|
set_last_active_to_now(user)
|
|
frappe.db.commit()
|
|
|
|
@click.command('publish-realtime')
|
|
@click.argument('event')
|
|
@click.option('--message')
|
|
@click.option('--room')
|
|
@click.option('--user')
|
|
@click.option('--doctype')
|
|
@click.option('--docname')
|
|
@click.option('--after-commit')
|
|
@pass_context
|
|
def publish_realtime(context, event, message, room, user, doctype, docname, after_commit):
|
|
"Publish realtime event from bench"
|
|
from frappe import publish_realtime
|
|
for site in context.sites:
|
|
try:
|
|
frappe.init(site=site)
|
|
frappe.connect()
|
|
publish_realtime(event, message=message, room=room, user=user, doctype=doctype, docname=docname,
|
|
after_commit=after_commit)
|
|
frappe.db.commit()
|
|
finally:
|
|
frappe.destroy()
|
|
if not context.sites:
|
|
raise SiteNotSpecifiedError
|
|
|
|
@click.command('browse')
|
|
@click.argument('site', required=False)
|
|
@click.option('--user', required=False, help='Login as user')
|
|
@pass_context
|
|
def browse(context, site, user=None):
|
|
'''Opens the site on web browser'''
|
|
from frappe.auth import CookieManager, LoginManager
|
|
|
|
site = get_site(context, raise_err=False) or site
|
|
|
|
if not site:
|
|
raise SiteNotSpecifiedError
|
|
|
|
if site not in frappe.utils.get_sites():
|
|
click.echo(f"\nSite named {click.style(site, bold=True)} doesn't exist\n", err=True)
|
|
sys.exit(1)
|
|
|
|
frappe.init(site=site)
|
|
frappe.connect()
|
|
|
|
sid = ''
|
|
if user:
|
|
if frappe.conf.developer_mode or user == "Administrator":
|
|
frappe.utils.set_request(path="/")
|
|
frappe.local.cookie_manager = CookieManager()
|
|
frappe.local.login_manager = LoginManager()
|
|
frappe.local.login_manager.login_as(user)
|
|
sid = f'/app?sid={frappe.session.sid}'
|
|
else:
|
|
click.echo("Please enable developer mode to login as a user")
|
|
|
|
url = f'{frappe.utils.get_site_url(site)}{sid}'
|
|
|
|
if user == "Administrator":
|
|
click.echo(f'Login URL: {url}')
|
|
|
|
click.launch(url)
|
|
|
|
|
|
@click.command('start-recording')
|
|
@pass_context
|
|
def start_recording(context):
|
|
import frappe.recorder
|
|
for site in context.sites:
|
|
frappe.init(site=site)
|
|
frappe.set_user("Administrator")
|
|
frappe.recorder.start()
|
|
if not context.sites:
|
|
raise SiteNotSpecifiedError
|
|
|
|
|
|
@click.command('stop-recording')
|
|
@pass_context
|
|
def stop_recording(context):
|
|
import frappe.recorder
|
|
for site in context.sites:
|
|
frappe.init(site=site)
|
|
frappe.set_user("Administrator")
|
|
frappe.recorder.stop()
|
|
if not context.sites:
|
|
raise SiteNotSpecifiedError
|
|
|
|
@click.command('ngrok')
|
|
@pass_context
|
|
def start_ngrok(context):
|
|
from pyngrok import ngrok
|
|
|
|
site = get_site(context)
|
|
frappe.init(site=site)
|
|
|
|
port = frappe.conf.http_port or frappe.conf.webserver_port
|
|
tunnel = ngrok.connect(addr=str(port), host_header=site)
|
|
print(f'Public URL: {tunnel.public_url}')
|
|
print('Inspect logs at http://localhost:4040')
|
|
|
|
ngrok_process = ngrok.get_ngrok_process()
|
|
try:
|
|
# Block until CTRL-C or some other terminating event
|
|
ngrok_process.proc.wait()
|
|
except KeyboardInterrupt:
|
|
print("Shutting down server...")
|
|
frappe.destroy()
|
|
ngrok.kill()
|
|
|
|
@click.command('build-search-index')
|
|
@pass_context
|
|
def build_search_index(context):
|
|
from frappe.search.website_search import build_index_for_all_routes
|
|
site = get_site(context)
|
|
if not site:
|
|
raise SiteNotSpecifiedError
|
|
|
|
print('Building search index for {}'.format(site))
|
|
frappe.init(site=site)
|
|
frappe.connect()
|
|
try:
|
|
build_index_for_all_routes()
|
|
finally:
|
|
frappe.destroy()
|
|
|
|
@click.command('trim-database')
|
|
@click.option('--dry-run', is_flag=True, default=False, help='Show what would be deleted')
|
|
@click.option('--format', '-f', default='text', type=click.Choice(['json', 'text']), help='Output format')
|
|
@click.option('--no-backup', is_flag=True, default=False, help='Do not backup the site')
|
|
@pass_context
|
|
def trim_database(context, dry_run, format, no_backup):
|
|
if not context.sites:
|
|
raise SiteNotSpecifiedError
|
|
|
|
from frappe.utils.backups import scheduled_backup
|
|
|
|
ALL_DATA = {}
|
|
|
|
for site in context.sites:
|
|
frappe.init(site=site)
|
|
frappe.connect()
|
|
|
|
TABLES_TO_DROP = []
|
|
STANDARD_TABLES = get_standard_tables()
|
|
information_schema = frappe.qb.Schema("information_schema")
|
|
table_name = frappe.qb.Field("table_name").as_("name")
|
|
|
|
queried_result = frappe.qb.from_(
|
|
information_schema.tables
|
|
).select(table_name).where(
|
|
information_schema.tables.table_schema == frappe.conf.db_name
|
|
).run()
|
|
|
|
database_tables = [x[0] for x in queried_result]
|
|
doctype_tables = frappe.get_all("DocType", pluck="name")
|
|
|
|
for x in database_tables:
|
|
doctype = x.lstrip("tab")
|
|
if not (doctype in doctype_tables or x.startswith("__") or x in STANDARD_TABLES):
|
|
TABLES_TO_DROP.append(x)
|
|
|
|
if not TABLES_TO_DROP:
|
|
if format == "text":
|
|
click.secho(f"No ghost tables found in {frappe.local.site}...Great!", fg="green")
|
|
else:
|
|
if not (no_backup or dry_run):
|
|
if format == "text":
|
|
print(f"Backing Up Tables: {', '.join(TABLES_TO_DROP)}")
|
|
|
|
odb = scheduled_backup(
|
|
ignore_conf=False,
|
|
include_doctypes=",".join(x.lstrip("tab") for x in TABLES_TO_DROP),
|
|
ignore_files=True,
|
|
force=True,
|
|
)
|
|
if format == "text":
|
|
odb.print_summary()
|
|
print("\nTrimming Database")
|
|
|
|
for table in TABLES_TO_DROP:
|
|
if format == "text":
|
|
print(f"* Dropping Table '{table}'...")
|
|
if not dry_run:
|
|
frappe.db.sql_ddl(f"drop table `{table}`")
|
|
|
|
ALL_DATA[frappe.local.site] = TABLES_TO_DROP
|
|
frappe.destroy()
|
|
|
|
if format == "json":
|
|
import json
|
|
print(json.dumps(ALL_DATA, indent=1))
|
|
|
|
|
|
def get_standard_tables():
|
|
import re
|
|
|
|
tables = []
|
|
sql_file = os.path.join(
|
|
"..", "apps", "frappe", "frappe", "database", frappe.conf.db_type, f'framework_{frappe.conf.db_type}.sql'
|
|
)
|
|
content = open(sql_file).read().splitlines()
|
|
|
|
for line in content:
|
|
table_found = re.search(r"""CREATE TABLE ("|`)(.*)?("|`) \(""", line)
|
|
if table_found:
|
|
tables.append(table_found.group(2))
|
|
|
|
return tables
|
|
|
|
@click.command('trim-tables')
|
|
@click.option('--dry-run', is_flag=True, default=False, help='Show what would be deleted')
|
|
@click.option('--format', '-f', default='table', type=click.Choice(['json', 'table']), help='Output format')
|
|
@click.option('--no-backup', is_flag=True, default=False, help='Do not backup the site')
|
|
@pass_context
|
|
def trim_tables(context, dry_run, format, no_backup):
|
|
if not context.sites:
|
|
raise SiteNotSpecifiedError
|
|
|
|
from frappe.model.meta import trim_tables
|
|
from frappe.utils.backups import scheduled_backup
|
|
|
|
for site in context.sites:
|
|
frappe.init(site=site)
|
|
frappe.connect()
|
|
|
|
if not (no_backup or dry_run):
|
|
click.secho(f"Taking backup for {frappe.local.site}", fg="green")
|
|
odb = scheduled_backup(ignore_files=False, force=True)
|
|
odb.print_summary()
|
|
|
|
try:
|
|
trimmed_data = trim_tables(dry_run=dry_run, quiet=format == 'json')
|
|
|
|
if format == 'table' and not dry_run:
|
|
click.secho(f"The following data have been removed from {frappe.local.site}", fg='green')
|
|
|
|
handle_data(trimmed_data, format=format)
|
|
finally:
|
|
frappe.destroy()
|
|
|
|
def handle_data(data: dict, format='json'):
|
|
if format == 'json':
|
|
import json
|
|
print(json.dumps({frappe.local.site: data}, indent=1, sort_keys=True))
|
|
else:
|
|
from frappe.utils.commands import render_table
|
|
data = [["DocType", "Fields"]] + [[table, ", ".join(columns)] for table, columns in data.items()]
|
|
render_table(data)
|
|
|
|
|
|
commands = [
|
|
add_system_manager,
|
|
backup,
|
|
drop_site,
|
|
install_app,
|
|
list_apps,
|
|
migrate,
|
|
migrate_to,
|
|
new_site,
|
|
reinstall,
|
|
reload_doc,
|
|
reload_doctype,
|
|
remove_from_installed_apps,
|
|
restore,
|
|
run_patch,
|
|
set_password,
|
|
set_admin_password,
|
|
uninstall,
|
|
disable_user,
|
|
_use,
|
|
set_last_active_for_user,
|
|
publish_realtime,
|
|
browse,
|
|
start_recording,
|
|
stop_recording,
|
|
add_to_hosts,
|
|
start_ngrok,
|
|
build_search_index,
|
|
partial_restore,
|
|
trim_tables,
|
|
trim_database,
|
|
]
|