diff --git a/frappe/installer.py b/frappe/installer.py index 16198c8931..1fe891c852 100755 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -4,6 +4,8 @@ import json import os import sys +from collections import OrderedDict +from typing import List, Dict import frappe from frappe.defaults import _clear_cache @@ -230,9 +232,29 @@ def remove_app(app_name, dry_run=False, yes=False, no_backup=False, force=False) scheduled_backup(ignore_files=True) frappe.flags.in_uninstall = True - drop_doctypes = [] modules = frappe.get_all("Module Def", filters={"app_name": app_name}, pluck="name") + + drop_doctypes = _delete_modules(modules, dry_run=dry_run) + _delete_doctypes(drop_doctypes, dry_run=dry_run) + + if not dry_run: + remove_from_installed_apps(app_name) + frappe.db.commit() + + click.secho(f"Uninstalled App {app_name} from Site {site}", fg="green") + frappe.flags.in_uninstall = False + + +def _delete_modules(modules: List[str], dry_run: bool) -> List[str]: + """ Delete modules belonging to the app and all related doctypes. + + Note: All record linked linked to Module Def are also deleted. + + Returns: list of deleted doctypes.""" + drop_doctypes = [] + + doctype_link_field_map = _get_module_linked_doctype_field_map() for module_name in modules: print(f"Deleting Module '{module_name}'") @@ -242,45 +264,67 @@ def remove_app(app_name, dry_run=False, yes=False, no_backup=False, force=False) print(f"* removing DocType '{doctype.name}'...") if not dry_run: - frappe.delete_doc("DocType", doctype.name, ignore_on_trash=True) - - if not doctype.issingle: + if doctype.issingle: + frappe.delete_doc("DocType", doctype.name, ignore_on_trash=True) + else: drop_doctypes.append(doctype.name) - linked_doctypes = frappe.get_all( - "DocField", filters={"fieldtype": "Link", "options": "Module Def"}, fields=["parent"] - ) - ordered_doctypes = ["Workspace", "Report", "Page", "Web Form"] - all_doctypes_with_linked_modules = ordered_doctypes + [ - doctype.parent - for doctype in linked_doctypes - if doctype.parent not in ordered_doctypes - ] - doctypes_with_linked_modules = [ - x for x in all_doctypes_with_linked_modules if frappe.db.exists("DocType", x) - ] - for doctype in doctypes_with_linked_modules: - for record in frappe.get_all(doctype, filters={"module": module_name}, pluck="name"): - print(f"* removing {doctype} '{record}'...") - if not dry_run: - frappe.delete_doc(doctype, record, ignore_on_trash=True, force=True) + _delete_linked_documents(module_name, doctype_link_field_map, dry_run=dry_run) print(f"* removing Module Def '{module_name}'...") if not dry_run: frappe.delete_doc("Module Def", module_name, ignore_on_trash=True, force=True) - for doctype in set(drop_doctypes): + return drop_doctypes + + +def _delete_linked_documents( + module_name: str, + doctype_linkfield_map: Dict[str, str], + dry_run: bool + ) -> None: + + """Deleted all records linked with module def""" + for doctype, fieldname in doctype_linkfield_map.items(): + for record in frappe.get_all(doctype, filters={fieldname: module_name}, pluck="name"): + print(f"* removing {doctype} '{record}'...") + if not dry_run: + frappe.delete_doc(doctype, record, ignore_on_trash=True, force=True) + +def _get_module_linked_doctype_field_map() -> Dict[str, str]: + """ Get all the doctypes which have module linked with them. + + returns ordered dictionary with doctype->link field mapping.""" + + # Hardcoded to change order of deletion + ordered_doctypes = [ + ("Workspace", "module"), + ("Report", "module"), + ("Page", "module"), + ("Web Form", "module") + ] + doctype_to_field_map = OrderedDict(ordered_doctypes) + + linked_doctypes = frappe.get_all( + "DocField", filters={"fieldtype": "Link", "options": "Module Def"}, fields=["parent", "fieldname"] + ) + existing_linked_doctypes = [d for d in linked_doctypes if frappe.db.exists("DocType", d.parent)] + + for d in existing_linked_doctypes: + # DocType deletion is handled separately in the end + if d.parent not in doctype_to_field_map and d.parent != "DocType": + doctype_to_field_map[d.parent] = d.fieldname + + return doctype_to_field_map + + +def _delete_doctypes(doctypes: List[str], dry_run: bool) -> None: + for doctype in set(doctypes): print(f"* dropping Table for '{doctype}'...") if not dry_run: + frappe.delete_doc("DocType", doctype, ignore_on_trash=True) frappe.db.sql_ddl(f"drop table `tab{doctype}`") - if not dry_run: - remove_from_installed_apps(app_name) - frappe.db.commit() - - click.secho(f"Uninstalled App {app_name} from Site {site}", fg="green") - frappe.flags.in_uninstall = False - def post_install(rebuild_website=False): from frappe.website.utils import clear_website_cache diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py index ee184843ad..c048e23949 100644 --- a/frappe/tests/test_commands.py +++ b/frappe/tests/test_commands.py @@ -13,7 +13,7 @@ import glob # imports - module imports import frappe import frappe.recorder -from frappe.installer import add_to_installed_apps +from frappe.installer import add_to_installed_apps, remove_app from frappe.utils import add_to_date, get_bench_relative_path, now from frappe.utils.backups import fetch_latest_backups @@ -465,3 +465,50 @@ class TestCommands(BaseTestCommands): self.execute("bench --site {site} set-admin-password test2") self.assertEqual(self.returncode, 0) self.assertEqual(check_password('Administrator', 'test2'), 'Administrator') + + +class RemoveAppUnitTests(unittest.TestCase): + def test_delete_modules(self): + from frappe.installer import ( + _delete_doctypes, + _delete_modules, + _get_module_linked_doctype_field_map, + ) + + test_module = frappe.new_doc("Module Def") + + test_module.update({"module_name": "RemoveThis", "app_name": "frappe"}) + test_module.save() + + module_def_linked_doctype = frappe.get_doc({ + "doctype": "DocType", + "name": "Doctype linked with module def", + "module": "RemoveThis", + "custom": 1, + "fields": [{ + "label": "Modulen't", + "fieldname": "notmodule", + "fieldtype": "Link", + "options": "Module Def" + }] + }).insert() + + doctype_to_link_field_map = _get_module_linked_doctype_field_map() + + self.assertIn("Report", doctype_to_link_field_map) + self.assertIn(module_def_linked_doctype.name, doctype_to_link_field_map) + self.assertEqual(doctype_to_link_field_map[module_def_linked_doctype.name], "notmodule") + self.assertNotIn("DocType", doctype_to_link_field_map) + + doctypes_to_delete = _delete_modules([test_module.module_name], dry_run=False) + self.assertEqual(len(doctypes_to_delete), 1) + + _delete_doctypes(doctypes_to_delete, dry_run=False) + self.assertFalse(frappe.db.exists("Module Def", test_module.module_name)) + self.assertFalse(frappe.db.exists("DocType", module_def_linked_doctype.name)) + + def test_dry_run(self): + """Check if dry run in not destructive.""" + + # nothing to assert, if this fails rest of the test suite will crumble. + remove_app("frappe", dry_run=True, yes=True, no_backup=True)