From 2b27a213161b84264bc1d6fd64c50917a5203a72 Mon Sep 17 00:00:00 2001 From: Pratik Date: Wed, 21 May 2025 14:20:48 +0000 Subject: [PATCH] feat: handle linked docs while deleting - cherry-picked --- crm/api/doc.py | 55 +++++++ crm/utils/__init__.py | 129 +++++++++++++++ frontend/components.d.ts | 1 + .../src/components/DeleteLinkedDocModal.vue | 155 ++++++++++++++++++ frontend/src/pages/Contact.vue | 48 +++--- frontend/src/pages/Deal.vue | 16 ++ frontend/src/pages/Lead.vue | 17 ++ frontend/src/pages/Organization.vue | 40 ++--- 8 files changed, 408 insertions(+), 53 deletions(-) create mode 100644 frontend/src/components/DeleteLinkedDocModal.vue diff --git a/crm/api/doc.py b/crm/api/doc.py index 16e6b2ef..2d27d1fc 100644 --- a/crm/api/doc.py +++ b/crm/api/doc.py @@ -11,6 +11,7 @@ from pypika import Criterion from crm.api.views import get_views from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script +from crm.utils import get_dynamic_linked_docs, get_linked_docs @frappe.whitelist() @@ -744,3 +745,57 @@ def getCounts(d, doctype): "FCRM Note", filters={"reference_doctype": doctype, "reference_docname": d.get("name")} ) return d + + +@frappe.whitelist() +def getLinkedDocs(doctype, docname): + doc = frappe.get_doc(doctype, docname) + linked_docs = get_linked_docs(doc) + dynamic_linked_docs = get_dynamic_linked_docs(doc) + linked_docs.extend(dynamic_linked_docs) + + return list({doc["reference_docname"]: doc for doc in linked_docs}.values()) + + +@frappe.whitelist() +def removeLinkedDocReference(doctype=None, docname=None,removeAll=False,removeContact=None): + + if (not doctype or not docname) and not removeAll: + return "Invalid doctype or docname" + + if removeAll: + if removeContact: + ref_doc = getLinkedDocs(doctype, docname) + for linked_doc in ref_doc: + removeContactLink(linked_doc["reference_doctype"], linked_doc["reference_docname"]) + + return "success" + + linked_docs = getLinkedDocs(doctype, docname) + for linked_doc in linked_docs: + removeDocLink(linked_doc["reference_doctype"], linked_doc["reference_docname"]) + return "success" + else: + if removeContact: + removeContactLink(doctype, docname) + return "success" + + removeDocLink(doctype, docname) + return "success" + +def removeDocLink(doctype, docname): + linked_doc_data = frappe.get_doc(doctype, docname) + linked_doc_data.update({ + "reference_doctype": None, + "reference_docname": None, + }) + linked_doc_data.save(ignore_permissions=True) + +def removeContactLink(doctype, docname): + linked_doc_data = frappe.get_doc(doctype, docname) + linked_doc_data.update({ + "contact": None, + "contacts": [], + }) + linked_doc_data.save(ignore_permissions=True) + \ No newline at end of file diff --git a/crm/utils/__init__.py b/crm/utils/__init__.py index cd4a4ff8..9b954859 100644 --- a/crm/utils/__init__.py +++ b/crm/utils/__init__.py @@ -1,7 +1,10 @@ +from frappe import frappe import phonenumbers from frappe.utils import floor from phonenumbers import NumberParseException from phonenumbers import PhoneNumberFormat as PNF +from frappe.model.docstatus import DocStatus +from frappe.model.dynamic_links import get_dynamic_link_map def parse_phone_number(phone_number, default_country="IN"): @@ -93,3 +96,129 @@ def seconds_to_duration(seconds): return f"{seconds}s" else: return "0s" + +# Extracted from frappe core frappe/model/delete_doc.py/check_if_doc_is_linked +def get_linked_docs(doc, method="Delete"): + from frappe.model.rename_doc import get_link_fields + + link_fields = get_link_fields(doc.doctype) + ignored_doctypes = set() + + if method == "Cancel" and (doc_ignore_flags := doc.get("ignore_linked_doctypes")): + ignored_doctypes.update(doc_ignore_flags) + if method == "Delete": + ignored_doctypes.update(frappe.get_hooks("ignore_links_on_delete")) + + docs = [] + + for lf in link_fields: + link_dt, link_field, issingle = lf["parent"], lf["fieldname"], lf["issingle"] + if link_dt in ignored_doctypes or (link_field == "amended_from" and method == "Cancel"): + continue + + try: + meta = frappe.get_meta(link_dt) + except frappe.DoesNotExistError: + frappe.clear_last_message() + # This mostly happens when app do not remove their customizations, we shouldn't + # prevent link checks from failing in those cases + continue + + if issingle: + if frappe.db.get_single_value(link_dt, link_field) == doc.name: + docs.append({"doc": doc.name, "link_dt": link_dt, "link_field": link_field}) + continue + + fields = ["name", "docstatus"] + + if meta.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_parent_doctype = item.parenttype if item_parent else link_dt + + if linked_parent_doctype in ignored_doctypes: + continue + + if method != "Delete" and (method != "Cancel" or not DocStatus(item.docstatus).is_submitted()): + # 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 + docs.append( + { + "doc": doc.name, + "reference_doctype": linked_parent_doctype, + "reference_docname": reference_docname, + } + ) + return docs + +# Extracted from frappe core frappe/model/delete_doc.py/check_if_doc_is_dynamically_linked +def get_dynamic_linked_docs(doc, method="Delete"): + docs = [] + for df in get_dynamic_link_map().get(doc.doctype, []): + ignore_linked_doctypes = doc.get("ignore_linked_doctypes") or [] + + if df.parent in frappe.get_hooks("ignore_links_on_delete") 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 ( + # linked to an non-cancelled doc when deleting + (method == "Delete" and not DocStatus(refdoc.docstatus).is_cancelled()) + # linked to a submitted doc when cancelling + or (method == "Cancel" and DocStatus(refdoc.docstatus).is_submitted()) + ) + ): + docs.append({"doc": doc.name, "reference_doctype": df.parent, "reference_docname": 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, + ): + # linked to an non-cancelled doc when deleting + # or linked to a submitted doc when cancelling + if (method == "Delete" and not DocStatus(refdoc.docstatus).is_cancelled()) or ( + method == "Cancel" and DocStatus(refdoc.docstatus).is_submitted() + ): + reference_doctype = refdoc.parenttype if meta.istable else df.parent + reference_docname = refdoc.parent if meta.istable else refdoc.name + + if reference_doctype in frappe.get_hooks("ignore_links_on_delete") or ( + reference_doctype in ignore_linked_doctypes and method == "Cancel" + ): + # don't check for communication and todo! + continue + + at_position = f"at Row: {refdoc.idx}" if meta.istable else "" + + docs.append( + { + "doc": doc.name, + "reference_doctype": reference_doctype, + "reference_docname": reference_docname, + "at_position": at_position, + } + ) + return docs diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 6a8cbbaf..db4334b6 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -66,6 +66,7 @@ declare module 'vue' { DealsIcon: typeof import('./src/components/Icons/DealsIcon.vue')['default'] DealsListView: typeof import('./src/components/ListViews/DealsListView.vue')['default'] DeclinedCallIcon: typeof import('./src/components/Icons/DeclinedCallIcon.vue')['default'] + DeleteLinkedDocModal: typeof import('./src/components/DeleteLinkedDocModal.vue')['default'] DesendingIcon: typeof import('./src/components/Icons/DesendingIcon.vue')['default'] DesktopLayout: typeof import('./src/components/Layouts/DesktopLayout.vue')['default'] DetailsIcon: typeof import('./src/components/Icons/DetailsIcon.vue')['default'] diff --git a/frontend/src/components/DeleteLinkedDocModal.vue b/frontend/src/components/DeleteLinkedDocModal.vue new file mode 100644 index 00000000..204b900b --- /dev/null +++ b/frontend/src/components/DeleteLinkedDocModal.vue @@ -0,0 +1,155 @@ + + + diff --git a/frontend/src/pages/Contact.vue b/frontend/src/pages/Contact.vue index dff41b97..396db22f 100644 --- a/frontend/src/pages/Contact.vue +++ b/frontend/src/pages/Contact.vue @@ -105,7 +105,7 @@ :label="__('Delete')" theme="red" size="sm" - @click="deleteContact" + @click="deleteContact()" >