diff --git a/crm/api/doc.py b/crm/api/doc.py index 16e6b2ef..1369a952 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() @@ -676,6 +677,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( @@ -744,3 +746,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 acc8ffde..d4d287e4 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'] @@ -153,11 +155,13 @@ 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'] ListRows: typeof import('./src/components/ListViews/ListRows.vue')['default'] LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['default'] + LucideCalendar: typeof import('~icons/lucide/calendar')['default'] LucideInfo: typeof import('~icons/lucide/info')['default'] LucideMoreHorizontal: typeof import('~icons/lucide/more-horizontal')['default'] LucidePlus: typeof import('~icons/lucide/plus')['default'] 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/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()" > diff --git a/frontend/src/pages/Organization.vue b/frontend/src/pages/Organization.vue index a339e75d..ab4239ab 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()" > diff --git a/frontend/vite.config.js b/frontend/vite.config.js index f4de2403..97d2b193 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -2,11 +2,72 @@ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import vueJsx from '@vitejs/plugin-vue-jsx' import path from 'path' +import fs from 'fs' import frappeui from 'frappe-ui/vite' import { VitePWA } from 'vite-plugin-pwa' +function appPath(app) { + const root = path.resolve(__dirname, '../..') // points to apps + const frontendPaths = [ + // Standard frontend structure: appname/frontend/src + path.join(root, app, 'frontend', 'src'), + // Desk-based apps: appname/desk/src + path.join(root, app, 'desk', 'src'), + // Alternative frontend structures + path.join(root, app, 'client', 'src'), + path.join(root, app, 'ui', 'src'), + // Direct src structure: appname/src + path.join(root, app, 'src'), + ] + return frontendPaths.find((srcPath) => fs.existsSync(srcPath)) || null +} + +function hasApp(app) { + return fs.existsSync(appPath(app)) +} + +// List of frontend apps used in this project +let apps = [] + +const alias = [ + // Default "@" for this app + { + find: '@', + replacement: path.resolve(__dirname, 'src'), + }, + + // App-specific aliases like @helpdesk, @hrms, etc. + ...apps.map((app) => + hasApp(app) + ? { find: `@${app}`, replacement: appPath(app) } + : { find: `@${app}`, replacement: `virtual:${app}` }, + ), +] + +const defineFlags = Object.fromEntries( + apps.map((app) => [ + `__HAS_${app.toUpperCase()}__`, + JSON.stringify(hasApp(app)), + ]), +) + +const virtualStubPlugin = { + name: 'virtual-empty-modules', + resolveId(id) { + if (id.startsWith('virtual:')) return '\0' + id + }, + load(id) { + if (id.startsWith('\0virtual:')) { + return 'export default {}; export const missing = true;' + } + }, +} + +console.log('Generated app aliases:', alias) + // https://vitejs.dev/config/ export default defineConfig({ + define: defineFlags, plugins: [ frappeui({ frappeProxy: true, @@ -60,12 +121,9 @@ export default defineConfig({ ], }, }), + virtualStubPlugin, ], - resolve: { - alias: { - '@': path.resolve(__dirname, 'src'), - }, - }, + resolve: { alias }, optimizeDeps: { include: [ 'feather-icons',