feat: handle linked docs while deleting

This commit is contained in:
Pratik 2025-05-21 14:20:48 +00:00
parent 2e1289df28
commit b47fc5b93b
8 changed files with 401 additions and 47 deletions

View File

@ -10,6 +10,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()
@ -726,3 +727,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)

View File

@ -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

View File

@ -63,6 +63,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']

View File

@ -0,0 +1,155 @@
<template>
<Dialog v-model="show" :options="{ size: 'xl' }">
<template #body>
<div class="bg-surface-modal px-4 pb-6 pt-5 sm:px-6">
<div class="mb-6 flex items-center justify-between">
<div>
<h3 class="text-2xl leading-6 text-ink-gray-9 font-semibold">
{{ __('Delete') }}
</h3>
</div>
<div class="flex items-center gap-1">
<Button variant="ghost" class="w-7" @click="show = false">
<FeatherIcon name="x" class="h-4 w-4" />
</Button>
</div>
</div>
<div>
<div v-if="linkedDocs.data?.length > 0">
<span>
{{
__(
'Unlink these linked documents before deleting this document',
)
}}
</span>
<ul class="mt-5 space-y-1">
<hr />
<li v-for="doc in linkedDocs.data" :key="doc.name">
<div class="flex justify-between items-center">
<span
class="text-lg font-medium text-ellipsis overflow-hidden whitespace-nowrap w-full"
>{{ doc.reference_doctype }} ({{
doc.reference_docname
}})</span
>
<div class="flex gap-2">
<Button variant="ghost" @click="viewLinkedDoc(doc)">
<div class="flex gap-1">
<FeatherIcon name="external-link" class="h-4 w-4" />
<span> View </span>
</div>
</Button>
<Button variant="ghost" @click="unlinkLinkedDoc(doc)">
<div class="flex gap-1">
<FeatherIcon name="unlock" class="h-4 w-4" />
<span> Unlink </span>
</div>
</Button>
</div>
</div>
<hr class="my-2 w-full" />
</li>
</ul>
</div>
<div v-if="linkedDocs.data?.length == 0">
{{
__('Are you sure you want to delete {0} - {1}?', [
props.doctype,
props.docname,
])
}}
</div>
<ErrorMessage class="mt-4" v-if="error" :message="__(error)" />
</div>
</div>
<div class="px-4 pb-7 pt-0 sm:px-6">
<div class="flex flex-row-reverse gap-2">
<Button
v-if="linkedDocs.data?.length > 0"
variant="solid"
@click="
unlinkLinkedDoc({
reference_doctype: props.doctype,
reference_docname: props.docname,
removeAll: true,
})
"
>
<div class="flex gap-1">
<FeatherIcon name="unlock" class="h-4 w-4" />
<span> Unlink all </span>
</div>
</Button>
<Button
v-if="linkedDocs.data?.length == 0"
variant="solid"
:label="__('Delete')"
:loading="isDealCreating"
@click="deleteDoc()"
theme="red"
/>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { createResource, call } from 'frappe-ui'
import { useRouter } from 'vue-router'
const show = defineModel()
const router = useRouter()
const props = defineProps({
name: {
type: String,
required: true,
},
doctype: {
type: String,
required: true,
},
docname: {
type: String,
required: true,
},
})
const linkedDocs = createResource({
url: 'crm.api.doc.getLinkedDocs',
params: {
doctype: props.doctype,
docname: props.docname,
},
auto: true,
validate(params) {
if (!params?.doctype || !params?.docname) {
return false
}
},
})
const viewLinkedDoc = (doc) => {
window.open(`/app/Form/${doc.reference_doctype}/${doc.reference_docname}`)
}
const unlinkLinkedDoc = (doc) => {
call('crm.api.doc.removeLinkedDocReference', {
doctype: doc.reference_doctype,
docname: doc.reference_docname,
removeAll: doc.removeAll,
removeContact: props.doctype == 'Contact',
}).then(() => {
linkedDocs.reload()
})
}
const deleteDoc = async () => {
await call('frappe.client.delete', {
doctype: props.doctype,
name: props.docname,
})
router.push({ name: props.name })
}
</script>

View File

@ -105,7 +105,7 @@
:label="__('Delete')"
theme="red"
size="sm"
@click="deleteContact"
@click="deleteContact()"
>
<template #prefix>
<FeatherIcon name="trash-2" class="h-4 w-4" />
@ -173,6 +173,13 @@
:errorMessage="errorMessage"
/>
<AddressModal v-model="showAddressModal" v-model:address="_address" />
<DeleteLinkedDocModal
v-if="showDeleteLinkedDocModal"
v-model="showDeleteLinkedDocModal"
:doctype="'Contact'"
:docname="contact.data.name"
name="Contacts"
/>
</template>
<script setup>
@ -208,7 +215,6 @@ import {
} from 'frappe-ui'
import { ref, computed, h } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { errorMessage as _errorMessage } from '../utils'
const { brand } = getSettings()
const { $dialog, makeCall } = globalStore()
@ -297,6 +303,11 @@ usePageMeta(() => {
icon: brand.favicon,
}
})
const showDeleteLinkedDocModal = ref(false)
async function deleteContact() {
showDeleteLinkedDocModal.value = true
}
function validateFile(file) {
let extn = file.name.split('.').pop().toLowerCase()
@ -315,28 +326,6 @@ async function changeContactImage(file) {
contact.reload()
}
async function deleteContact() {
$dialog({
title: __('Delete contact'),
message: __('Are you sure you want to delete this contact?'),
actions: [
{
label: __('Delete'),
theme: 'red',
variant: 'solid',
async onClick(close) {
await call('frappe.client.delete', {
doctype: 'Contact',
name: props.contactId,
})
close()
router.push({ name: 'Contacts' })
},
},
],
})
}
const tabIndex = ref(0)
const tabs = [
{

View File

@ -8,6 +8,11 @@
</Breadcrumbs>
</template>
<template #right-header>
<Button
:label="__('Delete')"
variant="subtle"
@click="deleteDealWithModal()"
/>
<CustomActions
v-if="deal.data._customActions?.length"
:actions="deal.data._customActions"
@ -299,6 +304,13 @@
}
"
/>
<DeleteLinkedDocModal
v-if="showDeleteLinkedDocModal"
v-model="showDeleteLinkedDocModal"
:doctype="'CRM Deal'"
:docname="props.dealId"
name="Deals"
/>
</template>
<script setup>
import ErrorPage from '@/components/ErrorPage.vue'
@ -447,7 +459,11 @@ const reload = ref(false)
const showOrganizationModal = ref(false)
const showFilesUploader = ref(false)
const _organization = ref({})
const showDeleteLinkedDocModal = ref(false)
async function deleteDealWithModal() {
showDeleteLinkedDocModal.value = true
}
function updateDeal(fieldname, value, callback) {
value = Array.isArray(fieldname) ? '' : value

View File

@ -34,6 +34,11 @@
</Button>
</template>
</Dropdown>
<Button
:label="__('Delete')"
variant="subtle"
@click="deleteLeadWithModal(lead.data.name)"
/>
<Button
:label="__('Convert to Deal')"
variant="solid"
@ -311,6 +316,13 @@
}
"
/>
<DeleteLinkedDocModal
v-if="showDeleteLinkedDocModal"
v-model="showDeleteLinkedDocModal"
:doctype="'CRM Lead'"
:docname="props.leadId"
name="Leads"
/>
</template>
<script setup>
import ErrorPage from '@/components/ErrorPage.vue'
@ -400,6 +412,7 @@ const props = defineProps({
const errorTitle = ref('')
const errorMessage = ref('')
const showDeleteLinkedDocModal = ref(false)
const lead = createResource({
url: 'crm.fcrm.doctype.crm_lead.api.get_lead',
@ -606,6 +619,10 @@ async function deleteLead(name) {
router.push({ name: 'Leads' })
}
async function deleteLeadWithModal(name) {
showDeleteLinkedDocModal.value = true
}
// Convert to Deal
const showConvertToDealModal = ref(false)
const existingContactChecked = ref(false)

View File

@ -83,7 +83,7 @@
:label="__('Delete')"
theme="red"
size="sm"
@click="deleteOrganization"
@click="deleteOrganization()"
>
<template #prefix>
<FeatherIcon name="trash-2" class="h-4 w-4" />
@ -170,6 +170,13 @@
doctype="CRM Organization"
/>
<AddressModal v-model="showAddressModal" v-model:address="_address" />
<DeleteLinkedDocModal
v-if="showDeleteLinkedDocModal"
v-model="showDeleteLinkedDocModal"
:doctype="'CRM Organization'"
:docname="props.organizationId"
name="Organizations"
/>
</template>
<script setup>
@ -209,6 +216,7 @@ import {
} from 'frappe-ui'
import { h, computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import DeleteLinkedDocModal from '@/components/DeleteLinkedDocModal.vue'
const props = defineProps({
organizationId: {
@ -230,6 +238,8 @@ const router = useRouter()
const errorTitle = ref('')
const errorMessage = ref('')
const showDeleteLinkedDocModal = ref(false)
const organization = createDocumentResource({
doctype: 'CRM Organization',
name: props.organizationId,
@ -294,6 +304,10 @@ usePageMeta(() => {
}
})
async function deleteOrganization() {
showDeleteLinkedDocModal.value = true
}
function validateFile(file) {
let extn = file.name.split('.').pop().toLowerCase()
if (!['png', 'jpg', 'jpeg'].includes(extn)) {
@ -311,28 +325,6 @@ async function changeOrganizationImage(file) {
organization.reload()
}
async function deleteOrganization() {
$dialog({
title: __('Delete organization'),
message: __('Are you sure you want to delete this organization?'),
actions: [
{
label: __('Delete'),
theme: 'red',
variant: 'solid',
async onClick(close) {
await call('frappe.client.delete', {
doctype: 'CRM Organization',
name: props.organizationId,
})
close()
router.push({ name: 'Organizations' })
},
},
],
})
}
function website(url) {
return url && url.replace(/^(?:https?:\/\/)?(?:www\.)?/i, '')
}