# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE import os import shutil import frappe import frappe.defaults import frappe.model.meta from frappe import _, get_module_path from frappe.desk.doctype.tag.tag import delete_tags_for_document from frappe.model.dynamic_links import get_dynamic_link_map from frappe.model.naming import revert_series_if_last from frappe.model.utils import is_virtual_doctype from frappe.utils.file_manager import remove_all from frappe.utils.global_search import delete_for_document from frappe.utils.password import delete_all_passwords_for doctypes_to_skip = ( "Communication", "ToDo", "DocShare", "Email Unsubscribe", "Activity Log", "File", "Version", "Document Follow", "Comment", "View Log", "Tag Link", "Notification Log", "Email Queue", "Document Share Key", "Integration Request", ) def delete_doc( doctype=None, name=None, force=0, ignore_doctypes=None, for_reload=False, ignore_permissions=False, flags=None, ignore_on_trash=False, ignore_missing=True, delete_permanently=False, ): """ Deletes a doc(dt, dn) and validates if it is not submitted and not linked in a live record """ if not ignore_doctypes: ignore_doctypes = [] # get from form if not doctype: doctype = frappe.form_dict.get("dt") name = frappe.form_dict.get("dn") is_virtual = is_virtual_doctype(doctype) names = name if isinstance(name, str) or isinstance(name, int): names = [name] for name in names or []: if is_virtual: frappe.get_doc(doctype, name).delete() continue # already deleted..? if not frappe.db.exists(doctype, name): if not ignore_missing: raise frappe.DoesNotExistError else: return False # delete passwords delete_all_passwords_for(doctype, name) doc = None if doctype == "DocType": if for_reload: try: doc = frappe.get_doc(doctype, name) except frappe.DoesNotExistError: pass else: doc.run_method("before_reload") else: doc = frappe.get_doc(doctype, name) if not (doc.custom or frappe.conf.developer_mode or frappe.flags.in_patch or force): frappe.throw(_("Standard DocType can not be deleted.")) update_flags(doc, flags, ignore_permissions) check_permission_and_not_submitted(doc) # delete custom table fields using this doctype. frappe.db.delete( "Custom Field", {"options": name, "fieldtype": ("in", frappe.model.table_fields)} ) frappe.db.delete("__global_search", {"doctype": name}) delete_from_table(doctype, name, ignore_doctypes, None) if ( frappe.conf.developer_mode and not doc.custom and not ( for_reload or frappe.flags.in_migrate or frappe.flags.in_install or frappe.flags.in_uninstall ) ): try: delete_controllers(name, doc.module) except (OSError, KeyError): # in case a doctype doesnt have any controller code nor any app and module pass else: doc = frappe.get_doc(doctype, name) if not for_reload: update_flags(doc, flags, ignore_permissions) check_permission_and_not_submitted(doc) if not ignore_on_trash: doc.run_method("on_trash") doc.flags.in_delete = True doc.run_method("on_change") # check if links exist if not force: check_if_doc_is_linked(doc) check_if_doc_is_dynamically_linked(doc) update_naming_series(doc) delete_from_table(doctype, name, ignore_doctypes, doc) doc.run_method("after_delete") # delete attachments remove_all(doctype, name, from_delete=True, delete_permanently=delete_permanently) if not for_reload: # Enqueued at the end, because it gets committed # All the linked docs should be checked beforehand frappe.enqueue( "frappe.model.delete_doc.delete_dynamic_links", doctype=doc.doctype, name=doc.name, now=frappe.flags.in_test, ) # clear cache for Document doc.clear_cache() # delete global search entry delete_for_document(doc) # delete tag link entry delete_tags_for_document(doc) if for_reload: delete_permanently = True if not delete_permanently: add_to_deleted_document(doc) if doc and not for_reload: if not frappe.flags.in_patch: try: doc.notify_update() insert_feed(doc) except ImportError: pass def add_to_deleted_document(doc): """Add this document to Deleted Document table. Called after delete""" if doc.doctype != "Deleted Document" and frappe.flags.in_install != "frappe": frappe.get_doc( dict( doctype="Deleted Document", deleted_doctype=doc.doctype, deleted_name=doc.name, data=doc.as_json(), owner=frappe.session.user, ) ).db_insert() def update_naming_series(doc): if doc.meta.autoname: if doc.meta.autoname.startswith("naming_series:") and getattr(doc, "naming_series", None): revert_series_if_last(doc.naming_series, doc.name, doc) elif doc.meta.autoname.split(":")[0] not in ("Prompt", "field", "hash", "autoincrement"): revert_series_if_last(doc.meta.autoname, doc.name, doc) def delete_from_table(doctype: str, name: str, ignore_doctypes: list[str], doc): if doctype != "DocType" and doctype == name: frappe.db.delete("Singles", {"doctype": name}) else: frappe.db.delete(doctype, {"name": name}) if doc: child_doctypes = [ d.options for d in doc.meta.get_table_fields() if frappe.get_meta(d.options).is_virtual == 0 ] else: child_doctypes = frappe.get_all( "DocField", fields="options", filters={"fieldtype": ["in", frappe.model.table_fields], "parent": doctype}, pluck="options", ) child_doctypes_to_delete = set(child_doctypes) - set(ignore_doctypes) for child_doctype in child_doctypes_to_delete: frappe.db.delete(child_doctype, {"parenttype": doctype, "parent": name}) def update_flags(doc, flags=None, ignore_permissions=False): if ignore_permissions: if not flags: flags = {} flags["ignore_permissions"] = ignore_permissions if flags: doc.flags.update(flags) def check_permission_and_not_submitted(doc): # permission if ( not doc.flags.ignore_permissions and frappe.session.user != "Administrator" and (not doc.has_permission("delete") or (doc.doctype == "DocType" and not doc.custom)) ): frappe.msgprint( _("User not allowed to delete {0}: {1}").format(doc.doctype, doc.name), raise_exception=frappe.PermissionError, ) # check if submitted if doc.docstatus.is_submitted(): frappe.msgprint( _("{0} {1}: Submitted Record cannot be deleted. You must {2} Cancel {3} it first.").format( _(doc.doctype), doc.name, "", "", ), raise_exception=True, ) def check_if_doc_is_linked(doc, method="Delete"): """ Raises excption if the given doc(dt, dn) is linked in another record. """ from frappe.model.rename_doc import get_link_fields link_fields = get_link_fields(doc.doctype) ignore_linked_doctypes = doc.get("ignore_linked_doctypes") or [] for lf in link_fields: link_dt, link_field, issingle = lf["parent"], lf["fieldname"], lf["issingle"] if not issingle: fields = ["name", "docstatus"] if frappe.get_meta(link_dt).istable: fields.extend(["parent", "parenttype"]) for item in frappe.db.get_values(link_dt, {link_field: doc.name}, fields, as_dict=True): # available only in child table cases item_parent = getattr(item, "parent", None) linked_doctype = item.parenttype if item_parent else link_dt if linked_doctype in doctypes_to_skip or ( linked_doctype in ignore_linked_doctypes and method == "Cancel" ): # don't check for communication and todo! continue if method != "Delete" and (method != "Cancel" or item.docstatus != 1): # don't raise exception if not # linked to a non-cancelled doc when deleting or to a submitted doc when cancelling continue elif link_dt == doc.doctype and (item_parent or item.name) == doc.name: # don't raise exception if not # linked to same item or doc having same name as the item continue else: reference_docname = item_parent or item.name raise_link_exists_exception(doc, linked_doctype, reference_docname) else: if frappe.db.get_value(link_dt, None, link_field) == doc.name: raise_link_exists_exception(doc, link_dt, link_dt) def check_if_doc_is_dynamically_linked(doc, method="Delete"): """Raise `frappe.LinkExistsError` if the document is dynamically linked""" for df in get_dynamic_link_map().get(doc.doctype, []): ignore_linked_doctypes = doc.get("ignore_linked_doctypes") or [] if df.parent in doctypes_to_skip or (df.parent in ignore_linked_doctypes and method == "Cancel"): # don't check for communication and todo! continue meta = frappe.get_meta(df.parent) if meta.issingle: # dynamic link in single doc refdoc = frappe.db.get_singles_dict(df.parent) if ( refdoc.get(df.options) == doc.doctype and refdoc.get(df.fieldname) == doc.name and ( (method == "Delete" and refdoc.docstatus < 2) or (method == "Cancel" and refdoc.docstatus == 1) ) ): # raise exception only if # linked to an non-cancelled doc when deleting # or linked to a submitted doc when cancelling raise_link_exists_exception(doc, df.parent, df.parent) else: # dynamic link in table df["table"] = ", `parent`, `parenttype`, `idx`" if meta.istable else "" for refdoc in frappe.db.sql( """select `name`, `docstatus` {table} from `tab{parent}` where {options}=%s and {fieldname}=%s""".format( **df ), (doc.doctype, doc.name), as_dict=True, ): if (method == "Delete" and refdoc.docstatus < 2) or ( method == "Cancel" and refdoc.docstatus == 1 ): # raise exception only if # linked to an non-cancelled doc when deleting # or linked to a submitted doc when cancelling reference_doctype = refdoc.parenttype if meta.istable else df.parent reference_docname = refdoc.parent if meta.istable else refdoc.name at_position = f"at Row: {refdoc.idx}" if meta.istable else "" raise_link_exists_exception(doc, reference_doctype, reference_docname, at_position) def raise_link_exists_exception(doc, reference_doctype, reference_docname, row=""): doc_link = '{1}'.format(doc.doctype, doc.name) reference_link = '{1}'.format( reference_doctype, reference_docname ) # hack to display Single doctype only once in message if reference_doctype == reference_docname: reference_doctype = "" frappe.throw( _("Cannot delete or cancel because {0} {1} is linked with {2} {3} {4}").format( _(doc.doctype), doc_link, _(reference_doctype), reference_link, row ), frappe.LinkExistsError, ) def delete_dynamic_links(doctype, name): delete_references("ToDo", doctype, name, "reference_type") delete_references("Email Unsubscribe", doctype, name) delete_references("DocShare", doctype, name, "share_doctype", "share_name") delete_references("Version", doctype, name, "ref_doctype", "docname") delete_references("Comment", doctype, name) delete_references("View Log", doctype, name) delete_references("Document Follow", doctype, name, "ref_doctype", "ref_docname") delete_references("Notification Log", doctype, name, "document_type", "document_name") # unlink communications clear_timeline_references(doctype, name) clear_references("Communication", doctype, name) clear_references("Activity Log", doctype, name) clear_references("Activity Log", doctype, name, "timeline_doctype", "timeline_name") def delete_references( doctype, reference_doctype, reference_name, reference_doctype_field="reference_doctype", reference_name_field="reference_name", ): frappe.db.delete( doctype, {reference_doctype_field: reference_doctype, reference_name_field: reference_name} ) def clear_references( doctype, reference_doctype, reference_name, reference_doctype_field="reference_doctype", reference_name_field="reference_name", ): frappe.db.sql( """update `tab{0}` set {1}=NULL, {2}=NULL where {1}=%s and {2}=%s""".format( doctype, reference_doctype_field, reference_name_field ), # nosec (reference_doctype, reference_name), ) def clear_timeline_references(link_doctype, link_name): frappe.db.delete("Communication Link", {"link_doctype": link_doctype, "link_name": link_name}) def insert_feed(doc): if ( frappe.flags.in_install or frappe.flags.in_uninstall or frappe.flags.in_import or getattr(doc, "no_feed_on_delete", False) ): return from frappe.utils import get_fullname frappe.get_doc( { "doctype": "Comment", "comment_type": "Deleted", "reference_doctype": doc.doctype, "subject": f"{_(doc.doctype)} {doc.name}", "full_name": get_fullname(doc.owner), } ).insert(ignore_permissions=True) def delete_controllers(doctype, module): """ Delete controller code in the doctype folder """ module_path = get_module_path(module) dir_path = os.path.join(module_path, "doctype", frappe.scrub(doctype)) shutil.rmtree(dir_path)