diff --git a/crm/api/doc.py b/crm/api/doc.py index f08c4ade..74227b68 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() @@ -677,6 +678,7 @@ def remove_assignments(doctype, name, assignees, ignore_permissions=False): ignore_permissions=ignore_permissions, ) + @frappe.whitelist() def get_assigned_users(doctype, name, default_assigned_to=None): assigned_users = frappe.get_all( @@ -745,3 +747,98 @@ def getCounts(d, doctype): "FCRM Note", filters={"reference_doctype": doctype, "reference_docname": d.get("name")} ) return d + + +@frappe.whitelist() +def get_linked_docs_of_document(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) + linked_docs = list({doc["reference_docname"]: doc for doc in linked_docs}.values()) + + docs_data = [] + for doc in linked_docs: + data = frappe.get_doc(doc["reference_doctype"], doc["reference_docname"]) + title = data.get("title") + if data.doctype == "CRM Call Log": + title = f"Call from {data.get('from')} to {data.get('to')}" + + if data.doctype == "CRM Deal": + title = data.get("organization") + + docs_data.append( + { + "doc": data.doctype, + "title": title or data.get("name"), + "reference_docname": doc["reference_docname"], + "reference_doctype": doc["reference_doctype"], + } + ) + return docs_data + + +def remove_doc_link(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 remove_contact_link(doctype, docname): + linked_doc_data = frappe.get_doc(doctype, docname) + linked_doc_data.update( + { + "contact": None, + "contacts": [], + } + ) + linked_doc_data.save(ignore_permissions=True) + + +@frappe.whitelist() +def remove_linked_doc_reference(items, remove_contact=None, delete=False): + if isinstance(items, str): + items = frappe.parse_json(items) + + for item in items: + if remove_contact: + remove_contact_link(item["doctype"], item["docname"]) + else: + remove_doc_link(item["doctype"], item["docname"]) + + if delete: + frappe.delete_doc(item["doctype"], item["docname"]) + + return "success" + + +@frappe.whitelist() +def delete_bulk_docs(doctype, items, delete_linked=False): + from frappe.desk.reportview import delete_bulk + + items = frappe.parse_json(items) + for doc in items: + linked_docs = get_linked_docs_of_document(doctype, doc) + for linked_doc in linked_docs: + remove_linked_doc_reference( + [ + { + "doctype": linked_doc["reference_doctype"], + "docname": linked_doc["reference_docname"], + } + ], + remove_contact=doctype == "Contact", + delete=delete_linked, + ) + + if len(items) > 10: + frappe.enqueue("frappe.desk.reportview.delete_bulk", doctype=doctype, items=items) + else: + delete_bulk(doctype, items) + return "success" 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..2db22144 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -31,6 +31,7 @@ declare module 'vue' { Autocomplete: typeof import('./src/components/frappe-ui/Autocomplete.vue')['default'] AvatarIcon: typeof import('./src/components/Icons/AvatarIcon.vue')['default'] BrandLogo: typeof import('./src/components/BrandLogo.vue')['default'] + BulkDeleteLinkedDocModal: typeof import('./src/components/BulkDeleteLinkedDocModal.vue')['default'] CalendarIcon: typeof import('./src/components/Icons/CalendarIcon.vue')['default'] CallArea: typeof import('./src/components/Activities/CallArea.vue')['default'] CallLogDetailModal: typeof import('./src/components/Modals/CallLogDetailModal.vue')['default'] @@ -66,6 +67,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'] @@ -149,6 +151,7 @@ declare module 'vue' { LeadsListView: typeof import('./src/components/ListViews/LeadsListView.vue')['default'] LightningIcon: typeof import('./src/components/Icons/LightningIcon.vue')['default'] Link: typeof import('./src/components/Controls/Link.vue')['default'] + LinkedDocsListView: typeof import('./src/components/ListViews/LinkedDocsListView.vue')['default'] LinkIcon: typeof import('./src/components/Icons/LinkIcon.vue')['default'] ListBulkActions: typeof import('./src/components/ListBulkActions.vue')['default'] ListIcon: typeof import('./src/components/Icons/ListIcon.vue')['default'] diff --git a/frontend/src/components/BulkDeleteLinkedDocModal.vue b/frontend/src/components/BulkDeleteLinkedDocModal.vue new file mode 100644 index 00000000..40b6cbac --- /dev/null +++ b/frontend/src/components/BulkDeleteLinkedDocModal.vue @@ -0,0 +1,165 @@ + + + diff --git a/frontend/src/components/DeleteLinkedDocModal.vue b/frontend/src/components/DeleteLinkedDocModal.vue new file mode 100644 index 00000000..d85548f0 --- /dev/null +++ b/frontend/src/components/DeleteLinkedDocModal.vue @@ -0,0 +1,280 @@ + + + diff --git a/frontend/src/components/ListBulkActions.vue b/frontend/src/components/ListBulkActions.vue index 76b41650..75026362 100644 --- a/frontend/src/components/ListBulkActions.vue +++ b/frontend/src/components/ListBulkActions.vue @@ -14,6 +14,20 @@ :doctype="doctype" @reload="reload" /> + + diff --git a/frontend/src/pages/CallLogs.vue b/frontend/src/pages/CallLogs.vue index a0e2da95..2f1d6ce6 100644 --- a/frontend/src/pages/CallLogs.vue +++ b/frontend/src/pages/CallLogs.vue @@ -80,7 +80,7 @@ import CallLogDetailModal from '@/components/Modals/CallLogDetailModal.vue' import CallLogModal from '@/components/Modals/CallLogModal.vue' import { getCallLogDetail } from '@/utils/callLog' import { createResource } from 'frappe-ui' -import { computed, ref } from 'vue' +import { computed, ref, onMounted } from 'vue' const callLogsListView = ref(null) const showCallLogModal = ref(false) @@ -124,4 +124,19 @@ function createCallLog() { callLog.value = {} showCallLogModal.value = true } + +const openCallLogFromURL = () => { + const searchParams = new URLSearchParams(window.location.search) + const callLogName = searchParams.get('open') + + if (callLogName) { + showCallLog(callLogName) + searchParams.delete('open') + window.history.replaceState(null, '', window.location.pathname) + } +} + +onMounted(() => { + openCallLogFromURL() +}) diff --git a/frontend/src/pages/Contact.vue b/frontend/src/pages/Contact.vue index dff41b97..b77f5ce7 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()" > diff --git a/frontend/src/pages/Organization.vue b/frontend/src/pages/Organization.vue index a339e75d..769d345f 100644 --- a/frontend/src/pages/Organization.vue +++ b/frontend/src/pages/Organization.vue @@ -83,7 +83,7 @@ :label="__('Delete')" theme="red" size="sm" - @click="deleteOrganization" + @click="deleteOrganization()" >