If someone deletes doctype and restores it back all customization are lost, there's no "real" reason to delete all these customization. They are only ever active if the doctype is being used. Explanations: - Custom field: is used by meta when doctype meta is requested, if meta isn't requested then custom field is effectively inactive. - Client script: loaded by meta when doctype is requested by desk. So inactive in deleted state. - Property setter: loaded by meta, so inactive when doctype isn't present. - Report: will break doctype isn't present, but user should delete them manually to avoid loss of "scripts" or anything special they might have done. Also report's doctype don't 100% indicate that it's based solely on that doctype.
459 lines
13 KiB
Python
459 lines
13 KiB
Python
# 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.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",
|
|
)
|
|
|
|
|
|
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")
|
|
|
|
names = name
|
|
if isinstance(name, str) or isinstance(name, int):
|
|
names = [name]
|
|
|
|
for name in names or []:
|
|
|
|
# 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)
|
|
|
|
update_flags(doc, flags, ignore_permissions)
|
|
check_permission_and_not_submitted(doc)
|
|
|
|
frappe.db.delete("Custom DocPerm", {"parent": name})
|
|
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
|
|
|
|
# delete user_permissions
|
|
frappe.defaults.clear_default(parenttype="User Permission", key=doctype, value=name)
|
|
|
|
|
|
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, name, ignore_doctypes, doc):
|
|
if doctype != "DocType" and doctype == name:
|
|
frappe.db.delete("Singles", {"doctype": name})
|
|
else:
|
|
frappe.db.delete(doctype, {"name": name})
|
|
# get child tables
|
|
if doc:
|
|
tables = [d.options for d in doc.meta.get_table_fields()]
|
|
|
|
else:
|
|
|
|
def get_table_fields(field_doctype):
|
|
if field_doctype == "Custom Field":
|
|
return []
|
|
|
|
return [
|
|
r[0]
|
|
for r in frappe.get_all(
|
|
field_doctype,
|
|
fields="options",
|
|
filters={"fieldtype": ["in", frappe.model.table_fields], "parent": doctype},
|
|
as_list=1,
|
|
)
|
|
]
|
|
|
|
tables = get_table_fields("DocField")
|
|
if not frappe.flags.in_install == "frappe":
|
|
tables += get_table_fields("Custom Field")
|
|
|
|
# delete from child tables
|
|
for t in list(set(tables)):
|
|
if t not in ignore_doctypes:
|
|
frappe.db.delete(t, {"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,
|
|
"<a href='https://docs.erpnext.com//docs/user/manual/en/setting-up/articles/delete-submitted-document' target='_blank'>",
|
|
"</a>",
|
|
),
|
|
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 = "at Row: {0}".format(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 = '<a href="/app/Form/{0}/{1}">{1}</a>'.format(doc.doctype, doc.name)
|
|
reference_link = '<a href="/app/Form/{0}/{1}">{1}</a>'.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": "{0} {1}".format(_(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)
|