Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 
 
 
 

1311 linhas
35 KiB

  1. # imports - standard imports
  2. import os
  3. import shutil
  4. import sys
  5. # imports - third party imports
  6. import click
  7. # imports - module imports
  8. import frappe
  9. from frappe.commands import get_site, pass_context
  10. from frappe.core.doctype.log_settings.log_settings import LOG_DOCTYPES
  11. from frappe.exceptions import SiteNotSpecifiedError
  12. @click.command("new-site")
  13. @click.argument("site")
  14. @click.option("--db-name", help="Database name")
  15. @click.option("--db-password", help="Database password")
  16. @click.option(
  17. "--db-type",
  18. default="mariadb",
  19. type=click.Choice(["mariadb", "postgres"]),
  20. help='Optional "postgres" or "mariadb". Default is "mariadb"',
  21. )
  22. @click.option("--db-host", help="Database Host")
  23. @click.option("--db-port", type=int, help="Database Port")
  24. @click.option(
  25. "--db-root-username",
  26. "--mariadb-root-username",
  27. help='Root username for MariaDB or PostgreSQL, Default is "root"',
  28. )
  29. @click.option(
  30. "--db-root-password", "--mariadb-root-password", help="Root password for MariaDB or PostgreSQL"
  31. )
  32. @click.option(
  33. "--no-mariadb-socket",
  34. is_flag=True,
  35. default=False,
  36. help="Set MariaDB host to % and use TCP/IP Socket instead of using the UNIX Socket",
  37. )
  38. @click.option("--admin-password", help="Administrator password for new site", default=None)
  39. @click.option("--verbose", is_flag=True, default=False, help="Verbose")
  40. @click.option(
  41. "--force", help="Force restore if site/database already exists", is_flag=True, default=False
  42. )
  43. @click.option("--source_sql", help="Initiate database with a SQL file")
  44. @click.option("--install-app", multiple=True, help="Install app after installation")
  45. @click.option(
  46. "--set-default", is_flag=True, default=False, help="Set the new site as default site"
  47. )
  48. def new_site(
  49. site,
  50. db_root_username=None,
  51. db_root_password=None,
  52. admin_password=None,
  53. verbose=False,
  54. source_sql=None,
  55. force=None,
  56. no_mariadb_socket=False,
  57. install_app=None,
  58. db_name=None,
  59. db_password=None,
  60. db_type=None,
  61. db_host=None,
  62. db_port=None,
  63. set_default=False,
  64. ):
  65. "Create a new site"
  66. from frappe.installer import _new_site
  67. frappe.init(site=site, new_site=True)
  68. _new_site(
  69. db_name,
  70. site,
  71. db_root_username=db_root_username,
  72. db_root_password=db_root_password,
  73. admin_password=admin_password,
  74. verbose=verbose,
  75. install_apps=install_app,
  76. source_sql=source_sql,
  77. force=force,
  78. no_mariadb_socket=no_mariadb_socket,
  79. db_password=db_password,
  80. db_type=db_type,
  81. db_host=db_host,
  82. db_port=db_port,
  83. )
  84. if set_default:
  85. use(site)
  86. @click.command("restore")
  87. @click.argument("sql-file-path")
  88. @click.option(
  89. "--db-root-username",
  90. "--mariadb-root-username",
  91. help='Root username for MariaDB or PostgreSQL, Default is "root"',
  92. )
  93. @click.option(
  94. "--db-root-password", "--mariadb-root-password", help="Root password for MariaDB or PostgreSQL"
  95. )
  96. @click.option("--db-name", help="Database name for site in case it is a new one")
  97. @click.option("--admin-password", help="Administrator password for new site")
  98. @click.option("--install-app", multiple=True, help="Install app after installation")
  99. @click.option(
  100. "--with-public-files", help="Restores the public files of the site, given path to its tar file"
  101. )
  102. @click.option(
  103. "--with-private-files", help="Restores the private files of the site, given path to its tar file"
  104. )
  105. @click.option(
  106. "--force",
  107. is_flag=True,
  108. default=False,
  109. help="Ignore the validations and downgrade warnings. This action is not recommended",
  110. )
  111. @click.option("--encryption-key", help="Backup encryption key")
  112. @pass_context
  113. def restore(
  114. context,
  115. sql_file_path,
  116. encryption_key=None,
  117. db_root_username=None,
  118. db_root_password=None,
  119. db_name=None,
  120. verbose=None,
  121. install_app=None,
  122. admin_password=None,
  123. force=None,
  124. with_public_files=None,
  125. with_private_files=None,
  126. ):
  127. "Restore site database from an sql file"
  128. from frappe.installer import (
  129. _new_site,
  130. extract_files,
  131. extract_sql_from_archive,
  132. is_downgrade,
  133. is_partial,
  134. validate_database_sql,
  135. )
  136. from frappe.utils.backups import Backup, get_or_generate_backup_encryption_key
  137. _backup = Backup(sql_file_path)
  138. site = get_site(context)
  139. frappe.init(site=site)
  140. force = context.force or force
  141. try:
  142. decompressed_file_name = extract_sql_from_archive(sql_file_path)
  143. if is_partial(decompressed_file_name):
  144. click.secho(
  145. "Partial Backup file detected. You cannot use a partial file to restore a Frappe site.",
  146. fg="red",
  147. )
  148. click.secho(
  149. "Use `bench partial-restore` to restore a partial backup to an existing site.", fg="yellow"
  150. )
  151. _backup.decryption_rollback()
  152. sys.exit(1)
  153. except UnicodeDecodeError:
  154. _backup.decryption_rollback()
  155. if encryption_key:
  156. click.secho("Encrypted backup file detected. Decrypting using provided key.", fg="yellow")
  157. _backup.backup_decryption(encryption_key)
  158. else:
  159. click.secho("Encrypted backup file detected. Decrypting using site config.", fg="yellow")
  160. encryption_key = get_or_generate_backup_encryption_key()
  161. _backup.backup_decryption(encryption_key)
  162. # Rollback on unsuccessful decryrption
  163. if not os.path.exists(sql_file_path):
  164. click.secho("Decryption failed. Please provide a valid key and try again.", fg="red")
  165. _backup.decryption_rollback()
  166. sys.exit(1)
  167. decompressed_file_name = extract_sql_from_archive(sql_file_path)
  168. if is_partial(decompressed_file_name):
  169. click.secho(
  170. "Partial Backup file detected. You cannot use a partial file to restore a Frappe site.",
  171. fg="red",
  172. )
  173. click.secho(
  174. "Use `bench partial-restore` to restore a partial backup to an existing site.", fg="yellow"
  175. )
  176. _backup.decryption_rollback()
  177. sys.exit(1)
  178. validate_database_sql(decompressed_file_name, _raise=not force)
  179. # dont allow downgrading to older versions of frappe without force
  180. if not force and is_downgrade(decompressed_file_name, verbose=True):
  181. warn_message = (
  182. "This is not recommended and may lead to unexpected behaviour. "
  183. "Do you want to continue anyway?"
  184. )
  185. click.confirm(warn_message, abort=True)
  186. try:
  187. _new_site(
  188. frappe.conf.db_name,
  189. site,
  190. db_root_username=db_root_username,
  191. db_root_password=db_root_password,
  192. admin_password=admin_password,
  193. verbose=context.verbose,
  194. install_apps=install_app,
  195. source_sql=decompressed_file_name,
  196. force=True,
  197. db_type=frappe.conf.db_type,
  198. )
  199. except Exception as err:
  200. print(err.args[1])
  201. _backup.decryption_rollback()
  202. sys.exit(1)
  203. # Removing temporarily created file
  204. if decompressed_file_name != sql_file_path:
  205. os.remove(decompressed_file_name)
  206. _backup.decryption_rollback()
  207. # Extract public and/or private files to the restored site, if user has given the path
  208. if with_public_files:
  209. # Decrypt data if there is a Key
  210. if encryption_key:
  211. _backup = Backup(with_public_files)
  212. _backup.backup_decryption(encryption_key)
  213. if not os.path.exists(with_public_files):
  214. _backup.decryption_rollback()
  215. public = extract_files(site, with_public_files)
  216. # Removing temporarily created file
  217. os.remove(public)
  218. _backup.decryption_rollback()
  219. if with_private_files:
  220. # Decrypt data if there is a Key
  221. if encryption_key:
  222. _backup = Backup(with_private_files)
  223. _backup.backup_decryption(encryption_key)
  224. if not os.path.exists(with_private_files):
  225. _backup.decryption_rollback()
  226. private = extract_files(site, with_private_files)
  227. # Removing temporarily created file
  228. os.remove(private)
  229. _backup.decryption_rollback()
  230. success_message = "Site {} has been restored{}".format(
  231. site, " with files" if (with_public_files or with_private_files) else ""
  232. )
  233. click.secho(success_message, fg="green")
  234. @click.command("partial-restore")
  235. @click.argument("sql-file-path")
  236. @click.option("--verbose", "-v", is_flag=True)
  237. @click.option("--encryption-key", help="Backup encryption key")
  238. @pass_context
  239. def partial_restore(context, sql_file_path, verbose, encryption_key=None):
  240. from frappe.installer import extract_sql_from_archive, partial_restore
  241. from frappe.utils.backups import Backup, get_or_generate_backup_encryption_key
  242. if not os.path.exists(sql_file_path):
  243. print("Invalid path", sql_file_path)
  244. sys.exit(1)
  245. site = get_site(context)
  246. frappe.init(site=site)
  247. _backup = Backup(sql_file_path)
  248. verbose = context.verbose or verbose
  249. frappe.connect(site=site)
  250. try:
  251. decompressed_file_name = extract_sql_from_archive(sql_file_path)
  252. with open(decompressed_file_name) as f:
  253. header = " ".join(f.readline() for _ in range(5))
  254. # Check for full backup file
  255. if "Partial Backup" not in header:
  256. click.secho(
  257. "Full backup file detected.Use `bench restore` to restore a Frappe Site.", fg="red"
  258. )
  259. _backup.decryption_rollback()
  260. sys.exit(1)
  261. except UnicodeDecodeError:
  262. _backup.decryption_rollback()
  263. if encryption_key:
  264. click.secho("Encrypted backup file detected. Decrypting using provided key.", fg="yellow")
  265. key = encryption_key
  266. else:
  267. click.secho("Encrypted backup file detected. Decrypting using site config.", fg="yellow")
  268. key = get_or_generate_backup_encryption_key()
  269. _backup.backup_decryption(key)
  270. # Rollback on unsuccessful decryrption
  271. if not os.path.exists(sql_file_path):
  272. click.secho("Decryption failed. Please provide a valid key and try again.", fg="red")
  273. _backup.decryption_rollback()
  274. sys.exit(1)
  275. decompressed_file_name = extract_sql_from_archive(sql_file_path)
  276. with open(decompressed_file_name) as f:
  277. header = " ".join(f.readline() for _ in range(5))
  278. # Check for Full backup file.
  279. if "Partial Backup" not in header:
  280. click.secho(
  281. "Full Backup file detected.Use `bench restore` to restore a Frappe Site.", fg="red"
  282. )
  283. _backup.decryption_rollback()
  284. sys.exit(1)
  285. partial_restore(sql_file_path, verbose)
  286. # Removing temporarily created file
  287. _backup.decryption_rollback()
  288. if os.path.exists(sql_file_path.rstrip(".gz")):
  289. os.remove(sql_file_path.rstrip(".gz"))
  290. frappe.destroy()
  291. @click.command("reinstall")
  292. @click.option("--admin-password", help="Administrator Password for reinstalled site")
  293. @click.option(
  294. "--db-root-username",
  295. "--mariadb-root-username",
  296. help='Root username for MariaDB or PostgreSQL, Default is "root"',
  297. )
  298. @click.option(
  299. "--db-root-password", "--mariadb-root-password", help="Root password for MariaDB or PostgreSQL"
  300. )
  301. @click.option("--yes", is_flag=True, default=False, help="Pass --yes to skip confirmation")
  302. @pass_context
  303. def reinstall(
  304. context, admin_password=None, db_root_username=None, db_root_password=None, yes=False
  305. ):
  306. "Reinstall site ie. wipe all data and start over"
  307. site = get_site(context)
  308. _reinstall(site, admin_password, db_root_username, db_root_password, yes, verbose=context.verbose)
  309. def _reinstall(
  310. site, admin_password=None, db_root_username=None, db_root_password=None, yes=False, verbose=False
  311. ):
  312. from frappe.installer import _new_site
  313. if not yes:
  314. click.confirm("This will wipe your database. Are you sure you want to reinstall?", abort=True)
  315. try:
  316. frappe.init(site=site)
  317. frappe.connect()
  318. frappe.clear_cache()
  319. installed = frappe.get_installed_apps()
  320. frappe.clear_cache()
  321. except Exception:
  322. installed = []
  323. finally:
  324. if frappe.db:
  325. frappe.db.close()
  326. frappe.destroy()
  327. frappe.init(site=site)
  328. _new_site(
  329. frappe.conf.db_name,
  330. site,
  331. verbose=verbose,
  332. force=True,
  333. reinstall=True,
  334. install_apps=installed,
  335. db_root_username=db_root_username,
  336. db_root_password=db_root_password,
  337. admin_password=admin_password,
  338. )
  339. @click.command("install-app")
  340. @click.argument("apps", nargs=-1)
  341. @click.option("--force", is_flag=True, default=False)
  342. @pass_context
  343. def install_app(context, apps, force=False):
  344. "Install a new app to site, supports multiple apps"
  345. from frappe.installer import install_app as _install_app
  346. exit_code = 0
  347. if not context.sites:
  348. raise SiteNotSpecifiedError
  349. for site in context.sites:
  350. frappe.init(site=site)
  351. frappe.connect()
  352. for app in apps:
  353. try:
  354. _install_app(app, verbose=context.verbose, force=force)
  355. except frappe.IncompatibleApp as err:
  356. err_msg = f":\n{err}" if str(err) else ""
  357. print(f"App {app} is Incompatible with Site {site}{err_msg}")
  358. exit_code = 1
  359. except Exception as err:
  360. err_msg = f": {str(err)}\n{frappe.get_traceback()}"
  361. print(f"An error occurred while installing {app}{err_msg}")
  362. exit_code = 1
  363. frappe.destroy()
  364. sys.exit(exit_code)
  365. @click.command("list-apps")
  366. @click.option("--format", "-f", type=click.Choice(["text", "json"]), default="text")
  367. @pass_context
  368. def list_apps(context, format):
  369. "List apps in site"
  370. summary_dict = {}
  371. def fix_whitespaces(text):
  372. if site == context.sites[-1]:
  373. text = text.rstrip()
  374. if len(context.sites) == 1:
  375. text = text.lstrip()
  376. return text
  377. for site in context.sites:
  378. frappe.init(site=site)
  379. frappe.connect()
  380. site_title = click.style(f"{site}", fg="green") if len(context.sites) > 1 else ""
  381. apps = frappe.get_single("Installed Applications").installed_applications
  382. if apps:
  383. name_len, ver_len = (max(len(x.get(y)) for x in apps) for y in ["app_name", "app_version"])
  384. template = f"{{0:{name_len}}} {{1:{ver_len}}} {{2}}"
  385. installed_applications = [
  386. template.format(app.app_name, app.app_version, app.git_branch) for app in apps
  387. ]
  388. applications_summary = "\n".join(installed_applications)
  389. summary = f"{site_title}\n{applications_summary}\n"
  390. summary_dict[site] = [app.app_name for app in apps]
  391. else:
  392. installed_applications = frappe.get_installed_apps()
  393. applications_summary = "\n".join(installed_applications)
  394. summary = f"{site_title}\n{applications_summary}\n"
  395. summary_dict[site] = installed_applications
  396. summary = fix_whitespaces(summary)
  397. if format == "text" and applications_summary and summary:
  398. print(summary)
  399. frappe.destroy()
  400. if format == "json":
  401. click.echo(frappe.as_json(summary_dict))
  402. @click.command("add-system-manager")
  403. @click.argument("email")
  404. @click.option("--first-name")
  405. @click.option("--last-name")
  406. @click.option("--password")
  407. @click.option("--send-welcome-email", default=False, is_flag=True)
  408. @pass_context
  409. def add_system_manager(context, email, first_name, last_name, send_welcome_email, password):
  410. "Add a new system manager to a site"
  411. import frappe.utils.user
  412. for site in context.sites:
  413. frappe.connect(site=site)
  414. try:
  415. frappe.utils.user.add_system_manager(email, first_name, last_name, send_welcome_email, password)
  416. frappe.db.commit()
  417. finally:
  418. frappe.destroy()
  419. if not context.sites:
  420. raise SiteNotSpecifiedError
  421. @click.command("disable-user")
  422. @click.argument("email")
  423. @pass_context
  424. def disable_user(context, email):
  425. site = get_site(context)
  426. with frappe.init_site(site):
  427. frappe.connect()
  428. user = frappe.get_doc("User", email)
  429. user.enabled = 0
  430. user.save(ignore_permissions=True)
  431. frappe.db.commit()
  432. @click.command("migrate")
  433. @click.option("--skip-failing", is_flag=True, help="Skip patches that fail to run")
  434. @click.option("--skip-search-index", is_flag=True, help="Skip search indexing for web documents")
  435. @pass_context
  436. def migrate(context, skip_failing=False, skip_search_index=False):
  437. "Run patches, sync schema and rebuild files/translations"
  438. from frappe.migrate import SiteMigration
  439. for site in context.sites:
  440. click.secho(f"Migrating {site}", fg="green")
  441. try:
  442. SiteMigration(
  443. skip_failing=skip_failing,
  444. skip_search_index=skip_search_index,
  445. ).run(site=site)
  446. finally:
  447. print()
  448. if not context.sites:
  449. raise SiteNotSpecifiedError
  450. @click.command("migrate-to")
  451. @click.argument("frappe_provider")
  452. @pass_context
  453. def migrate_to(context, frappe_provider):
  454. "Migrates site to the specified provider"
  455. from frappe.integrations.frappe_providers import migrate_to
  456. for site in context.sites:
  457. frappe.init(site=site)
  458. frappe.connect()
  459. migrate_to(site, frappe_provider)
  460. frappe.destroy()
  461. if not context.sites:
  462. raise SiteNotSpecifiedError
  463. @click.command("run-patch")
  464. @click.argument("module")
  465. @click.option("--force", is_flag=True)
  466. @pass_context
  467. def run_patch(context, module, force):
  468. "Run a particular patch"
  469. import frappe.modules.patch_handler
  470. for site in context.sites:
  471. frappe.init(site=site)
  472. try:
  473. frappe.connect()
  474. frappe.modules.patch_handler.run_single(module, force=force or context.force)
  475. finally:
  476. frappe.destroy()
  477. if not context.sites:
  478. raise SiteNotSpecifiedError
  479. @click.command("reload-doc")
  480. @click.argument("module")
  481. @click.argument("doctype")
  482. @click.argument("docname")
  483. @pass_context
  484. def reload_doc(context, module, doctype, docname):
  485. "Reload schema for a DocType"
  486. for site in context.sites:
  487. try:
  488. frappe.init(site=site)
  489. frappe.connect()
  490. frappe.reload_doc(module, doctype, docname, force=context.force)
  491. frappe.db.commit()
  492. finally:
  493. frappe.destroy()
  494. if not context.sites:
  495. raise SiteNotSpecifiedError
  496. @click.command("reload-doctype")
  497. @click.argument("doctype")
  498. @pass_context
  499. def reload_doctype(context, doctype):
  500. "Reload schema for a DocType"
  501. for site in context.sites:
  502. try:
  503. frappe.init(site=site)
  504. frappe.connect()
  505. frappe.reload_doctype(doctype, force=context.force)
  506. frappe.db.commit()
  507. finally:
  508. frappe.destroy()
  509. if not context.sites:
  510. raise SiteNotSpecifiedError
  511. @click.command("add-to-hosts")
  512. @pass_context
  513. def add_to_hosts(context):
  514. "Add site to hosts"
  515. for site in context.sites:
  516. frappe.commands.popen(f"echo 127.0.0.1\t{site} | sudo tee -a /etc/hosts")
  517. if not context.sites:
  518. raise SiteNotSpecifiedError
  519. @click.command("use")
  520. @click.argument("site")
  521. def _use(site, sites_path="."):
  522. "Set a default site"
  523. use(site, sites_path=sites_path)
  524. def use(site, sites_path="."):
  525. if os.path.exists(os.path.join(sites_path, site)):
  526. with open(os.path.join(sites_path, "currentsite.txt"), "w") as sitefile:
  527. sitefile.write(site)
  528. print(f"Current Site set to {site}")
  529. else:
  530. print(f"Site {site} does not exist")
  531. @click.command("backup")
  532. @click.option("--with-files", default=False, is_flag=True, help="Take backup with files")
  533. @click.option(
  534. "--include",
  535. "--only",
  536. "-i",
  537. default="",
  538. type=str,
  539. help="Specify the DocTypes to backup seperated by commas",
  540. )
  541. @click.option(
  542. "--exclude",
  543. "-e",
  544. default="",
  545. type=str,
  546. help="Specify the DocTypes to not backup seperated by commas",
  547. )
  548. @click.option(
  549. "--backup-path", default=None, help="Set path for saving all the files in this operation"
  550. )
  551. @click.option("--backup-path-db", default=None, help="Set path for saving database file")
  552. @click.option("--backup-path-files", default=None, help="Set path for saving public file")
  553. @click.option("--backup-path-private-files", default=None, help="Set path for saving private file")
  554. @click.option("--backup-path-conf", default=None, help="Set path for saving config file")
  555. @click.option(
  556. "--ignore-backup-conf", default=False, is_flag=True, help="Ignore excludes/includes set in config"
  557. )
  558. @click.option("--verbose", default=False, is_flag=True, help="Add verbosity")
  559. @click.option("--compress", default=False, is_flag=True, help="Compress private and public files")
  560. @pass_context
  561. def backup(
  562. context,
  563. with_files=False,
  564. backup_path=None,
  565. backup_path_db=None,
  566. backup_path_files=None,
  567. backup_path_private_files=None,
  568. backup_path_conf=None,
  569. ignore_backup_conf=False,
  570. verbose=False,
  571. compress=False,
  572. include="",
  573. exclude="",
  574. ):
  575. "Backup"
  576. from frappe.utils.backups import scheduled_backup
  577. verbose = verbose or context.verbose
  578. exit_code = 0
  579. for site in context.sites:
  580. try:
  581. frappe.init(site=site)
  582. frappe.connect()
  583. odb = scheduled_backup(
  584. ignore_files=not with_files,
  585. backup_path=backup_path,
  586. backup_path_db=backup_path_db,
  587. backup_path_files=backup_path_files,
  588. backup_path_private_files=backup_path_private_files,
  589. backup_path_conf=backup_path_conf,
  590. ignore_conf=ignore_backup_conf,
  591. include_doctypes=include,
  592. exclude_doctypes=exclude,
  593. compress=compress,
  594. verbose=verbose,
  595. force=True,
  596. )
  597. except Exception:
  598. click.secho(
  599. f"Backup failed for Site {site}. Database or site_config.json may be corrupted",
  600. fg="red",
  601. )
  602. if verbose:
  603. print(frappe.get_traceback())
  604. exit_code = 1
  605. continue
  606. if frappe.get_system_settings("encrypt_backup") and frappe.get_site_config().encryption_key:
  607. click.secho(
  608. "Backup encryption is turned on. Please note the backup encryption key.", fg="yellow"
  609. )
  610. odb.print_summary()
  611. click.secho(
  612. "Backup for Site {} has been successfully completed{}".format(
  613. site, " with files" if with_files else ""
  614. ),
  615. fg="green",
  616. )
  617. frappe.destroy()
  618. if not context.sites:
  619. raise SiteNotSpecifiedError
  620. sys.exit(exit_code)
  621. @click.command("remove-from-installed-apps")
  622. @click.argument("app")
  623. @pass_context
  624. def remove_from_installed_apps(context, app):
  625. "Remove app from site's installed-apps list"
  626. from frappe.installer import remove_from_installed_apps
  627. for site in context.sites:
  628. try:
  629. frappe.init(site=site)
  630. frappe.connect()
  631. remove_from_installed_apps(app)
  632. finally:
  633. frappe.destroy()
  634. if not context.sites:
  635. raise SiteNotSpecifiedError
  636. @click.command("uninstall-app")
  637. @click.argument("app")
  638. @click.option(
  639. "--yes",
  640. "-y",
  641. help="To bypass confirmation prompt for uninstalling the app",
  642. is_flag=True,
  643. default=False,
  644. )
  645. @click.option(
  646. "--dry-run", help="List all doctypes that will be deleted", is_flag=True, default=False
  647. )
  648. @click.option("--no-backup", help="Do not backup the site", is_flag=True, default=False)
  649. @click.option("--force", help="Force remove app from site", is_flag=True, default=False)
  650. @pass_context
  651. def uninstall(context, app, dry_run, yes, no_backup, force):
  652. "Remove app and linked modules from site"
  653. from frappe.installer import remove_app
  654. for site in context.sites:
  655. try:
  656. frappe.init(site=site)
  657. frappe.connect()
  658. remove_app(app_name=app, dry_run=dry_run, yes=yes, no_backup=no_backup, force=force)
  659. finally:
  660. frappe.destroy()
  661. if not context.sites:
  662. raise SiteNotSpecifiedError
  663. @click.command("drop-site")
  664. @click.argument("site")
  665. @click.option(
  666. "--db-root-username",
  667. "--mariadb-root-username",
  668. "--root-login",
  669. help='Root username for MariaDB or PostgreSQL, Default is "root"',
  670. )
  671. @click.option(
  672. "--db-root-password",
  673. "--mariadb-root-password",
  674. "--root-password",
  675. help="Root password for MariaDB or PostgreSQL",
  676. )
  677. @click.option("--archived-sites-path")
  678. @click.option("--no-backup", is_flag=True, default=False)
  679. @click.option(
  680. "--force", help="Force drop-site even if an error is encountered", is_flag=True, default=False
  681. )
  682. def drop_site(
  683. site,
  684. db_root_username="root",
  685. db_root_password=None,
  686. archived_sites_path=None,
  687. force=False,
  688. no_backup=False,
  689. ):
  690. _drop_site(site, db_root_username, db_root_password, archived_sites_path, force, no_backup)
  691. def _drop_site(
  692. site,
  693. db_root_username=None,
  694. db_root_password=None,
  695. archived_sites_path=None,
  696. force=False,
  697. no_backup=False,
  698. ):
  699. "Remove site from database and filesystem"
  700. from frappe.database import drop_user_and_database
  701. from frappe.utils.backups import scheduled_backup
  702. frappe.init(site=site)
  703. frappe.connect()
  704. try:
  705. if not no_backup:
  706. click.secho(f"Taking backup of {site}", fg="green")
  707. odb = scheduled_backup(ignore_files=False, ignore_conf=True, force=True, verbose=True)
  708. odb.print_summary()
  709. except Exception as err:
  710. if force:
  711. pass
  712. else:
  713. messages = [
  714. "=" * 80,
  715. f"Error: The operation has stopped because backup of {site}'s database failed.",
  716. f"Reason: {str(err)}\n",
  717. "Fix the issue and try again.",
  718. "Hint: Use 'bench drop-site {0} --force' to force the removal of {0}".format(site),
  719. ]
  720. click.echo("\n".join(messages))
  721. sys.exit(1)
  722. click.secho("Dropping site database and user", fg="green")
  723. drop_user_and_database(frappe.conf.db_name, db_root_username, db_root_password)
  724. archived_sites_path = archived_sites_path or os.path.join(
  725. frappe.get_app_path("frappe"), "..", "..", "..", "archived", "sites"
  726. )
  727. archived_sites_path = os.path.realpath(archived_sites_path)
  728. click.secho(f"Moving site to archive under {archived_sites_path}", fg="green")
  729. os.makedirs(archived_sites_path, exist_ok=True)
  730. move(archived_sites_path, site)
  731. def move(dest_dir, site):
  732. if not os.path.isdir(dest_dir):
  733. raise Exception("destination is not a directory or does not exist")
  734. frappe.init(site)
  735. old_path = frappe.utils.get_site_path()
  736. new_path = os.path.join(dest_dir, site)
  737. # check if site dump of same name already exists
  738. site_dump_exists = True
  739. count = 0
  740. while site_dump_exists:
  741. final_new_path = new_path + (count and str(count) or "")
  742. site_dump_exists = os.path.exists(final_new_path)
  743. count = int(count or 0) + 1
  744. shutil.move(old_path, final_new_path)
  745. frappe.destroy()
  746. return final_new_path
  747. @click.command("set-password")
  748. @click.argument("user")
  749. @click.argument("password", required=False)
  750. @click.option(
  751. "--logout-all-sessions", help="Log out from all sessions", is_flag=True, default=False
  752. )
  753. @pass_context
  754. def set_password(context, user, password=None, logout_all_sessions=False):
  755. "Set password for a user on a site"
  756. if not context.sites:
  757. raise SiteNotSpecifiedError
  758. for site in context.sites:
  759. set_user_password(site, user, password, logout_all_sessions)
  760. @click.command("set-admin-password")
  761. @click.argument("admin-password", required=False)
  762. @click.option(
  763. "--logout-all-sessions", help="Log out from all sessions", is_flag=True, default=False
  764. )
  765. @pass_context
  766. def set_admin_password(context, admin_password=None, logout_all_sessions=False):
  767. "Set Administrator password for a site"
  768. if not context.sites:
  769. raise SiteNotSpecifiedError
  770. for site in context.sites:
  771. set_user_password(site, "Administrator", admin_password, logout_all_sessions)
  772. def set_user_password(site, user, password, logout_all_sessions=False):
  773. import getpass
  774. from frappe.utils.password import update_password
  775. try:
  776. frappe.init(site=site)
  777. while not password:
  778. password = getpass.getpass(f"{user}'s password for {site}: ")
  779. frappe.connect()
  780. if not frappe.db.exists("User", user):
  781. print(f"User {user} does not exist")
  782. sys.exit(1)
  783. update_password(user=user, pwd=password, logout_all_sessions=logout_all_sessions)
  784. frappe.db.commit()
  785. finally:
  786. frappe.destroy()
  787. @click.command("set-last-active-for-user")
  788. @click.option("--user", help="Setup last active date for user")
  789. @pass_context
  790. def set_last_active_for_user(context, user=None):
  791. "Set users last active date to current datetime"
  792. from frappe.core.doctype.user.user import get_system_users
  793. from frappe.utils import now_datetime
  794. site = get_site(context)
  795. with frappe.init_site(site):
  796. frappe.connect()
  797. if not user:
  798. user = get_system_users(limit=1)
  799. if len(user) > 0:
  800. user = user[0]
  801. else:
  802. return
  803. frappe.db.set_value("User", user, "last_active", now_datetime())
  804. frappe.db.commit()
  805. @click.command("publish-realtime")
  806. @click.argument("event")
  807. @click.option("--message")
  808. @click.option("--room")
  809. @click.option("--user")
  810. @click.option("--doctype")
  811. @click.option("--docname")
  812. @click.option("--after-commit")
  813. @pass_context
  814. def publish_realtime(context, event, message, room, user, doctype, docname, after_commit):
  815. "Publish realtime event from bench"
  816. from frappe import publish_realtime
  817. for site in context.sites:
  818. try:
  819. frappe.init(site=site)
  820. frappe.connect()
  821. publish_realtime(
  822. event,
  823. message=message,
  824. room=room,
  825. user=user,
  826. doctype=doctype,
  827. docname=docname,
  828. after_commit=after_commit,
  829. )
  830. frappe.db.commit()
  831. finally:
  832. frappe.destroy()
  833. if not context.sites:
  834. raise SiteNotSpecifiedError
  835. @click.command("browse")
  836. @click.argument("site", required=False)
  837. @click.option("--user", required=False, help="Login as user")
  838. @pass_context
  839. def browse(context, site, user=None):
  840. """Opens the site on web browser"""
  841. from frappe.auth import CookieManager, LoginManager
  842. site = get_site(context, raise_err=False) or site
  843. if not site:
  844. raise SiteNotSpecifiedError
  845. if site not in frappe.utils.get_sites():
  846. click.echo(f"\nSite named {click.style(site, bold=True)} doesn't exist\n", err=True)
  847. sys.exit(1)
  848. frappe.init(site=site)
  849. frappe.connect()
  850. sid = ""
  851. if user:
  852. if frappe.conf.developer_mode or user == "Administrator":
  853. frappe.utils.set_request(path="/")
  854. frappe.local.cookie_manager = CookieManager()
  855. frappe.local.login_manager = LoginManager()
  856. frappe.local.login_manager.login_as(user)
  857. sid = f"/app?sid={frappe.session.sid}"
  858. else:
  859. click.echo("Please enable developer mode to login as a user")
  860. url = f"{frappe.utils.get_site_url(site)}{sid}"
  861. if user == "Administrator":
  862. click.echo(f"Login URL: {url}")
  863. click.launch(url)
  864. @click.command("start-recording")
  865. @pass_context
  866. def start_recording(context):
  867. import frappe.recorder
  868. for site in context.sites:
  869. frappe.init(site=site)
  870. frappe.set_user("Administrator")
  871. frappe.recorder.start()
  872. if not context.sites:
  873. raise SiteNotSpecifiedError
  874. @click.command("stop-recording")
  875. @pass_context
  876. def stop_recording(context):
  877. import frappe.recorder
  878. for site in context.sites:
  879. frappe.init(site=site)
  880. frappe.set_user("Administrator")
  881. frappe.recorder.stop()
  882. if not context.sites:
  883. raise SiteNotSpecifiedError
  884. @click.command("ngrok")
  885. @click.option(
  886. "--bind-tls", is_flag=True, default=False, help="Returns a reference to the https tunnel."
  887. )
  888. @pass_context
  889. def start_ngrok(context, bind_tls):
  890. from pyngrok import ngrok
  891. site = get_site(context)
  892. frappe.init(site=site)
  893. port = frappe.conf.http_port or frappe.conf.webserver_port
  894. tunnel = ngrok.connect(addr=str(port), host_header=site, bind_tls=bind_tls)
  895. print(f"Public URL: {tunnel.public_url}")
  896. print("Inspect logs at http://localhost:4040")
  897. ngrok_process = ngrok.get_ngrok_process()
  898. try:
  899. # Block until CTRL-C or some other terminating event
  900. ngrok_process.proc.wait()
  901. except KeyboardInterrupt:
  902. print("Shutting down server...")
  903. frappe.destroy()
  904. ngrok.kill()
  905. @click.command("build-search-index")
  906. @pass_context
  907. def build_search_index(context):
  908. from frappe.search.website_search import build_index_for_all_routes
  909. site = get_site(context)
  910. if not site:
  911. raise SiteNotSpecifiedError
  912. print(f"Building search index for {site}")
  913. frappe.init(site=site)
  914. frappe.connect()
  915. try:
  916. build_index_for_all_routes()
  917. finally:
  918. frappe.destroy()
  919. @click.command("clear-log-table")
  920. @click.option("--doctype", default="text", type=click.Choice(LOG_DOCTYPES), help="Log DocType")
  921. @click.option("--days", type=int, help="Keep records for days")
  922. @click.option("--no-backup", is_flag=True, default=False, help="Do not backup the table")
  923. @pass_context
  924. def clear_log_table(context, doctype, days, no_backup):
  925. """If any logtype table grows too large then clearing it with DELETE query
  926. is not feasible in reasonable time. This command copies recent data to new
  927. table and replaces current table with new smaller table.
  928. ref: https://mariadb.com/kb/en/big-deletes/#deleting-more-than-half-a-table
  929. """
  930. from frappe.core.doctype.log_settings.log_settings import clear_log_table as clear_logs
  931. from frappe.utils.backups import scheduled_backup
  932. if not context.sites:
  933. raise SiteNotSpecifiedError
  934. if doctype not in LOG_DOCTYPES:
  935. raise frappe.ValidationError(f"Unsupported logging DocType: {doctype}")
  936. for site in context.sites:
  937. frappe.init(site=site)
  938. frappe.connect()
  939. if not no_backup:
  940. scheduled_backup(
  941. ignore_conf=False,
  942. include_doctypes=doctype,
  943. ignore_files=True,
  944. force=True,
  945. )
  946. click.echo(f"Backed up {doctype}")
  947. try:
  948. click.echo(f"Copying {doctype} records from last {days} days to temporary table.")
  949. clear_logs(doctype, days=days)
  950. except Exception as e:
  951. click.echo(f"Log cleanup for {doctype} failed:\n{e}")
  952. sys.exit(1)
  953. else:
  954. click.secho(f"Cleared {doctype} records older than {days} days", fg="green")
  955. @click.command("trim-database")
  956. @click.option("--dry-run", is_flag=True, default=False, help="Show what would be deleted")
  957. @click.option(
  958. "--format", "-f", default="text", type=click.Choice(["json", "text"]), help="Output format"
  959. )
  960. @click.option("--no-backup", is_flag=True, default=False, help="Do not backup the site")
  961. @pass_context
  962. def trim_database(context, dry_run, format, no_backup):
  963. if not context.sites:
  964. raise SiteNotSpecifiedError
  965. from frappe.utils.backups import scheduled_backup
  966. ALL_DATA = {}
  967. for site in context.sites:
  968. frappe.init(site=site)
  969. frappe.connect()
  970. TABLES_TO_DROP = []
  971. STANDARD_TABLES = get_standard_tables()
  972. information_schema = frappe.qb.Schema("information_schema")
  973. table_name = frappe.qb.Field("table_name").as_("name")
  974. queried_result = (
  975. frappe.qb.from_(information_schema.tables)
  976. .select(table_name)
  977. .where(information_schema.tables.table_schema == frappe.conf.db_name)
  978. .run()
  979. )
  980. database_tables = [x[0] for x in queried_result]
  981. doctype_tables = frappe.get_all("DocType", pluck="name")
  982. for x in database_tables:
  983. doctype = x.replace("tab", "", 1)
  984. if not (doctype in doctype_tables or x.startswith("__") or x in STANDARD_TABLES):
  985. TABLES_TO_DROP.append(x)
  986. if not TABLES_TO_DROP:
  987. if format == "text":
  988. click.secho(f"No ghost tables found in {frappe.local.site}...Great!", fg="green")
  989. else:
  990. if not (no_backup or dry_run):
  991. if format == "text":
  992. print(f"Backing Up Tables: {', '.join(TABLES_TO_DROP)}")
  993. odb = scheduled_backup(
  994. ignore_conf=False,
  995. include_doctypes=",".join(x.replace("tab", "", 1) for x in TABLES_TO_DROP),
  996. ignore_files=True,
  997. force=True,
  998. )
  999. if format == "text":
  1000. odb.print_summary()
  1001. print("\nTrimming Database")
  1002. for table in TABLES_TO_DROP:
  1003. if format == "text":
  1004. print(f"* Dropping Table '{table}'...")
  1005. if not dry_run:
  1006. frappe.db.sql_ddl(f"drop table `{table}`")
  1007. ALL_DATA[frappe.local.site] = TABLES_TO_DROP
  1008. frappe.destroy()
  1009. if format == "json":
  1010. import json
  1011. print(json.dumps(ALL_DATA, indent=1))
  1012. def get_standard_tables():
  1013. import re
  1014. tables = []
  1015. sql_file = os.path.join(
  1016. "..",
  1017. "apps",
  1018. "frappe",
  1019. "frappe",
  1020. "database",
  1021. frappe.conf.db_type,
  1022. f"framework_{frappe.conf.db_type}.sql",
  1023. )
  1024. content = open(sql_file).read().splitlines()
  1025. for line in content:
  1026. table_found = re.search(r"""CREATE TABLE ("|`)(.*)?("|`) \(""", line)
  1027. if table_found:
  1028. tables.append(table_found.group(2))
  1029. return tables
  1030. @click.command("trim-tables")
  1031. @click.option("--dry-run", is_flag=True, default=False, help="Show what would be deleted")
  1032. @click.option(
  1033. "--format", "-f", default="table", type=click.Choice(["json", "table"]), help="Output format"
  1034. )
  1035. @click.option("--no-backup", is_flag=True, default=False, help="Do not backup the site")
  1036. @pass_context
  1037. def trim_tables(context, dry_run, format, no_backup):
  1038. if not context.sites:
  1039. raise SiteNotSpecifiedError
  1040. from frappe.model.meta import trim_tables
  1041. from frappe.utils.backups import scheduled_backup
  1042. for site in context.sites:
  1043. frappe.init(site=site)
  1044. frappe.connect()
  1045. if not (no_backup or dry_run):
  1046. click.secho(f"Taking backup for {frappe.local.site}", fg="green")
  1047. odb = scheduled_backup(ignore_files=False, force=True)
  1048. odb.print_summary()
  1049. try:
  1050. trimmed_data = trim_tables(dry_run=dry_run, quiet=format == "json")
  1051. if format == "table" and not dry_run:
  1052. click.secho(f"The following data have been removed from {frappe.local.site}", fg="green")
  1053. handle_data(trimmed_data, format=format)
  1054. finally:
  1055. frappe.destroy()
  1056. def handle_data(data: dict, format="json"):
  1057. if format == "json":
  1058. import json
  1059. print(json.dumps({frappe.local.site: data}, indent=1, sort_keys=True))
  1060. else:
  1061. from frappe.utils.commands import render_table
  1062. data = [["DocType", "Fields"]] + [[table, ", ".join(columns)] for table, columns in data.items()]
  1063. render_table(data)
  1064. commands = [
  1065. add_system_manager,
  1066. backup,
  1067. drop_site,
  1068. install_app,
  1069. list_apps,
  1070. migrate,
  1071. migrate_to,
  1072. new_site,
  1073. reinstall,
  1074. reload_doc,
  1075. reload_doctype,
  1076. remove_from_installed_apps,
  1077. restore,
  1078. run_patch,
  1079. set_password,
  1080. set_admin_password,
  1081. uninstall,
  1082. disable_user,
  1083. _use,
  1084. set_last_active_for_user,
  1085. publish_realtime,
  1086. browse,
  1087. start_recording,
  1088. stop_recording,
  1089. add_to_hosts,
  1090. start_ngrok,
  1091. build_search_index,
  1092. partial_restore,
  1093. trim_tables,
  1094. trim_database,
  1095. clear_log_table,
  1096. ]