1
0
forked from test/crm

feat: handle linked docs while deleting - cherry-picked

This commit is contained in:
Pratik 2025-05-21 14:20:48 +00:00
parent aa59703709
commit 2b27a21316
8 changed files with 408 additions and 53 deletions

View File

@ -11,6 +11,7 @@ from pypika import Criterion
from crm.api.views import get_views from crm.api.views import get_views
from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script 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() @frappe.whitelist()
@ -744,3 +745,57 @@ def getCounts(d, doctype):
"FCRM Note", filters={"reference_doctype": doctype, "reference_docname": d.get("name")} "FCRM Note", filters={"reference_doctype": doctype, "reference_docname": d.get("name")}
) )
return d 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 import phonenumbers
from frappe.utils import floor from frappe.utils import floor
from phonenumbers import NumberParseException from phonenumbers import NumberParseException
from phonenumbers import PhoneNumberFormat as PNF 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"): def parse_phone_number(phone_number, default_country="IN"):
@ -93,3 +96,129 @@ def seconds_to_duration(seconds):
return f"{seconds}s" return f"{seconds}s"
else: else:
return "0s" 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

@ -66,6 +66,7 @@ declare module 'vue' {
DealsIcon: typeof import('./src/components/Icons/DealsIcon.vue')['default'] DealsIcon: typeof import('./src/components/Icons/DealsIcon.vue')['default']
DealsListView: typeof import('./src/components/ListViews/DealsListView.vue')['default'] DealsListView: typeof import('./src/components/ListViews/DealsListView.vue')['default']
DeclinedCallIcon: typeof import('./src/components/Icons/DeclinedCallIcon.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'] DesendingIcon: typeof import('./src/components/Icons/DesendingIcon.vue')['default']
DesktopLayout: typeof import('./src/components/Layouts/DesktopLayout.vue')['default'] DesktopLayout: typeof import('./src/components/Layouts/DesktopLayout.vue')['default']
DetailsIcon: typeof import('./src/components/Icons/DetailsIcon.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')" :label="__('Delete')"
theme="red" theme="red"
size="sm" size="sm"
@click="deleteContact" @click="deleteContact()"
> >
<template #prefix> <template #prefix>
<FeatherIcon name="trash-2" class="h-4 w-4" /> <FeatherIcon name="trash-2" class="h-4 w-4" />
@ -172,6 +172,13 @@
:errorTitle="errorTitle" :errorTitle="errorTitle"
:errorMessage="errorMessage" :errorMessage="errorMessage"
/> />
<DeleteLinkedDocModal
v-if="showDeleteLinkedDocModal"
v-model="showDeleteLinkedDocModal"
:doctype="'Contact'"
:docname="contact.data.name"
name="Contacts"
/>
</template> </template>
<script setup> <script setup>
@ -293,6 +300,11 @@ usePageMeta(() => {
icon: brand.favicon, icon: brand.favicon,
} }
}) })
const showDeleteLinkedDocModal = ref(false)
async function deleteContact() {
showDeleteLinkedDocModal.value = true
}
async function changeContactImage(file) { async function changeContactImage(file) {
await call('frappe.client.set_value', { await call('frappe.client.set_value', {
@ -304,28 +316,6 @@ async function changeContactImage(file) {
contact.reload() 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 tabIndex = ref(0)
const tabs = [ const tabs = [
{ {
@ -389,13 +379,13 @@ function getParsedSections(_sections) {
'Contact Email', 'Contact Email',
option.name, option.name,
'email_id', 'email_id',
option.value, option.value
) )
} }
}, },
onDelete: async (option, isNew) => { onDelete: async (option, isNew) => {
contact.data.email_ids = contact.data.email_ids.filter( contact.data.email_ids = contact.data.email_ids.filter(
(email) => email.name !== option.name, (email) => email.name !== option.name
) )
!isNew && (await deleteOption('Contact Email', option.name)) !isNew && (await deleteOption('Contact Email', option.name))
if (_contact.value.email_id === option.value) { if (_contact.value.email_id === option.value) {
@ -403,7 +393,7 @@ function getParsedSections(_sections) {
_contact.value.email_id = '' _contact.value.email_id = ''
} else { } else {
_contact.value.email_id = contact.data.email_ids.find( _contact.value.email_id = contact.data.email_ids.find(
(email) => email.is_primary, (email) => email.is_primary
)?.email_id )?.email_id
} }
} }
@ -446,13 +436,13 @@ function getParsedSections(_sections) {
'Contact Phone', 'Contact Phone',
option.name, option.name,
'phone', 'phone',
option.value, option.value
) )
} }
}, },
onDelete: async (option, isNew) => { onDelete: async (option, isNew) => {
contact.data.phone_nos = contact.data.phone_nos.filter( contact.data.phone_nos = contact.data.phone_nos.filter(
(phone) => phone.name !== option.name, (phone) => phone.name !== option.name
) )
!isNew && (await deleteOption('Contact Phone', option.name)) !isNew && (await deleteOption('Contact Phone', option.name))
if (_contact.value.actual_mobile_no === option.value) { if (_contact.value.actual_mobile_no === option.value) {
@ -461,7 +451,7 @@ function getParsedSections(_sections) {
} else { } else {
_contact.value.actual_mobile_no = _contact.value.actual_mobile_no =
contact.data.phone_nos.find( contact.data.phone_nos.find(
(phone) => phone.is_primary_mobile_no, (phone) => phone.is_primary_mobile_no
)?.phone )?.phone
} }
} }

View File

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

View File

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

View File

@ -83,7 +83,7 @@
:label="__('Delete')" :label="__('Delete')"
theme="red" theme="red"
size="sm" size="sm"
@click="deleteOrganization" @click="deleteOrganization()"
> >
<template #prefix> <template #prefix>
<FeatherIcon name="trash-2" class="h-4 w-4" /> <FeatherIcon name="trash-2" class="h-4 w-4" />
@ -164,6 +164,13 @@
:errorTitle="errorTitle" :errorTitle="errorTitle"
:errorMessage="errorMessage" :errorMessage="errorMessage"
/> />
<DeleteLinkedDocModal
v-if="showDeleteLinkedDocModal"
v-model="showDeleteLinkedDocModal"
:doctype="'CRM Organization'"
:docname="props.organizationId"
name="Organizations"
/>
</template> </template>
<script setup> <script setup>
@ -202,6 +209,7 @@ import {
} from 'frappe-ui' } from 'frappe-ui'
import { h, computed, ref } from 'vue' import { h, computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import DeleteLinkedDocModal from '@/components/DeleteLinkedDocModal.vue'
const props = defineProps({ const props = defineProps({
organizationId: { organizationId: {
@ -222,6 +230,8 @@ const router = useRouter()
const errorTitle = ref('') const errorTitle = ref('')
const errorMessage = ref('') const errorMessage = ref('')
const showDeleteLinkedDocModal = ref(false)
const organization = createDocumentResource({ const organization = createDocumentResource({
doctype: 'CRM Organization', doctype: 'CRM Organization',
name: props.organizationId, name: props.organizationId,
@ -249,7 +259,7 @@ const breadcrumbs = computed(() => {
let view = getView( let view = getView(
route.query.view, route.query.view,
route.query.viewType, route.query.viewType,
'CRM Organization', 'CRM Organization'
) )
if (view) { if (view) {
items.push({ items.push({
@ -286,6 +296,10 @@ usePageMeta(() => {
} }
}) })
async function deleteOrganization() {
showDeleteLinkedDocModal.value = true
}
async function changeOrganizationImage(file) { async function changeOrganizationImage(file) {
await call('frappe.client.set_value', { await call('frappe.client.set_value', {
doctype: 'CRM Organization', doctype: 'CRM Organization',
@ -296,28 +310,6 @@ async function changeOrganizationImage(file) {
organization.reload() 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) { function website(url) {
return url && url.replace(/^(?:https?:\/\/)?(?:www\.)?/i, '') return url && url.replace(/^(?:https?:\/\/)?(?:www\.)?/i, '')
} }