snapshot of https://github.com/frappe/crm.git (develop, 2026-09-18; blobs > 131072B omitted)
Some checks failed
Frontend / Unit Tests & Coverage (push) Has been skipped
Server / Tests (frappe ${{ matrix.frappe-branch }}) (push) Has been skipped
Server / Resolve frappe matrix (push) Failing after 1s
Frontend / Lint (push) Failing after 1s
Frontend / Production Build (push) Failing after 1s
Server / Coverage Wrap Up (push) Has been skipped
Frontend / Coverage Wrap Up (push) Has been skipped
Regenerate POT file (translatable strings) / Regenerate POT file (develop) (push) Failing after 1s
Regenerate POT file (translatable strings) / Regenerate POT file (main-hotfix) (push) Failing after 1s
Some checks failed
Frontend / Unit Tests & Coverage (push) Has been skipped
Server / Tests (frappe ${{ matrix.frappe-branch }}) (push) Has been skipped
Server / Resolve frappe matrix (push) Failing after 1s
Frontend / Lint (push) Failing after 1s
Frontend / Production Build (push) Failing after 1s
Server / Coverage Wrap Up (push) Has been skipped
Frontend / Coverage Wrap Up (push) Has been skipped
Regenerate POT file (translatable strings) / Regenerate POT file (develop) (push) Failing after 1s
Regenerate POT file (translatable strings) / Regenerate POT file (main-hotfix) (push) Failing after 1s
This commit is contained in:
0
crm/fcrm/doctype/__init__.py
Normal file
0
crm/fcrm/doctype/__init__.py
Normal file
0
crm/fcrm/doctype/crm_call_log/__init__.py
Normal file
0
crm/fcrm/doctype/crm_call_log/__init__.py
Normal file
8
crm/fcrm/doctype/crm_call_log/crm_call_log.js
Normal file
8
crm/fcrm/doctype/crm_call_log/crm_call_log.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("CRM Call Log", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
212
crm/fcrm/doctype/crm_call_log/crm_call_log.json
Normal file
212
crm/fcrm/doctype/crm_call_log/crm_call_log.json
Normal file
@@ -0,0 +1,212 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_import": 1,
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:id",
|
||||
"creation": "2023-08-28 00:23:36.229137",
|
||||
"default_view": "List",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"telephony_medium",
|
||||
"section_break_gyqe",
|
||||
"id",
|
||||
"from",
|
||||
"status",
|
||||
"duration",
|
||||
"medium",
|
||||
"start_time",
|
||||
"reference_doctype",
|
||||
"reference_docname",
|
||||
"column_break_ufnp",
|
||||
"to",
|
||||
"type",
|
||||
"receiver",
|
||||
"caller",
|
||||
"recording_url",
|
||||
"end_time",
|
||||
"note",
|
||||
"section_break_kebz",
|
||||
"links"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "id",
|
||||
"fieldtype": "Data",
|
||||
"label": "ID",
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "from",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "From Number",
|
||||
"options": "Phone",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "status",
|
||||
"fieldtype": "Select",
|
||||
"in_standard_filter": 1,
|
||||
"label": "Status",
|
||||
"options": "Initiated\nRinging\nIn Progress\nCompleted\nFailed\nBusy\nNo Answer\nQueued\nCanceled",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "start_time",
|
||||
"fieldtype": "Datetime",
|
||||
"label": "Start Time"
|
||||
},
|
||||
{
|
||||
"fieldname": "medium",
|
||||
"fieldtype": "Data",
|
||||
"label": "Medium"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_ufnp",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "type",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Type",
|
||||
"options": "Incoming\nOutgoing",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "to",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "To Number",
|
||||
"options": "Phone",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "duration",
|
||||
"fieldtype": "Duration",
|
||||
"in_list_view": 1,
|
||||
"label": "Duration"
|
||||
},
|
||||
{
|
||||
"fieldname": "recording_url",
|
||||
"fieldtype": "Small Text",
|
||||
"label": "Recording URL"
|
||||
},
|
||||
{
|
||||
"fieldname": "end_time",
|
||||
"fieldtype": "Datetime",
|
||||
"label": "End Time"
|
||||
},
|
||||
{
|
||||
"fieldname": "note",
|
||||
"fieldtype": "Link",
|
||||
"label": "Note",
|
||||
"options": "FCRM Note"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.type == 'Incoming'",
|
||||
"fieldname": "receiver",
|
||||
"fieldtype": "Link",
|
||||
"label": "Call Received By",
|
||||
"options": "User"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.type == 'Outgoing'",
|
||||
"fieldname": "caller",
|
||||
"fieldtype": "Link",
|
||||
"label": "Caller",
|
||||
"options": "User"
|
||||
},
|
||||
{
|
||||
"default": "CRM Lead",
|
||||
"fieldname": "reference_doctype",
|
||||
"fieldtype": "Link",
|
||||
"label": "Reference Document Type",
|
||||
"options": "DocType"
|
||||
},
|
||||
{
|
||||
"fieldname": "reference_docname",
|
||||
"fieldtype": "Dynamic Link",
|
||||
"label": "Reference Name",
|
||||
"options": "reference_doctype"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_kebz",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "links",
|
||||
"fieldtype": "Table",
|
||||
"label": "Links",
|
||||
"options": "Dynamic Link"
|
||||
},
|
||||
{
|
||||
"fieldname": "telephony_medium",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Telephony Medium",
|
||||
"options": "\nManual\nTwilio\nExotel",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_gyqe",
|
||||
"fieldtype": "Section Break"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-30 12:31:44.974374",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Call Log",
|
||||
"naming_rule": "By fieldname",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales User",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
303
crm/fcrm/doctype/crm_call_log/crm_call_log.py
Normal file
303
crm/fcrm/doctype/crm_call_log/crm_call_log.py
Normal file
@@ -0,0 +1,303 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _, generate_hash
|
||||
from frappe.model.document import Document
|
||||
|
||||
from crm.integrations.api import get_contact_by_phone_number
|
||||
from crm.utils import seconds_to_duration
|
||||
|
||||
|
||||
class CRMCallLog(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.core.doctype.dynamic_link.dynamic_link import DynamicLink
|
||||
from frappe.types import DF
|
||||
|
||||
caller: DF.Link | None
|
||||
duration: DF.Duration | None
|
||||
end_time: DF.Datetime | None
|
||||
id: DF.Data | None
|
||||
links: DF.Table[DynamicLink]
|
||||
medium: DF.Data | None
|
||||
note: DF.Link | None
|
||||
receiver: DF.Link | None
|
||||
recording_url: DF.SmallText | None
|
||||
reference_docname: DF.DynamicLink | None
|
||||
reference_doctype: DF.Link | None
|
||||
start_time: DF.Datetime | None
|
||||
status: DF.Literal[
|
||||
"Initiated",
|
||||
"Ringing",
|
||||
"In Progress",
|
||||
"Completed",
|
||||
"Failed",
|
||||
"Busy",
|
||||
"No Answer",
|
||||
"Queued",
|
||||
"Canceled",
|
||||
]
|
||||
telephony_medium: DF.Literal["", "Manual", "Twilio", "Exotel"]
|
||||
to: DF.Data
|
||||
type: DF.Literal["Incoming", "Outgoing"]
|
||||
# end: auto-generated types
|
||||
|
||||
def before_insert(self):
|
||||
if not self.id:
|
||||
self.id = generate_hash(length=12)
|
||||
if not self.telephony_medium:
|
||||
self.telephony_medium = "Manual"
|
||||
|
||||
@staticmethod
|
||||
def default_list_data():
|
||||
columns = [
|
||||
{
|
||||
"label": "Caller",
|
||||
"type": "Link",
|
||||
"key": "caller",
|
||||
"options": "User",
|
||||
"width": "9rem",
|
||||
},
|
||||
{
|
||||
"label": "Receiver",
|
||||
"type": "Link",
|
||||
"key": "receiver",
|
||||
"options": "User",
|
||||
"width": "9rem",
|
||||
},
|
||||
{
|
||||
"label": "Type",
|
||||
"type": "Select",
|
||||
"key": "type",
|
||||
"width": "9rem",
|
||||
},
|
||||
{
|
||||
"label": "Status",
|
||||
"type": "Select",
|
||||
"key": "status",
|
||||
"width": "9rem",
|
||||
},
|
||||
{
|
||||
"label": "Duration",
|
||||
"type": "Duration",
|
||||
"key": "duration",
|
||||
"width": "6rem",
|
||||
},
|
||||
{
|
||||
"label": "From (number)",
|
||||
"type": "Data",
|
||||
"key": "from",
|
||||
"width": "9rem",
|
||||
},
|
||||
{
|
||||
"label": "To (number)",
|
||||
"type": "Data",
|
||||
"key": "to",
|
||||
"width": "9rem",
|
||||
},
|
||||
{
|
||||
"label": "Created On",
|
||||
"type": "Datetime",
|
||||
"key": "creation",
|
||||
"width": "8rem",
|
||||
},
|
||||
]
|
||||
rows = [
|
||||
"name",
|
||||
"caller",
|
||||
"receiver",
|
||||
"type",
|
||||
"status",
|
||||
"duration",
|
||||
"from",
|
||||
"to",
|
||||
"note",
|
||||
"recording_url",
|
||||
"reference_doctype",
|
||||
"reference_docname",
|
||||
"creation",
|
||||
]
|
||||
return {"columns": columns, "rows": rows}
|
||||
|
||||
def parse_list_data(calls):
|
||||
return [parse_call_log(call) for call in calls] if calls else []
|
||||
|
||||
def has_link(self, doctype, name):
|
||||
for link in self.links:
|
||||
if link.link_doctype == doctype and link.link_name == name:
|
||||
return True
|
||||
|
||||
def link_with_reference_doc(self, reference_doctype, reference_name):
|
||||
if self.has_link(reference_doctype, reference_name):
|
||||
return
|
||||
|
||||
self.append("links", {"link_doctype": reference_doctype, "link_name": reference_name})
|
||||
|
||||
def as_dict(self, *args, **kwargs):
|
||||
d = super().as_dict(*args, **kwargs)
|
||||
if d.get("recording_url"):
|
||||
d["recording_url_path"] = (
|
||||
f"/api/method/crm.integrations.api.get_recording_url?call_log_name={d.get('name')}"
|
||||
)
|
||||
return d
|
||||
|
||||
|
||||
def parse_call_log(call):
|
||||
call["show_recording"] = False
|
||||
call["_duration"] = seconds_to_duration(call.get("duration"))
|
||||
if call.get("type") == "Incoming":
|
||||
call["activity_type"] = "incoming_call"
|
||||
contact = get_contact_by_phone_number(call.get("from"))
|
||||
receiver = (
|
||||
frappe.db.get_values("User", call.get("receiver"), ["full_name", "user_image"])[0]
|
||||
if call.get("receiver")
|
||||
else [None, None]
|
||||
)
|
||||
call["_caller"] = {
|
||||
"label": contact.get("full_name", "Unknown"),
|
||||
"image": contact.get("image"),
|
||||
}
|
||||
call["_receiver"] = {
|
||||
"label": receiver[0],
|
||||
"image": receiver[1],
|
||||
}
|
||||
elif call.get("type") == "Outgoing":
|
||||
call["activity_type"] = "outgoing_call"
|
||||
contact = get_contact_by_phone_number(call.get("to"))
|
||||
caller = (
|
||||
frappe.db.get_values("User", call.get("caller"), ["full_name", "user_image"])[0]
|
||||
if call.get("caller")
|
||||
else [None, None]
|
||||
)
|
||||
call["_caller"] = {
|
||||
"label": caller[0],
|
||||
"image": caller[1],
|
||||
}
|
||||
call["_receiver"] = {
|
||||
"label": contact.get("full_name", "Unknown"),
|
||||
"image": contact.get("image"),
|
||||
}
|
||||
|
||||
return call
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_call_log(name: str):
|
||||
if not frappe.has_permission("CRM Call Log", "read", name):
|
||||
frappe.throw(_("Not permitted"), frappe.PermissionError)
|
||||
|
||||
call = frappe.get_cached_doc(
|
||||
"CRM Call Log",
|
||||
name,
|
||||
fields=[
|
||||
"name",
|
||||
"caller",
|
||||
"receiver",
|
||||
"duration",
|
||||
"type",
|
||||
"status",
|
||||
"from",
|
||||
"to",
|
||||
"note",
|
||||
"recording_url",
|
||||
"recording_url_path",
|
||||
"reference_doctype",
|
||||
"reference_docname",
|
||||
"creation",
|
||||
],
|
||||
).as_dict()
|
||||
|
||||
call = parse_call_log(call)
|
||||
|
||||
note_names = [call["note"]] if call.get("note") else []
|
||||
task_names = []
|
||||
references = {}
|
||||
|
||||
if call.get("reference_doctype") in ("CRM Lead", "CRM Deal") and call.get("reference_docname"):
|
||||
references[call["reference_doctype"]] = call["reference_docname"]
|
||||
|
||||
for link in call.get("links") or []:
|
||||
link_doctype, link_name = link.get("link_doctype"), link.get("link_name")
|
||||
if link_doctype == "CRM Task":
|
||||
task_names.append(link_name)
|
||||
elif link_doctype == "FCRM Note":
|
||||
note_names.append(link_name)
|
||||
elif link_doctype in ("CRM Lead", "CRM Deal"):
|
||||
references[link_doctype] = link_name
|
||||
|
||||
call["_notes"] = get_permitted_docs("FCRM Note", note_names)
|
||||
call["_tasks"] = get_permitted_docs("CRM Task", task_names)
|
||||
|
||||
lead, deal = references.get("CRM Lead"), references.get("CRM Deal")
|
||||
if lead and frappe.has_permission("CRM Lead", "read", lead):
|
||||
call["_lead"] = lead
|
||||
if deal and frappe.has_permission("CRM Deal", "read", deal):
|
||||
call["_deal"] = deal
|
||||
|
||||
return call
|
||||
|
||||
|
||||
def get_permitted_docs(doctype: str, names: list[str]) -> list[dict]:
|
||||
if not names or not frappe.has_permission(doctype, "read"):
|
||||
return []
|
||||
|
||||
docs = frappe.get_list(doctype, filters={"name": ("in", names)}, fields=["*"], limit=len(names))
|
||||
return sorted(docs, key=lambda doc: names.index(str(doc.name)))
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_lead_from_call_log(call_log: str | dict, lead_details: str | dict | None = None):
|
||||
call_log_data = frappe.parse_json(call_log or {})
|
||||
|
||||
if isinstance(call_log_data, str):
|
||||
call_log_name = call_log_data
|
||||
elif isinstance(call_log_data, dict):
|
||||
call_log_name = call_log_data.get("name")
|
||||
else:
|
||||
call_log_name = None
|
||||
|
||||
if not call_log_name:
|
||||
frappe.throw(_("A valid call log is required."), frappe.ValidationError)
|
||||
|
||||
call_doc = frappe.get_doc("CRM Call Log", call_log_name)
|
||||
|
||||
if not call_doc.has_permission("write"):
|
||||
frappe.throw(_("You are not permitted to update this call log."), frappe.PermissionError)
|
||||
|
||||
if not frappe.has_permission("CRM Lead", "create"):
|
||||
frappe.throw(_("You are not permitted to create leads."), frappe.PermissionError)
|
||||
|
||||
lead_details_data = frappe.parse_json(lead_details or {})
|
||||
if lead_details_data and not isinstance(lead_details_data, dict):
|
||||
frappe.throw(_("Invalid lead details supplied."), frappe.ValidationError)
|
||||
|
||||
lead = frappe.new_doc("CRM Lead")
|
||||
meta = frappe.get_meta("CRM Lead")
|
||||
valid_fieldnames = [df.fieldname for df in meta.fields]
|
||||
|
||||
sanitized_details = {
|
||||
key: value for key, value in (lead_details_data or {}).items() if key in valid_fieldnames
|
||||
}
|
||||
|
||||
if "lead_owner" in valid_fieldnames and not sanitized_details.get("lead_owner"):
|
||||
sanitized_details["lead_owner"] = frappe.session.user
|
||||
|
||||
if "mobile_no" in valid_fieldnames and not sanitized_details.get("mobile_no"):
|
||||
sanitized_details["mobile_no"] = call_doc.get("from") or ""
|
||||
|
||||
if "first_name" in valid_fieldnames and not sanitized_details.get("first_name"):
|
||||
reference_label = sanitized_details.get("mobile_no") or call_doc.name
|
||||
sanitized_details["first_name"] = _("Lead from call {0}").format(reference_label)
|
||||
|
||||
lead.update(sanitized_details)
|
||||
lead.insert()
|
||||
|
||||
call_doc.link_with_reference_doc("CRM Lead", lead.name)
|
||||
call_doc.save()
|
||||
|
||||
return lead.name
|
||||
562
crm/fcrm/doctype/crm_call_log/test_crm_call_log.py
Normal file
562
crm/fcrm/doctype/crm_call_log/test_crm_call_log.py
Normal file
@@ -0,0 +1,562 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import frappe
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
from crm.fcrm.doctype.crm_call_log.crm_call_log import (
|
||||
create_lead_from_call_log,
|
||||
get_call_log,
|
||||
parse_call_log,
|
||||
)
|
||||
from crm.integrations.api import _get_recording_credentials
|
||||
|
||||
|
||||
class TestCRMCallLog(IntegrationTestCase):
|
||||
def tearDown(self):
|
||||
frappe.db.rollback()
|
||||
|
||||
def test_call_log_creation_incoming(self):
|
||||
"""Test creating an incoming call log"""
|
||||
call = create_test_call_log(
|
||||
type="Incoming",
|
||||
status="Completed",
|
||||
caller=None,
|
||||
receiver="Administrator",
|
||||
)
|
||||
|
||||
self.assertEqual(call.type, "Incoming")
|
||||
self.assertEqual(call.status, "Completed")
|
||||
self.assertEqual(call.receiver, "Administrator")
|
||||
|
||||
def test_call_log_creation_outgoing(self):
|
||||
"""Test creating an outgoing call log"""
|
||||
call = create_test_call_log(
|
||||
type="Outgoing",
|
||||
status="Completed",
|
||||
caller="Administrator",
|
||||
receiver=None,
|
||||
)
|
||||
|
||||
self.assertEqual(call.type, "Outgoing")
|
||||
self.assertEqual(call.status, "Completed")
|
||||
self.assertEqual(call.caller, "Administrator")
|
||||
|
||||
def test_call_log_with_duration(self):
|
||||
"""Test call log with duration field"""
|
||||
call = create_test_call_log(
|
||||
type="Incoming",
|
||||
status="Completed",
|
||||
duration=120, # 2 minutes
|
||||
)
|
||||
|
||||
self.assertEqual(call.duration, 120)
|
||||
|
||||
def test_call_log_with_recording_url(self):
|
||||
"""Test call log with recording URL"""
|
||||
recording_url = "https://example.com/recording.mp3"
|
||||
call = create_test_call_log(
|
||||
type="Outgoing",
|
||||
status="Completed",
|
||||
recording_url=recording_url,
|
||||
)
|
||||
|
||||
self.assertEqual(call.recording_url, recording_url)
|
||||
|
||||
def test_has_link_method(self):
|
||||
"""Test has_link method to check if document link exists"""
|
||||
# Create a lead for linking
|
||||
lead = frappe.get_doc(
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Test Lead",
|
||||
"lead_owner": "Administrator",
|
||||
}
|
||||
).insert()
|
||||
|
||||
call = create_test_call_log()
|
||||
|
||||
# Initially no link should exist
|
||||
self.assertFalse(call.has_link("CRM Lead", lead.name))
|
||||
|
||||
# Add link
|
||||
call.link_with_reference_doc("CRM Lead", lead.name)
|
||||
|
||||
# Now link should exist
|
||||
self.assertTrue(call.has_link("CRM Lead", lead.name))
|
||||
|
||||
def test_link_with_reference_doc(self):
|
||||
"""Test linking call log with reference document"""
|
||||
# Create a deal for linking
|
||||
org = frappe.get_doc(
|
||||
{
|
||||
"doctype": "CRM Organization",
|
||||
"organization_name": "Test Org for Call Log",
|
||||
}
|
||||
).insert()
|
||||
|
||||
deal = frappe.get_doc(
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": org.name,
|
||||
"deal_owner": "Administrator",
|
||||
}
|
||||
).insert()
|
||||
|
||||
call = create_test_call_log()
|
||||
|
||||
# Link with deal
|
||||
call.link_with_reference_doc("CRM Deal", deal.name)
|
||||
call.save()
|
||||
|
||||
# Verify link was created
|
||||
self.assertTrue(call.has_link("CRM Deal", deal.name))
|
||||
self.assertEqual(len(call.links), 1)
|
||||
self.assertEqual(call.links[0].link_doctype, "CRM Deal")
|
||||
self.assertEqual(call.links[0].link_name, deal.name)
|
||||
|
||||
def test_link_with_reference_doc_duplicate_prevention(self):
|
||||
"""Test that linking same document twice doesn't create duplicate links"""
|
||||
lead = frappe.get_doc(
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Test Lead",
|
||||
"lead_owner": "Administrator",
|
||||
}
|
||||
).insert()
|
||||
|
||||
call = create_test_call_log()
|
||||
|
||||
# Link twice
|
||||
call.link_with_reference_doc("CRM Lead", lead.name)
|
||||
call.link_with_reference_doc("CRM Lead", lead.name)
|
||||
call.save()
|
||||
|
||||
# Should only have one link
|
||||
self.assertEqual(len(call.links), 1)
|
||||
|
||||
def test_default_list_data_returns_actual_logs(self):
|
||||
"""Test default_list_data returns actual call log data"""
|
||||
from crm.fcrm.doctype.crm_call_log.crm_call_log import CRMCallLog
|
||||
|
||||
# Create test call logs with valid users
|
||||
create_test_call_log(
|
||||
type="Incoming",
|
||||
caller=None,
|
||||
receiver="Administrator",
|
||||
status="Completed",
|
||||
)
|
||||
create_test_call_log(
|
||||
type="Outgoing",
|
||||
caller="Administrator",
|
||||
receiver=None,
|
||||
status="Failed",
|
||||
)
|
||||
|
||||
data = CRMCallLog.default_list_data()
|
||||
|
||||
# Verify structure
|
||||
self.assertIn("columns", data)
|
||||
self.assertIn("rows", data)
|
||||
self.assertIsInstance(data["columns"], list)
|
||||
self.assertIsInstance(data["rows"], list)
|
||||
|
||||
# Verify columns contain required fields
|
||||
self.assertTrue(len(data["columns"]) > 0)
|
||||
column_keys = [col["key"] for col in data["columns"]]
|
||||
self.assertIn("caller", column_keys)
|
||||
self.assertIn("receiver", column_keys)
|
||||
self.assertIn("type", column_keys)
|
||||
self.assertIn("status", column_keys)
|
||||
|
||||
# Verify rows contain data fields
|
||||
self.assertIn("name", data["rows"])
|
||||
self.assertIn("caller", data["rows"])
|
||||
self.assertIn("receiver", data["rows"])
|
||||
self.assertIn("type", data["rows"])
|
||||
self.assertIn("status", data["rows"])
|
||||
|
||||
def test_parse_call_log_incoming(self):
|
||||
"""Test parse_call_log function with incoming call"""
|
||||
# Create a user for testing
|
||||
if not frappe.db.exists("User", "test@example.com"):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "User",
|
||||
"email": "test@example.com",
|
||||
"first_name": "Test",
|
||||
}
|
||||
).insert()
|
||||
|
||||
call_data = {
|
||||
"type": "Incoming",
|
||||
"from": "+1234567890",
|
||||
"to": "+0987654321",
|
||||
"receiver": "test@example.com",
|
||||
"duration": 120,
|
||||
}
|
||||
|
||||
parsed = parse_call_log(call_data)
|
||||
|
||||
# Verify parsed data with actual values
|
||||
self.assertEqual(parsed["activity_type"], "incoming_call")
|
||||
self.assertEqual(parsed["_duration"], "2m")
|
||||
# _caller and _receiver are dicts with label and image
|
||||
self.assertIsInstance(parsed["_caller"], dict)
|
||||
self.assertIn("label", parsed["_caller"])
|
||||
self.assertEqual(parsed["_caller"]["label"], "Unknown") # Phone number resolves to Unknown
|
||||
self.assertIsInstance(parsed["_receiver"], dict)
|
||||
self.assertIn("label", parsed["_receiver"])
|
||||
self.assertEqual(parsed["from"], "+1234567890")
|
||||
self.assertEqual(parsed["to"], "+0987654321")
|
||||
|
||||
def test_parse_call_log_outgoing(self):
|
||||
"""Test parse_call_log function with outgoing call"""
|
||||
call_data = {
|
||||
"type": "Outgoing",
|
||||
"from": "+1234567890",
|
||||
"to": "+0987654321",
|
||||
"caller": "Administrator",
|
||||
"duration": 180,
|
||||
}
|
||||
|
||||
parsed = parse_call_log(call_data)
|
||||
|
||||
# Verify parsed data with actual values
|
||||
self.assertEqual(parsed["activity_type"], "outgoing_call")
|
||||
self.assertEqual(parsed["_duration"], "3m")
|
||||
# _caller and _receiver are dicts with label and image
|
||||
self.assertIsInstance(parsed["_caller"], dict)
|
||||
self.assertEqual(parsed["_caller"]["label"], "Administrator")
|
||||
self.assertIsInstance(parsed["_receiver"], dict)
|
||||
self.assertEqual(parsed["_receiver"]["label"], "Unknown") # Phone number resolves to Unknown
|
||||
self.assertEqual(parsed["from"], "+1234567890")
|
||||
self.assertEqual(parsed["to"], "+0987654321")
|
||||
|
||||
def test_get_call_log_api(self):
|
||||
"""Test get_call_log API function"""
|
||||
call = create_test_call_log(
|
||||
type="Outgoing",
|
||||
status="Completed",
|
||||
duration=60,
|
||||
)
|
||||
|
||||
# Call the API
|
||||
result = get_call_log(call.name)
|
||||
|
||||
# Verify result
|
||||
self.assertEqual(result["name"], call.name)
|
||||
self.assertEqual(result["type"], "Outgoing")
|
||||
self.assertEqual(result["status"], "Completed")
|
||||
self.assertIn("_duration", result)
|
||||
self.assertIn("_tasks", result)
|
||||
self.assertIn("_notes", result)
|
||||
|
||||
def test_get_call_log_denies_user_without_read_access(self):
|
||||
"""A logged in user with no call log access must not be able to read one"""
|
||||
call = create_test_call_log(type="Outgoing", status="Completed")
|
||||
|
||||
if not frappe.db.exists("User", "no-roles-user@example.com"):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "User",
|
||||
"email": "no-roles-user@example.com",
|
||||
"first_name": "No Roles",
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
frappe.set_user("no-roles-user@example.com")
|
||||
try:
|
||||
with self.assertRaises(frappe.PermissionError):
|
||||
get_call_log(call.name)
|
||||
finally:
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def test_get_call_log_hides_lead_user_cannot_read(self):
|
||||
"""A user who can read the call log but not its lead must not receive the lead id"""
|
||||
lead = frappe.get_doc(
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Restricted Lead",
|
||||
"lead_owner": "Administrator",
|
||||
}
|
||||
).insert()
|
||||
call = create_test_call_log(reference_doctype="CRM Lead", reference_docname=lead.name)
|
||||
|
||||
if not frappe.db.exists("User", "sales-user@example.com"):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "User",
|
||||
"email": "sales-user@example.com",
|
||||
"first_name": "Sales",
|
||||
"roles": [{"role": "Sales User"}],
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
frappe.set_user("sales-user@example.com")
|
||||
try:
|
||||
result = get_call_log(call.name)
|
||||
finally:
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
self.assertEqual(result["name"], call.name)
|
||||
self.assertNotIn("_lead", result)
|
||||
|
||||
def test_get_call_log_with_reference_lead(self):
|
||||
"""Test get_call_log API with reference to CRM Lead"""
|
||||
lead = frappe.get_doc(
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Test Lead",
|
||||
"lead_owner": "Administrator",
|
||||
}
|
||||
).insert()
|
||||
|
||||
call = create_test_call_log(
|
||||
reference_doctype="CRM Lead",
|
||||
reference_docname=lead.name,
|
||||
)
|
||||
|
||||
result = get_call_log(call.name)
|
||||
|
||||
# Verify lead reference
|
||||
self.assertEqual(result.get("_lead"), lead.name)
|
||||
|
||||
def test_get_call_log_with_reference_deal(self):
|
||||
"""Test get_call_log API with reference to CRM Deal"""
|
||||
org = frappe.get_doc(
|
||||
{
|
||||
"doctype": "CRM Organization",
|
||||
"organization_name": "Test Org for Call",
|
||||
}
|
||||
).insert()
|
||||
|
||||
deal = frappe.get_doc(
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": org.name,
|
||||
"deal_owner": "Administrator",
|
||||
}
|
||||
).insert()
|
||||
|
||||
call = create_test_call_log(
|
||||
reference_doctype="CRM Deal",
|
||||
reference_docname=deal.name,
|
||||
)
|
||||
|
||||
result = get_call_log(call.name)
|
||||
|
||||
# Verify deal reference
|
||||
self.assertEqual(result.get("_deal"), deal.name)
|
||||
|
||||
def test_get_call_log_with_linked_task(self):
|
||||
"""Test get_call_log API with linked CRM Task"""
|
||||
call = create_test_call_log()
|
||||
|
||||
# Create and link a task
|
||||
task = frappe.get_doc(
|
||||
{
|
||||
"doctype": "CRM Task",
|
||||
"title": "Follow up call",
|
||||
"assigned_to": "Administrator",
|
||||
}
|
||||
).insert()
|
||||
|
||||
call.link_with_reference_doc("CRM Task", task.name)
|
||||
call.save()
|
||||
|
||||
result = get_call_log(call.name)
|
||||
|
||||
# Verify task is in results
|
||||
self.assertEqual(len(result["_tasks"]), 1)
|
||||
self.assertEqual(result["_tasks"][0]["name"], task.name)
|
||||
|
||||
def test_get_call_log_returns_all_linked_tasks_beyond_default_page_length(self):
|
||||
"""Linked records must not be cut off by the framework's default list page length"""
|
||||
call = create_test_call_log()
|
||||
task_count = 25
|
||||
|
||||
for i in range(task_count):
|
||||
task = frappe.get_doc(
|
||||
{
|
||||
"doctype": "CRM Task",
|
||||
"title": f"Follow up {i}",
|
||||
"assigned_to": "Administrator",
|
||||
}
|
||||
).insert()
|
||||
call.link_with_reference_doc("CRM Task", task.name)
|
||||
call.save()
|
||||
|
||||
result = get_call_log(call.name)
|
||||
|
||||
self.assertEqual(len(result["_tasks"]), task_count)
|
||||
|
||||
def test_create_lead_from_call_log_basic(self):
|
||||
"""Test creating a lead from call log"""
|
||||
call = create_test_call_log(
|
||||
type="Incoming",
|
||||
from_number="+1234567890",
|
||||
)
|
||||
|
||||
# Create lead from call log
|
||||
lead_name = create_lead_from_call_log(
|
||||
call_log=frappe.as_json({"name": call.name}),
|
||||
lead_details=frappe.as_json({"first_name": "John", "last_name": "Doe"}),
|
||||
)
|
||||
|
||||
# Verify lead was created
|
||||
self.assertTrue(frappe.db.exists("CRM Lead", lead_name))
|
||||
lead = frappe.get_doc("CRM Lead", lead_name)
|
||||
self.assertEqual(lead.first_name, "John")
|
||||
self.assertEqual(lead.last_name, "Doe")
|
||||
self.assertEqual(lead.mobile_no, "+1234567890")
|
||||
|
||||
# Verify call log was linked
|
||||
call.reload()
|
||||
self.assertTrue(call.has_link("CRM Lead", lead_name))
|
||||
|
||||
def test_create_lead_from_call_log_with_owner(self):
|
||||
"""Test creating lead from call log with specific owner"""
|
||||
call = create_test_call_log(
|
||||
type="Outgoing",
|
||||
to="+9876543210",
|
||||
)
|
||||
|
||||
lead_name = create_lead_from_call_log(
|
||||
call_log=frappe.as_json({"name": call.name}),
|
||||
lead_details=frappe.as_json({"first_name": "Jane", "lead_owner": "Administrator"}),
|
||||
)
|
||||
|
||||
lead = frappe.get_doc("CRM Lead", lead_name)
|
||||
self.assertEqual(lead.lead_owner, "Administrator")
|
||||
|
||||
def test_create_lead_from_call_log_no_details(self):
|
||||
"""Test creating lead from call log without lead details"""
|
||||
call = create_test_call_log(
|
||||
type="Incoming",
|
||||
from_number="+1112223333",
|
||||
)
|
||||
|
||||
lead_name = create_lead_from_call_log(call_log=frappe.as_json({"name": call.name}))
|
||||
|
||||
# Verify lead was created with default name
|
||||
lead = frappe.get_doc("CRM Lead", lead_name)
|
||||
self.assertTrue(lead.first_name.startswith("Lead from call"))
|
||||
self.assertEqual(lead.mobile_no, "+1112223333")
|
||||
|
||||
def test_create_lead_from_call_log_invalid_call_log(self):
|
||||
"""Test that invalid call log throws error"""
|
||||
with self.assertRaises(frappe.DoesNotExistError):
|
||||
create_lead_from_call_log(call_log=frappe.as_json({"name": "invalid_name"}))
|
||||
|
||||
def test_create_lead_from_call_log_permission_check(self):
|
||||
"""Test that permission is checked when creating lead from call log"""
|
||||
create_test_call_log()
|
||||
|
||||
# Verify that empty call log throws error
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
create_lead_from_call_log(call_log=frappe.as_json({}))
|
||||
|
||||
def test_call_log_status_filtering(self):
|
||||
"""Test that call logs can be filtered by status"""
|
||||
# Create calls with different statuses
|
||||
completed_call = create_test_call_log(status="Completed", type="Incoming")
|
||||
failed_call = create_test_call_log(status="Failed", type="Outgoing")
|
||||
busy_call = create_test_call_log(status="Busy", type="Incoming")
|
||||
|
||||
# Verify statuses are set correctly
|
||||
self.assertEqual(completed_call.status, "Completed")
|
||||
self.assertEqual(failed_call.status, "Failed")
|
||||
self.assertEqual(busy_call.status, "Busy")
|
||||
|
||||
# Test filtering by status
|
||||
completed_logs = frappe.get_all("CRM Call Log", filters={"status": "Completed"})
|
||||
failed_logs = frappe.get_all("CRM Call Log", filters={"status": "Failed"})
|
||||
|
||||
self.assertGreaterEqual(len(completed_logs), 1)
|
||||
self.assertGreaterEqual(len(failed_logs), 1)
|
||||
|
||||
# Verify our specific calls are in the right filtered lists
|
||||
completed_names = [log.name for log in completed_logs]
|
||||
failed_names = [log.name for log in failed_logs]
|
||||
|
||||
self.assertIn(completed_call.name, completed_names)
|
||||
self.assertIn(failed_call.name, failed_names)
|
||||
self.assertNotIn(busy_call.name, completed_names)
|
||||
self.assertNotIn(busy_call.name, failed_names)
|
||||
|
||||
def test_call_log_with_telephony_medium(self):
|
||||
"""Test call log with different telephony mediums"""
|
||||
call = create_test_call_log(telephony_medium="Twilio")
|
||||
self.assertEqual(call.telephony_medium, "Twilio")
|
||||
|
||||
call2 = create_test_call_log(telephony_medium="Exotel")
|
||||
self.assertEqual(call2.telephony_medium, "Exotel")
|
||||
|
||||
def test_recording_credentials_manual_medium_needs_no_auth(self):
|
||||
"""A manually added recording is fetched as-is, with no provider auth."""
|
||||
self.assertIsNone(_get_recording_credentials("Manual"))
|
||||
|
||||
def test_recording_credentials_unknown_medium_returns_none(self):
|
||||
"""An empty/unrecognized medium must not raise; it just skips auth."""
|
||||
self.assertIsNone(_get_recording_credentials(""))
|
||||
self.assertIsNone(_get_recording_credentials(None))
|
||||
|
||||
def test_recording_credentials_twilio_unconfigured_returns_none(self):
|
||||
"""Twilio without a configured secret falls back to no auth instead of raising.
|
||||
|
||||
Regression: get_password used to raise when the secret was unset, which made
|
||||
the recording proxy 500 and the UI show "Recording not available".
|
||||
"""
|
||||
settings = MagicMock()
|
||||
settings.api_key = "ACxxxxxxxx"
|
||||
settings.get_password.return_value = None
|
||||
with patch("crm.integrations.api.frappe.get_single", return_value=settings):
|
||||
self.assertIsNone(_get_recording_credentials("Twilio"))
|
||||
|
||||
def test_recording_credentials_twilio_configured_returns_tuple(self):
|
||||
"""Twilio with both key and secret set yields the auth pair."""
|
||||
settings = MagicMock()
|
||||
settings.api_key = "ACxxxxxxxx"
|
||||
settings.get_password.return_value = "twilio_secret"
|
||||
with patch("crm.integrations.api.frappe.get_single", return_value=settings):
|
||||
self.assertEqual(_get_recording_credentials("Twilio"), ("ACxxxxxxxx", "twilio_secret"))
|
||||
|
||||
def test_recording_credentials_exotel_configured_returns_tuple(self):
|
||||
"""Exotel with both key and token set yields the auth pair."""
|
||||
settings = MagicMock()
|
||||
settings.api_key = "exotel_key"
|
||||
settings.get_password.return_value = "exotel_token"
|
||||
with patch("crm.integrations.api.frappe.get_single", return_value=settings):
|
||||
self.assertEqual(_get_recording_credentials("Exotel"), ("exotel_key", "exotel_token"))
|
||||
|
||||
|
||||
def create_test_call_log(**kwargs):
|
||||
"""Helper function to create a CRM Call Log for testing"""
|
||||
import uuid
|
||||
|
||||
# Generate unique ID if not provided (required for autoname)
|
||||
unique_id = kwargs.pop("id", str(uuid.uuid4())[:10])
|
||||
|
||||
# Handle special field name mapping
|
||||
if "from_number" in kwargs:
|
||||
kwargs["from"] = kwargs.pop("from_number")
|
||||
|
||||
# Set defaults for required fields
|
||||
data = {
|
||||
"doctype": "CRM Call Log",
|
||||
"id": unique_id,
|
||||
"type": "Incoming",
|
||||
"status": "Completed",
|
||||
"to": "+1234567890",
|
||||
"from": "+0987654321",
|
||||
}
|
||||
|
||||
# Update with all provided kwargs
|
||||
data.update(kwargs)
|
||||
|
||||
call_log = frappe.get_doc(data)
|
||||
call_log.insert()
|
||||
return call_log
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("CRM Communication Status", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:status",
|
||||
"creation": "2023-12-13 13:25:07.213100",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"status"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "status",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Status",
|
||||
"reqd": 1,
|
||||
"unique": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-28 17:10:00.827558",
|
||||
"modified_by": "shariq@frappe.io",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Communication Status",
|
||||
"naming_rule": "By fieldname",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales User",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class CRMCommunicationStatus(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
status: DF.Data
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestCRMCommunicationStatus(UnitTestCase):
|
||||
pass
|
||||
0
crm/fcrm/doctype/crm_contacts/__init__.py
Normal file
0
crm/fcrm/doctype/crm_contacts/__init__.py
Normal file
92
crm/fcrm/doctype/crm_contacts/crm_contacts.json
Normal file
92
crm/fcrm/doctype/crm_contacts/crm_contacts.json
Normal file
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2023-08-25 19:08:36.356133",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"contact",
|
||||
"full_name",
|
||||
"email",
|
||||
"column_break_uvny",
|
||||
"gender",
|
||||
"mobile_no",
|
||||
"phone",
|
||||
"is_primary"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "contact",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Contact",
|
||||
"options": "Contact"
|
||||
},
|
||||
{
|
||||
"fetch_from": "contact.full_name",
|
||||
"fieldname": "full_name",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Full Name",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fetch_from": "contact.email_id",
|
||||
"fieldname": "email",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Email",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_uvny",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fetch_from": "contact.mobile_no",
|
||||
"fieldname": "mobile_no",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Mobile No.",
|
||||
"options": "Phone",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fetch_from": "contact.phone",
|
||||
"fieldname": "phone",
|
||||
"fieldtype": "Data",
|
||||
"label": "Phone",
|
||||
"options": "Phone",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fetch_from": "contact.gender",
|
||||
"fieldname": "gender",
|
||||
"fieldtype": "Link",
|
||||
"label": "Gender",
|
||||
"options": "Gender",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "is_primary",
|
||||
"fieldtype": "Check",
|
||||
"in_list_view": 1,
|
||||
"label": "Is Primary"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-23 12:25:24.755041",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Contacts",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
29
crm/fcrm/doctype/crm_contacts/crm_contacts.py
Normal file
29
crm/fcrm/doctype/crm_contacts/crm_contacts.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class CRMContacts(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
contact: DF.Link | None
|
||||
email: DF.Data | None
|
||||
full_name: DF.Data | None
|
||||
gender: DF.Link | None
|
||||
is_primary: DF.Check
|
||||
mobile_no: DF.Data | None
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
phone: DF.Data | None
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
0
crm/fcrm/doctype/crm_dashboard/__init__.py
Normal file
0
crm/fcrm/doctype/crm_dashboard/__init__.py
Normal file
8
crm/fcrm/doctype/crm_dashboard/crm_dashboard.js
Normal file
8
crm/fcrm/doctype/crm_dashboard/crm_dashboard.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("CRM Dashboard", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
102
crm/fcrm/doctype/crm_dashboard/crm_dashboard.json
Normal file
102
crm/fcrm/doctype/crm_dashboard/crm_dashboard.json
Normal file
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:title",
|
||||
"creation": "2025-07-14 12:19:49.725022",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"title",
|
||||
"private",
|
||||
"column_break_exbw",
|
||||
"user",
|
||||
"section_break_hfza",
|
||||
"layout"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "column_break_exbw",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_hfza",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"default": "[]",
|
||||
"fieldname": "layout",
|
||||
"fieldtype": "Code",
|
||||
"label": "Layout",
|
||||
"options": "JSON"
|
||||
},
|
||||
{
|
||||
"fieldname": "title",
|
||||
"fieldtype": "Data",
|
||||
"label": "Name",
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "private",
|
||||
"fieldname": "user",
|
||||
"fieldtype": "Link",
|
||||
"label": "User",
|
||||
"mandatory_depends_on": "private",
|
||||
"options": "User"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "private",
|
||||
"fieldtype": "Check",
|
||||
"label": "Private"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-04-14 12:36:10.831351",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Dashboard",
|
||||
"naming_rule": "By fieldname",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales User",
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "title"
|
||||
}
|
||||
33
crm/fcrm/doctype/crm_dashboard/crm_dashboard.py
Normal file
33
crm/fcrm/doctype/crm_dashboard/crm_dashboard.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class CRMDashboard(Document):
|
||||
pass
|
||||
|
||||
|
||||
def default_manager_dashboard_layout():
|
||||
"""
|
||||
Returns the default layout for the CRM Manager Dashboard.
|
||||
"""
|
||||
return '[{"name":"total_leads","type":"number_chart","tooltip":"Total number of leads","layout":{"x":0,"y":0,"w":4,"h":3,"i":"total_leads"}},{"name":"ongoing_deals","type":"number_chart","tooltip":"Total number of ongoing deals","layout":{"x":8,"y":0,"w":4,"h":3,"i":"ongoing_deals"}},{"name":"won_deals","type":"number_chart","tooltip":"Total number of won deals","layout":{"x":12,"y":0,"w":4,"h":3,"i":"won_deals"}},{"name":"average_won_deal_value","type":"number_chart","tooltip":"Average value of won deals","layout":{"x":16,"y":0,"w":4,"h":3,"i":"average_won_deal_value"}},{"name":"average_deal_value","type":"number_chart","tooltip":"Average deal value of ongoing and won deals","layout":{"x":0,"y":2,"w":4,"h":3,"i":"average_deal_value"}},{"name":"average_time_to_close_a_lead","type":"number_chart","tooltip":"Average time taken to close a lead","layout":{"x":4,"y":0,"w":4,"h":3,"i":"average_time_to_close_a_lead"}},{"name":"average_time_to_close_a_deal","type":"number_chart","layout":{"x":4,"y":2,"w":4,"h":3,"i":"average_time_to_close_a_deal"}},{"name":"spacer","type":"spacer","layout":{"x":8,"y":2,"w":12,"h":3,"i":"spacer"}},{"name":"sales_trend","type":"axis_chart","layout":{"x":0,"y":4,"w":10,"h":9,"i":"sales_trend"}},{"name":"forecasted_revenue","type":"axis_chart","layout":{"x":10,"y":4,"w":10,"h":9,"i":"forecasted_revenue"}},{"name":"funnel_conversion","type":"axis_chart","layout":{"x":0,"y":11,"w":10,"h":9,"i":"funnel_conversion"}},{"name":"deals_by_stage_donut","type":"donut_chart","layout":{"x":10,"y":11,"w":10,"h":9,"i":"deals_by_stage_donut"}},{"name":"lost_deal_reasons","type":"axis_chart","layout":{"x":0,"y":32,"w":20,"h":9,"i":"lost_deal_reasons"}},{"name":"leads_by_source","type":"donut_chart","layout":{"x":0,"y":18,"w":10,"h":9,"i":"leads_by_source"}},{"name":"deals_by_source","type":"donut_chart","layout":{"x":10,"y":18,"w":10,"h":9,"i":"deals_by_source"}},{"name":"deals_by_territory","type":"axis_chart","layout":{"x":0,"y":25,"w":10,"h":9,"i":"deals_by_territory"}},{"name":"deals_by_salesperson","type":"axis_chart","layout":{"x":10,"y":25,"w":10,"h":9,"i":"deals_by_salesperson"}}]'
|
||||
|
||||
|
||||
def create_default_manager_dashboard(force=False):
|
||||
"""
|
||||
Creates the default CRM Manager Dashboard if it does not exist.
|
||||
"""
|
||||
if not frappe.db.exists("CRM Dashboard", "Manager Dashboard"):
|
||||
doc = frappe.new_doc("CRM Dashboard")
|
||||
doc.title = "Manager Dashboard"
|
||||
doc.layout = default_manager_dashboard_layout()
|
||||
doc.insert(ignore_permissions=True)
|
||||
else:
|
||||
doc = frappe.get_doc("CRM Dashboard", "Manager Dashboard")
|
||||
if force:
|
||||
doc.layout = default_manager_dashboard_layout()
|
||||
doc.save(ignore_permissions=True)
|
||||
return doc.layout
|
||||
29
crm/fcrm/doctype/crm_dashboard/test_crm_dashboard.py
Normal file
29
crm/fcrm/doctype/crm_dashboard/test_crm_dashboard.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
|
||||
# On IntegrationTestCase, the doctype test records and all
|
||||
# link-field test record dependencies are recursively loaded
|
||||
# Use these module variables to add/remove to/from that list
|
||||
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
|
||||
|
||||
class UnitTestCRMDashboard(UnitTestCase):
|
||||
"""
|
||||
Unit tests for CRMDashboard.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class IntegrationTestCRMDashboard(IntegrationTestCase):
|
||||
"""
|
||||
Integration tests for CRMDashboard.
|
||||
Use this class for testing interactions between multiple components.
|
||||
"""
|
||||
|
||||
pass
|
||||
0
crm/fcrm/doctype/crm_deal/__init__.py
Normal file
0
crm/fcrm/doctype/crm_deal/__init__.py
Normal file
34
crm/fcrm/doctype/crm_deal/api.py
Normal file
34
crm/fcrm/doctype/crm_deal/api.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_deal_contacts(name: str):
|
||||
if not frappe.has_permission("CRM Deal", "read", name):
|
||||
frappe.throw(_("Not permitted"), frappe.PermissionError)
|
||||
|
||||
contacts = frappe.get_all(
|
||||
"CRM Contacts",
|
||||
filters={"parenttype": "CRM Deal", "parent": name},
|
||||
fields=["contact", "is_primary"],
|
||||
order_by="is_primary desc, idx asc",
|
||||
distinct=True,
|
||||
)
|
||||
deal_contacts = []
|
||||
for contact in contacts:
|
||||
if not contact.contact:
|
||||
continue
|
||||
|
||||
is_primary = contact.is_primary
|
||||
contact = frappe.get_doc("Contact", contact.contact).as_dict()
|
||||
|
||||
_contact = {
|
||||
"name": contact.name,
|
||||
"image": contact.image,
|
||||
"full_name": contact.full_name,
|
||||
"email": contact.email_id,
|
||||
"mobile_no": contact.mobile_no,
|
||||
"is_primary": is_primary,
|
||||
}
|
||||
deal_contacts.append(_contact)
|
||||
return deal_contacts
|
||||
67
crm/fcrm/doctype/crm_deal/crm_deal.js
Normal file
67
crm/fcrm/doctype/crm_deal/crm_deal.js
Normal file
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on("CRM Deal", {
|
||||
refresh(frm) {
|
||||
frm.add_web_link(`/crm/deals/${frm.doc.name}`, __("Open in Portal"));
|
||||
},
|
||||
update_total: function (frm) {
|
||||
let total = 0;
|
||||
let total_qty = 0;
|
||||
let net_total = 0;
|
||||
frm.doc.products.forEach((d) => {
|
||||
total += d.amount;
|
||||
total_qty += d.qty;
|
||||
net_total += d.net_amount;
|
||||
});
|
||||
|
||||
frappe.model.set_value(frm.doctype, frm.docname, "total", total);
|
||||
frappe.model.set_value(
|
||||
frm.doctype,
|
||||
frm.docname,
|
||||
"net_total",
|
||||
net_total || total,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("CRM Products", {
|
||||
products_add: function (frm, cdt, cdn) {
|
||||
frm.trigger("update_total");
|
||||
},
|
||||
products_remove: function (frm, cdt, cdn) {
|
||||
frm.trigger("update_total");
|
||||
},
|
||||
product_code: function (frm, cdt, cdn) {
|
||||
let d = frappe.get_doc(cdt, cdn);
|
||||
frappe.model.set_value(cdt, cdn, "product_name", d.product_code);
|
||||
},
|
||||
rate: function (frm, cdt, cdn) {
|
||||
let d = frappe.get_doc(cdt, cdn);
|
||||
if (d.rate && d.qty) {
|
||||
frappe.model.set_value(cdt, cdn, "amount", d.rate * d.qty);
|
||||
}
|
||||
frm.trigger("update_total");
|
||||
},
|
||||
qty: function (frm, cdt, cdn) {
|
||||
let d = frappe.get_doc(cdt, cdn);
|
||||
if (d.rate && d.qty) {
|
||||
frappe.model.set_value(cdt, cdn, "amount", d.rate * d.qty);
|
||||
}
|
||||
frm.trigger("update_total");
|
||||
},
|
||||
discount_percentage: function (frm, cdt, cdn) {
|
||||
let d = frappe.get_doc(cdt, cdn);
|
||||
if (d.discount_percentage && d.amount) {
|
||||
discount_amount = (d.discount_percentage / 100) * d.amount;
|
||||
frappe.model.set_value(cdt, cdn, "discount_amount", discount_amount);
|
||||
frappe.model.set_value(
|
||||
cdt,
|
||||
cdn,
|
||||
"net_amount",
|
||||
d.amount - discount_amount,
|
||||
);
|
||||
}
|
||||
frm.trigger("update_total");
|
||||
},
|
||||
});
|
||||
575
crm/fcrm/doctype/crm_deal/crm_deal.json
Normal file
575
crm/fcrm/doctype/crm_deal/crm_deal.json
Normal file
@@ -0,0 +1,575 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_import": 1,
|
||||
"allow_rename": 1,
|
||||
"autoname": "naming_series:",
|
||||
"creation": "2023-11-06 17:56:25.210449",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"organization_tab",
|
||||
"naming_series",
|
||||
"organization",
|
||||
"next_step",
|
||||
"column_break_ijan",
|
||||
"status",
|
||||
"deal_owner",
|
||||
"section_break_jgpm",
|
||||
"probability",
|
||||
"expected_deal_value",
|
||||
"deal_value",
|
||||
"column_break_kpxa",
|
||||
"expected_closure_date",
|
||||
"closed_date",
|
||||
"contacts_tab",
|
||||
"contacts",
|
||||
"contact",
|
||||
"lead_details_tab",
|
||||
"lead",
|
||||
"source",
|
||||
"column_break_wsde",
|
||||
"lead_name",
|
||||
"organization_details_section",
|
||||
"organization_name",
|
||||
"website",
|
||||
"organization_logo",
|
||||
"company_description",
|
||||
"linkedin",
|
||||
"twitter",
|
||||
"facebook",
|
||||
"no_of_employees",
|
||||
"job_title",
|
||||
"column_break_xbyf",
|
||||
"territory",
|
||||
"currency",
|
||||
"exchange_rate",
|
||||
"annual_revenue",
|
||||
"industry",
|
||||
"person_section",
|
||||
"salutation",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"column_break_xjmy",
|
||||
"email",
|
||||
"mobile_no",
|
||||
"phone",
|
||||
"gender",
|
||||
"products_tab",
|
||||
"products",
|
||||
"section_break_ccbj",
|
||||
"total",
|
||||
"column_break_udbq",
|
||||
"net_total",
|
||||
"sla_tab",
|
||||
"sla",
|
||||
"sla_creation",
|
||||
"column_break_pfvq",
|
||||
"sla_status",
|
||||
"communication_status",
|
||||
"response_details_section",
|
||||
"response_by",
|
||||
"column_break_hpvj",
|
||||
"first_response_time",
|
||||
"first_responded_on",
|
||||
"section_break_mwvg",
|
||||
"rolling_responses",
|
||||
"section_break_jgdr",
|
||||
"last_response_time",
|
||||
"column_break_evzj",
|
||||
"last_responded_on",
|
||||
"log_tab",
|
||||
"status_change_log",
|
||||
"lost_details_tab",
|
||||
"lost_reason",
|
||||
"lost_notes"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "organization",
|
||||
"fieldtype": "Link",
|
||||
"in_standard_filter": 1,
|
||||
"label": "Organization",
|
||||
"options": "CRM Organization"
|
||||
},
|
||||
{
|
||||
"fieldname": "probability",
|
||||
"fieldtype": "Percent",
|
||||
"label": "Probability"
|
||||
},
|
||||
{
|
||||
"fetch_from": ".annual_revenue",
|
||||
"fieldname": "annual_revenue",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Annual Revenue",
|
||||
"non_negative": 1,
|
||||
"options": "currency"
|
||||
},
|
||||
{
|
||||
"fetch_from": ".website",
|
||||
"fieldname": "website",
|
||||
"fieldtype": "Data",
|
||||
"label": "Website"
|
||||
},
|
||||
{
|
||||
"fieldname": "next_step",
|
||||
"fieldtype": "Data",
|
||||
"label": "Next Step"
|
||||
},
|
||||
{
|
||||
"fieldname": "lead",
|
||||
"fieldtype": "Link",
|
||||
"label": "Lead",
|
||||
"options": "CRM Lead"
|
||||
},
|
||||
{
|
||||
"fieldname": "deal_owner",
|
||||
"fieldtype": "Link",
|
||||
"label": "Deal Owner",
|
||||
"options": "User"
|
||||
},
|
||||
{
|
||||
"default": "CRM-DEAL-.YYYY.-",
|
||||
"fieldname": "naming_series",
|
||||
"fieldtype": "Select",
|
||||
"label": "Naming Series",
|
||||
"options": "CRM-DEAL-.YYYY.-"
|
||||
},
|
||||
{
|
||||
"fieldname": "contacts_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Contacts"
|
||||
},
|
||||
{
|
||||
"fieldname": "email",
|
||||
"fieldtype": "Data",
|
||||
"in_standard_filter": 1,
|
||||
"label": "Primary email",
|
||||
"options": "Email"
|
||||
},
|
||||
{
|
||||
"fieldname": "mobile_no",
|
||||
"fieldtype": "Data",
|
||||
"label": "Primary mobile no",
|
||||
"options": "Phone"
|
||||
},
|
||||
{
|
||||
"fieldname": "status",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Status",
|
||||
"options": "CRM Deal Status",
|
||||
"reqd": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "contacts",
|
||||
"fieldtype": "Table",
|
||||
"label": "Contacts",
|
||||
"options": "CRM Contacts"
|
||||
},
|
||||
{
|
||||
"fieldname": "organization_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Organization"
|
||||
},
|
||||
{
|
||||
"fieldname": "sla_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "SLA",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "sla",
|
||||
"fieldtype": "Link",
|
||||
"label": "SLA",
|
||||
"options": "CRM Service Level Agreement"
|
||||
},
|
||||
{
|
||||
"fieldname": "response_by",
|
||||
"fieldtype": "Datetime",
|
||||
"label": "Response By",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_pfvq",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "sla_status",
|
||||
"fieldtype": "Select",
|
||||
"label": "SLA Status",
|
||||
"options": "\nFirst Response Due\nRolling Response Due\nFailed\nFulfilled",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "sla_creation",
|
||||
"fieldtype": "Datetime",
|
||||
"label": "SLA Creation",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "response_details_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Response Details"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_hpvj",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "first_response_time",
|
||||
"fieldtype": "Duration",
|
||||
"label": "First Response Time",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "first_responded_on",
|
||||
"fieldtype": "Datetime",
|
||||
"label": "First Responded On",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "Open",
|
||||
"fieldname": "communication_status",
|
||||
"fieldtype": "Link",
|
||||
"label": "Communication Status",
|
||||
"options": "CRM Communication Status"
|
||||
},
|
||||
{
|
||||
"fetch_from": ".territory",
|
||||
"fieldname": "territory",
|
||||
"fieldtype": "Link",
|
||||
"label": "Territory",
|
||||
"options": "CRM Territory"
|
||||
},
|
||||
{
|
||||
"fieldname": "source",
|
||||
"fieldtype": "Link",
|
||||
"label": "Source",
|
||||
"options": "CRM Lead Source"
|
||||
},
|
||||
{
|
||||
"fieldname": "no_of_employees",
|
||||
"fieldtype": "Select",
|
||||
"label": "No. of Employees",
|
||||
"options": "1-10\n11-50\n51-200\n201-500\n501-1000\n1000+"
|
||||
},
|
||||
{
|
||||
"fieldname": "job_title",
|
||||
"fieldtype": "Data",
|
||||
"label": "Job Title"
|
||||
},
|
||||
{
|
||||
"fieldname": "phone",
|
||||
"fieldtype": "Data",
|
||||
"label": "Primary phone",
|
||||
"options": "Phone"
|
||||
},
|
||||
{
|
||||
"fieldname": "log_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Log",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "status_change_log",
|
||||
"fieldtype": "Table",
|
||||
"label": "Status Change Log",
|
||||
"options": "CRM Status Change Log"
|
||||
},
|
||||
{
|
||||
"fieldname": "lead_name",
|
||||
"fieldtype": "Data",
|
||||
"label": "Lead Name"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_ijan",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "lead_details_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Lead Details"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_wsde",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "organization_details_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Organization Details"
|
||||
},
|
||||
{
|
||||
"fieldname": "organization_name",
|
||||
"fieldtype": "Data",
|
||||
"label": "Organization Name"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_xbyf",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "industry",
|
||||
"fieldtype": "Link",
|
||||
"label": "Industry",
|
||||
"options": "CRM Industry"
|
||||
},
|
||||
{
|
||||
"fieldname": "person_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Person"
|
||||
},
|
||||
{
|
||||
"fieldname": "salutation",
|
||||
"fieldtype": "Link",
|
||||
"label": "Salutation",
|
||||
"options": "Salutation"
|
||||
},
|
||||
{
|
||||
"fieldname": "first_name",
|
||||
"fieldtype": "Data",
|
||||
"label": "First name"
|
||||
},
|
||||
{
|
||||
"fieldname": "last_name",
|
||||
"fieldtype": "Data",
|
||||
"label": "Last name"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_xjmy",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "gender",
|
||||
"fieldtype": "Link",
|
||||
"label": "Gender",
|
||||
"options": "Gender"
|
||||
},
|
||||
{
|
||||
"fieldname": "contact",
|
||||
"fieldtype": "Link",
|
||||
"label": "Contact",
|
||||
"options": "Contact"
|
||||
},
|
||||
{
|
||||
"fieldname": "currency",
|
||||
"fieldtype": "Link",
|
||||
"label": "Currency",
|
||||
"options": "Currency"
|
||||
},
|
||||
{
|
||||
"fieldname": "products_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Products"
|
||||
},
|
||||
{
|
||||
"fieldname": "products",
|
||||
"fieldtype": "Table",
|
||||
"label": "Products",
|
||||
"options": "CRM Products"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_ccbj",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_udbq",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "total",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Total",
|
||||
"non_negative": 1,
|
||||
"options": "currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"description": "Total after discount",
|
||||
"fieldname": "net_total",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Net Total",
|
||||
"non_negative": 1,
|
||||
"options": "currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_jgpm",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "deal_value",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Deal Value",
|
||||
"non_negative": 1,
|
||||
"options": "currency"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_kpxa",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "lost_reason",
|
||||
"fieldtype": "Link",
|
||||
"label": "Lost Reason",
|
||||
"options": "CRM Lost Reason"
|
||||
},
|
||||
{
|
||||
"fieldname": "lost_notes",
|
||||
"fieldtype": "Text",
|
||||
"label": "Lost Notes",
|
||||
"mandatory_depends_on": "eval: doc.lost_reason == \"Other\""
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"description": "The rate used to convert the deal\u2019s currency to your crm's base currency (set in CRM Settings). It is set once when the currency is first added and doesn't change automatically.",
|
||||
"fieldname": "exchange_rate",
|
||||
"fieldtype": "Float",
|
||||
"label": "Exchange Rate"
|
||||
},
|
||||
{
|
||||
"fieldname": "expected_deal_value",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Expected Deal Value",
|
||||
"non_negative": 1,
|
||||
"options": "currency"
|
||||
},
|
||||
{
|
||||
"fieldname": "expected_closure_date",
|
||||
"fieldtype": "Date",
|
||||
"label": "Expected Closure Date"
|
||||
},
|
||||
{
|
||||
"fieldname": "closed_date",
|
||||
"fieldtype": "Date",
|
||||
"label": "Closed Date"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_mwvg",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "rolling_responses",
|
||||
"fieldtype": "Table",
|
||||
"label": "Rolling Responses",
|
||||
"options": "CRM Rolling Response Time"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_jgdr",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "last_response_time",
|
||||
"fieldtype": "Duration",
|
||||
"label": "Last Response Time",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_evzj",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "last_responded_on",
|
||||
"fieldtype": "Datetime",
|
||||
"label": "Last Responded On",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "lost_details_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Lost Details"
|
||||
},
|
||||
{
|
||||
"fieldname": "organization_logo",
|
||||
"fieldtype": "Attach Image",
|
||||
"label": "Organization Logo",
|
||||
"no_copy": 1,
|
||||
"description": "Company logo discovered by Domain Enrichment."
|
||||
},
|
||||
{
|
||||
"fieldname": "company_description",
|
||||
"fieldtype": "Small Text",
|
||||
"label": "Company Description",
|
||||
"no_copy": 1,
|
||||
"description": "Company description discovered by Domain Enrichment."
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.linkedin",
|
||||
"fieldname": "linkedin",
|
||||
"fieldtype": "Data",
|
||||
"label": "LinkedIn",
|
||||
"no_copy": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.twitter",
|
||||
"fieldname": "twitter",
|
||||
"fieldtype": "Data",
|
||||
"label": "X (Twitter)",
|
||||
"no_copy": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.facebook",
|
||||
"fieldname": "facebook",
|
||||
"fieldtype": "Data",
|
||||
"label": "Facebook",
|
||||
"no_copy": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-04-04 12:34:10.808282",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Deal",
|
||||
"naming_rule": "By \"Naming Series\" field",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales User",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"show_title_field_in_link": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "organization",
|
||||
"track_changes": 1,
|
||||
"track_seen": 1
|
||||
}
|
||||
498
crm/fcrm/doctype/crm_deal/crm_deal.py
Normal file
498
crm/fcrm/doctype/crm_deal/crm_deal.py
Normal file
@@ -0,0 +1,498 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.desk.form.assign_to import _add as assign
|
||||
from frappe.model.document import Document
|
||||
|
||||
from crm.api.exchange_rate import get_exchange_rate
|
||||
from crm.fcrm.doctype.crm_service_level_agreement.utils import get_sla
|
||||
from crm.fcrm.doctype.crm_status_change_log.crm_status_change_log import add_status_change_log
|
||||
from crm.fcrm.doctype.utils import add_or_remove_lost_reason_section_in_sidepanel
|
||||
|
||||
|
||||
class CRMDeal(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
from crm.fcrm.doctype.crm_contacts.crm_contacts import CRMContacts
|
||||
from crm.fcrm.doctype.crm_products.crm_products import CRMProducts
|
||||
from crm.fcrm.doctype.crm_rolling_response_time.crm_rolling_response_time import (
|
||||
CRMRollingResponseTime,
|
||||
)
|
||||
from crm.fcrm.doctype.crm_status_change_log.crm_status_change_log import CRMStatusChangeLog
|
||||
|
||||
annual_revenue: DF.Currency
|
||||
closed_date: DF.Date | None
|
||||
communication_status: DF.Link | None
|
||||
contact: DF.Link | None
|
||||
contacts: DF.Table[CRMContacts]
|
||||
currency: DF.Link | None
|
||||
deal_owner: DF.Link | None
|
||||
deal_value: DF.Currency
|
||||
email: DF.Data | None
|
||||
exchange_rate: DF.Float
|
||||
expected_closure_date: DF.Date | None
|
||||
expected_deal_value: DF.Currency
|
||||
first_name: DF.Data | None
|
||||
first_responded_on: DF.Datetime | None
|
||||
first_response_time: DF.Duration | None
|
||||
gender: DF.Link | None
|
||||
industry: DF.Link | None
|
||||
job_title: DF.Data | None
|
||||
last_name: DF.Data | None
|
||||
last_responded_on: DF.Datetime | None
|
||||
last_response_time: DF.Duration | None
|
||||
lead: DF.Link | None
|
||||
lead_name: DF.Data | None
|
||||
lost_notes: DF.Text | None
|
||||
lost_reason: DF.Link | None
|
||||
mobile_no: DF.Data | None
|
||||
naming_series: DF.Literal["CRM-DEAL-.YYYY.-"]
|
||||
net_total: DF.Currency
|
||||
next_step: DF.Data | None
|
||||
no_of_employees: DF.Literal["1-10", "11-50", "51-200", "201-500", "501-1000", "1000+"]
|
||||
organization: DF.Link | None
|
||||
organization_name: DF.Data | None
|
||||
phone: DF.Data | None
|
||||
probability: DF.Percent
|
||||
products: DF.Table[CRMProducts]
|
||||
response_by: DF.Datetime | None
|
||||
rolling_responses: DF.Table[CRMRollingResponseTime]
|
||||
salutation: DF.Link | None
|
||||
sla: DF.Link | None
|
||||
sla_creation: DF.Datetime | None
|
||||
sla_status: DF.Literal["", "First Response Due", "Rolling Response Due", "Failed", "Fulfilled"]
|
||||
source: DF.Link | None
|
||||
status: DF.Link
|
||||
status_change_log: DF.Table[CRMStatusChangeLog]
|
||||
territory: DF.Link | None
|
||||
total: DF.Currency
|
||||
website: DF.Data | None
|
||||
# end: auto-generated types
|
||||
|
||||
def before_insert(self):
|
||||
# apply CRM enrichment (source/organization/contact) when created via a web form
|
||||
from crm.api.form import enrich_form_submission
|
||||
|
||||
enrich_form_submission(self)
|
||||
|
||||
def before_validate(self):
|
||||
self.set_sla()
|
||||
|
||||
def validate(self):
|
||||
self.validate_status()
|
||||
self.set_primary_contact()
|
||||
self.set_primary_email_mobile_no()
|
||||
if not self.is_new() and self.has_value_changed("deal_owner") and self.deal_owner:
|
||||
self.share_with_agent(self.deal_owner)
|
||||
self.assign_agent(self.deal_owner)
|
||||
if self.has_value_changed("status"):
|
||||
add_status_change_log(self)
|
||||
if frappe.db.get_value("CRM Deal Status", self.status, "type") == "Won":
|
||||
self.closed_date = frappe.utils.nowdate()
|
||||
self.validate_forecasting_fields()
|
||||
self.validate_lost_reason()
|
||||
self.update_exchange_rate()
|
||||
if self.organization and (self.is_new() or self.has_value_changed("organization")):
|
||||
self.copy_enrichment_from_organization()
|
||||
|
||||
def after_insert(self):
|
||||
if self.deal_owner:
|
||||
if self.deal_owner != frappe.session.user:
|
||||
self.share_with_agent(self.deal_owner)
|
||||
self.assign_agent(self.deal_owner)
|
||||
|
||||
# Auto-enrich a new Deal from its website (best-effort, background job).
|
||||
from crm.domain_enrichment.tasks import auto_enrich_on_create
|
||||
|
||||
auto_enrich_on_create(self)
|
||||
|
||||
def before_save(self):
|
||||
self.apply_sla()
|
||||
|
||||
def validate_status(self):
|
||||
if self.is_new() and not self.status:
|
||||
if frappe.db.exists("CRM Deal Status", "Qualification"):
|
||||
self.status = "Qualification"
|
||||
else:
|
||||
self.status = frappe.get_all("CRM Deal Status", {"type": "Open"}, pluck="name")[0]
|
||||
|
||||
def set_primary_contact(self, contact=None):
|
||||
if not self.contacts:
|
||||
return
|
||||
|
||||
if not contact and len(self.contacts) == 1:
|
||||
self.contacts[0].is_primary = 1
|
||||
elif contact:
|
||||
for d in self.contacts:
|
||||
if d.contact == contact:
|
||||
d.is_primary = 1
|
||||
else:
|
||||
d.is_primary = 0
|
||||
|
||||
def set_primary_email_mobile_no(self):
|
||||
if not self.contacts:
|
||||
self.email = ""
|
||||
self.mobile_no = ""
|
||||
self.phone = ""
|
||||
return
|
||||
|
||||
if len([contact for contact in self.contacts if contact.is_primary]) > 1:
|
||||
frappe.throw(_("Only one {0} can be set as primary.").format(frappe.bold("Contact")))
|
||||
|
||||
primary_contact_exists = False
|
||||
for d in self.contacts:
|
||||
if d.is_primary == 1:
|
||||
primary_contact_exists = True
|
||||
self.email = d.email.strip() if d.email else ""
|
||||
self.mobile_no = d.mobile_no.strip() if d.mobile_no else ""
|
||||
self.phone = d.phone.strip() if d.phone else ""
|
||||
break
|
||||
|
||||
if not primary_contact_exists:
|
||||
self.email = ""
|
||||
self.mobile_no = ""
|
||||
self.phone = ""
|
||||
|
||||
def assign_agent(self, agent):
|
||||
if not agent:
|
||||
return
|
||||
|
||||
assignees = self.get_assigned_users()
|
||||
if assignees:
|
||||
for assignee in assignees:
|
||||
if agent == assignee:
|
||||
# the agent is already set as an assignee
|
||||
return
|
||||
|
||||
assign({"assign_to": [agent], "doctype": "CRM Deal", "name": self.name}, ignore_permissions=True)
|
||||
|
||||
def share_with_agent(self, agent):
|
||||
if not agent:
|
||||
return
|
||||
|
||||
docshares = frappe.get_all(
|
||||
"DocShare",
|
||||
filters={"share_name": self.name, "share_doctype": self.doctype},
|
||||
fields=["name", "user"],
|
||||
)
|
||||
|
||||
shared_with = [d.user for d in docshares] + [agent]
|
||||
|
||||
for user in shared_with:
|
||||
if user == agent and not frappe.db.exists(
|
||||
"DocShare",
|
||||
{"user": agent, "share_name": self.name, "share_doctype": self.doctype},
|
||||
):
|
||||
frappe.share.add_docshare(
|
||||
self.doctype,
|
||||
self.name,
|
||||
agent,
|
||||
write=1,
|
||||
flags={"ignore_share_permission": True},
|
||||
)
|
||||
elif user != agent:
|
||||
frappe.share.remove(
|
||||
self.doctype,
|
||||
self.name,
|
||||
user,
|
||||
flags={"ignore_share_permission": True, "ignore_permissions": True},
|
||||
)
|
||||
|
||||
def copy_enrichment_from_organization(self):
|
||||
"""Fill-empty copy of the linked enriched Organization's fields onto this Deal.
|
||||
|
||||
Called from ``validate`` when ``organization`` is set or changed to an
|
||||
already-enriched CRM Organization. The shared helper mutates ``self`` in place
|
||||
(fill-empty, so user-entered data is preserved); the in-progress save persists
|
||||
the changes. Best-effort -- never blocks the Deal save.
|
||||
"""
|
||||
from crm.domain_enrichment.cross_record import copy_enrichment_from_organization
|
||||
|
||||
return copy_enrichment_from_organization(self)
|
||||
|
||||
def set_sla(self):
|
||||
"""
|
||||
Find an SLA to apply to the deal.
|
||||
"""
|
||||
if self.sla:
|
||||
return
|
||||
|
||||
sla = get_sla(self)
|
||||
if not sla:
|
||||
self.first_responded_on = None
|
||||
self.first_response_time = None
|
||||
return
|
||||
self.sla = sla.name
|
||||
|
||||
def apply_sla(self):
|
||||
"""
|
||||
Apply SLA if set.
|
||||
"""
|
||||
if not self.sla:
|
||||
return
|
||||
sla = frappe.get_last_doc("CRM Service Level Agreement", {"name": self.sla})
|
||||
if sla:
|
||||
sla.apply(self)
|
||||
|
||||
def update_closed_date(self):
|
||||
"""
|
||||
Update the closed date based on the "Won" status.
|
||||
"""
|
||||
if self.status == "Won" and not self.closed_date:
|
||||
self.closed_date = frappe.utils.nowdate()
|
||||
|
||||
def update_default_probability(self):
|
||||
"""
|
||||
Update the default probability based on the status.
|
||||
"""
|
||||
if not self.probability or self.probability == 0:
|
||||
self.probability = frappe.db.get_value("CRM Deal Status", self.status, "probability") or 0
|
||||
|
||||
def update_expected_deal_value(self):
|
||||
"""
|
||||
Update the expected deal value based on the net total or total.
|
||||
"""
|
||||
if (
|
||||
frappe.db.get_single_value("FCRM Settings", "auto_update_expected_deal_value")
|
||||
and (self.net_total or self.total)
|
||||
and self.expected_deal_value
|
||||
):
|
||||
self.expected_deal_value = self.net_total or self.total
|
||||
|
||||
def validate_forecasting_fields(self):
|
||||
self.update_closed_date()
|
||||
self.update_default_probability()
|
||||
self.update_expected_deal_value()
|
||||
if frappe.db.get_single_value("FCRM Settings", "enable_forecasting"):
|
||||
if not self.expected_deal_value or self.expected_deal_value == 0:
|
||||
frappe.throw(_("Expected deal value is required."), frappe.MandatoryError)
|
||||
if not self.expected_closure_date:
|
||||
frappe.throw(_("Expected closure date is required."), frappe.MandatoryError)
|
||||
|
||||
def validate_lost_reason(self):
|
||||
"""
|
||||
Validate the lost reason if the status is set to "Lost".
|
||||
"""
|
||||
if self.status and frappe.get_cached_value("CRM Deal Status", self.status, "type") == "Lost":
|
||||
if not self.lost_reason:
|
||||
frappe.throw(_("Please specify a reason for losing the deal."), frappe.ValidationError)
|
||||
elif self.lost_reason == "Other" and not self.lost_notes:
|
||||
frappe.throw(_("Please specify the reason for losing the deal."), frappe.ValidationError)
|
||||
if self.has_value_changed("status"):
|
||||
add_or_remove_lost_reason_section_in_sidepanel(self)
|
||||
|
||||
def update_exchange_rate(self):
|
||||
if self.has_value_changed("currency") or not self.exchange_rate:
|
||||
system_currency = frappe.db.get_single_value("FCRM Settings", "currency") or "USD"
|
||||
exchange_rate = 1
|
||||
if self.currency and self.currency != system_currency:
|
||||
exchange_rate = get_exchange_rate(self.currency, system_currency)
|
||||
|
||||
self.db_set("exchange_rate", exchange_rate)
|
||||
|
||||
@staticmethod
|
||||
def default_list_data():
|
||||
columns = [
|
||||
{
|
||||
"label": "Organization",
|
||||
"type": "Link",
|
||||
"key": "organization",
|
||||
"options": "CRM Organization",
|
||||
"width": "11rem",
|
||||
},
|
||||
{
|
||||
"label": "Annual Revenue",
|
||||
"type": "Currency",
|
||||
"key": "annual_revenue",
|
||||
"align": "right",
|
||||
"width": "9rem",
|
||||
},
|
||||
{
|
||||
"label": "Status",
|
||||
"type": "Link",
|
||||
"options": "CRM Deal Status",
|
||||
"key": "status",
|
||||
"width": "10rem",
|
||||
},
|
||||
{
|
||||
"label": "Email",
|
||||
"type": "Data",
|
||||
"key": "email",
|
||||
"width": "12rem",
|
||||
},
|
||||
{
|
||||
"label": "Mobile No.",
|
||||
"type": "Data",
|
||||
"key": "mobile_no",
|
||||
"width": "11rem",
|
||||
},
|
||||
{
|
||||
"label": "Assigned To",
|
||||
"type": "Text",
|
||||
"key": "_assign",
|
||||
"width": "10rem",
|
||||
},
|
||||
{
|
||||
"label": "Last Modified",
|
||||
"type": "Datetime",
|
||||
"key": "modified",
|
||||
"width": "8rem",
|
||||
},
|
||||
]
|
||||
rows = [
|
||||
"name",
|
||||
"organization",
|
||||
"annual_revenue",
|
||||
"status",
|
||||
"email",
|
||||
"currency",
|
||||
"mobile_no",
|
||||
"deal_owner",
|
||||
"sla_status",
|
||||
"response_by",
|
||||
"first_response_time",
|
||||
"first_responded_on",
|
||||
"modified",
|
||||
"_assign",
|
||||
]
|
||||
return {"columns": columns, "rows": rows}
|
||||
|
||||
@staticmethod
|
||||
def default_kanban_settings():
|
||||
return {
|
||||
"column_field": "status",
|
||||
"title_field": "organization",
|
||||
"kanban_fields": '["annual_revenue", "email", "mobile_no", "_assign", "modified"]',
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def add_contact(deal: str, contact: str):
|
||||
if not frappe.has_permission("CRM Deal", "write", deal):
|
||||
frappe.throw(_("Not allowed to add contact to Deal"), frappe.PermissionError)
|
||||
|
||||
deal = frappe.get_cached_doc("CRM Deal", deal)
|
||||
deal.append("contacts", {"contact": contact})
|
||||
deal.save()
|
||||
return True
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def remove_contact(deal: str, contact: str):
|
||||
if not frappe.has_permission("CRM Deal", "write", deal):
|
||||
frappe.throw(_("Not allowed to remove contact from Deal"), frappe.PermissionError)
|
||||
|
||||
deal = frappe.get_cached_doc("CRM Deal", deal)
|
||||
deal.contacts = [d for d in deal.contacts if d.contact != contact]
|
||||
deal.save()
|
||||
return True
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def set_primary_contact(deal: str, contact: str):
|
||||
if not frappe.has_permission("CRM Deal", "write", deal):
|
||||
frappe.throw(_("Not allowed to set primary contact for Deal"), frappe.PermissionError)
|
||||
|
||||
deal = frappe.get_cached_doc("CRM Deal", deal)
|
||||
deal.set_primary_contact(contact)
|
||||
deal.save()
|
||||
return True
|
||||
|
||||
|
||||
def create_organization(doc):
|
||||
if not doc.get("organization_name"):
|
||||
return
|
||||
|
||||
existing_organization = frappe.db.exists(
|
||||
"CRM Organization", {"organization_name": doc.get("organization_name")}
|
||||
)
|
||||
if existing_organization:
|
||||
return existing_organization
|
||||
|
||||
organization = frappe.new_doc("CRM Organization")
|
||||
organization.update(
|
||||
{
|
||||
"organization_name": doc.get("organization_name"),
|
||||
"website": doc.get("website"),
|
||||
"territory": doc.get("territory"),
|
||||
"industry": doc.get("industry"),
|
||||
"annual_revenue": doc.get("annual_revenue"),
|
||||
"no_of_employees": doc.get("no_of_employees"),
|
||||
}
|
||||
)
|
||||
organization.insert(ignore_permissions=True)
|
||||
return organization.name
|
||||
|
||||
|
||||
def contact_exists(doc):
|
||||
email_exist = frappe.db.exists("Contact Email", {"email_id": doc.get("email")})
|
||||
mobile_exist = frappe.db.exists("Contact Phone", {"phone": doc.get("mobile_no")})
|
||||
|
||||
doctype = "Contact Email" if email_exist else "Contact Phone"
|
||||
name = email_exist or mobile_exist
|
||||
|
||||
if name:
|
||||
return frappe.db.get_value(doctype, name, "parent")
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def create_contact(doc):
|
||||
existing_contact = contact_exists(doc)
|
||||
if existing_contact:
|
||||
return existing_contact
|
||||
|
||||
contact = frappe.new_doc("Contact")
|
||||
contact.update(
|
||||
{
|
||||
"first_name": doc.get("first_name"),
|
||||
"last_name": doc.get("last_name"),
|
||||
"salutation": doc.get("salutation"),
|
||||
"company_name": doc.get("organization") or doc.get("organization_name"),
|
||||
"gender": doc.get("gender"),
|
||||
}
|
||||
)
|
||||
|
||||
if doc.get("email"):
|
||||
contact.append("email_ids", {"email_id": doc.get("email"), "is_primary": 1})
|
||||
|
||||
if doc.get("mobile_no"):
|
||||
contact.append("phone_nos", {"phone": doc.get("mobile_no"), "is_primary_mobile_no": 1})
|
||||
|
||||
contact.insert(ignore_permissions=True)
|
||||
contact.reload() # load changes by hooks on contact
|
||||
|
||||
return contact.name
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_deal(doc: dict):
|
||||
deal = frappe.new_doc("CRM Deal")
|
||||
|
||||
contact = doc.get("contact")
|
||||
if not contact and (
|
||||
doc.get("first_name") or doc.get("last_name") or doc.get("email") or doc.get("mobile_no")
|
||||
):
|
||||
contact = create_contact(doc)
|
||||
|
||||
deal.update(
|
||||
{
|
||||
"organization": doc.get("organization") or create_organization(doc),
|
||||
"contacts": [{"contact": contact, "is_primary": 1}] if contact else [],
|
||||
}
|
||||
)
|
||||
|
||||
doc.pop("organization", None)
|
||||
|
||||
deal.update(doc)
|
||||
|
||||
deal.insert(ignore_permissions=True)
|
||||
return deal.name
|
||||
571
crm/fcrm/doctype/crm_deal/test_crm_deal.py
Normal file
571
crm/fcrm/doctype/crm_deal/test_crm_deal.py
Normal file
@@ -0,0 +1,571 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.desk.form.assign_to import add as assign_add
|
||||
from frappe.desk.form.assign_to import remove as assign_remove
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
from crm.fcrm.doctype.crm_deal.api import get_deal_contacts
|
||||
from crm.fcrm.doctype.crm_deal.crm_deal import (
|
||||
add_contact,
|
||||
create_deal,
|
||||
remove_contact,
|
||||
set_primary_contact,
|
||||
)
|
||||
|
||||
|
||||
class TestCRMDeal(IntegrationTestCase):
|
||||
def tearDown(self) -> None:
|
||||
frappe.set_user("Administrator")
|
||||
frappe.db.rollback()
|
||||
|
||||
def test_deal_creation_with_organization(self):
|
||||
"""Test creating a deal with organization"""
|
||||
deal = create_test_deal(
|
||||
organization="Test Org Inc",
|
||||
annual_revenue=1000000,
|
||||
status="Qualification",
|
||||
)
|
||||
|
||||
self.assertTrue(deal.name)
|
||||
self.assertTrue(deal.organization)
|
||||
self.assertEqual(deal.annual_revenue, 1000000)
|
||||
|
||||
def test_set_primary_contact(self):
|
||||
"""Test setting primary contact from contacts table"""
|
||||
# Create contacts
|
||||
contact1 = create_test_contact(first_name="John", last_name="Doe", email="john@example.com")
|
||||
contact2 = create_test_contact(
|
||||
first_name="Jane", last_name="Smith", email="jane@example.com", mobile_no="+1234567890"
|
||||
)
|
||||
|
||||
# Create deal with two contacts
|
||||
deal = create_test_deal(organization="Contact Test Org")
|
||||
deal.append("contacts", {"contact": contact1.name})
|
||||
deal.append("contacts", {"contact": contact2.name, "is_primary": 1})
|
||||
deal.save()
|
||||
|
||||
# Verify primary contact is set
|
||||
deal.reload()
|
||||
primary_contacts = [c for c in deal.contacts if c.is_primary == 1]
|
||||
self.assertEqual(len(primary_contacts), 1)
|
||||
self.assertEqual(primary_contacts[0].contact, contact2.name)
|
||||
|
||||
def test_set_primary_email_mobile_no(self):
|
||||
"""Test that email and mobile are set from primary contact"""
|
||||
# Create contact
|
||||
contact = create_test_contact(
|
||||
first_name="Test",
|
||||
last_name="User",
|
||||
email="testuser@example.com",
|
||||
mobile_no="+9876543210",
|
||||
phone="+1111111111",
|
||||
)
|
||||
|
||||
# Create deal with contact
|
||||
deal = create_test_deal(organization="Email Test Org")
|
||||
deal.append("contacts", {"contact": contact.name, "is_primary": 1})
|
||||
deal.save()
|
||||
|
||||
# Verify email and mobile are set from contact
|
||||
deal.reload()
|
||||
self.assertEqual(deal.email, "testuser@example.com")
|
||||
self.assertEqual(deal.mobile_no, "+9876543210")
|
||||
self.assertEqual(deal.phone, "+1111111111")
|
||||
|
||||
def test_multiple_primary_contacts_error(self):
|
||||
"""Test that having multiple primary contacts throws error"""
|
||||
contact1 = create_test_contact(first_name="Primary1", email="p1@example.com")
|
||||
contact2 = create_test_contact(first_name="Primary2", email="p2@example.com")
|
||||
|
||||
deal = create_test_deal(organization="Multiple Primary Test")
|
||||
deal.append("contacts", {"contact": contact1.name, "is_primary": 1})
|
||||
deal.append("contacts", {"contact": contact2.name, "is_primary": 1})
|
||||
|
||||
with self.assertRaises(frappe.exceptions.ValidationError) as context:
|
||||
deal.save()
|
||||
self.assertIn("Only one", str(context.exception))
|
||||
|
||||
def test_no_primary_contact_clears_email(self):
|
||||
"""Test that email/mobile on deal (not child table) are cleared when no primary contact"""
|
||||
contact1 = create_test_contact(
|
||||
first_name="Primary", email="primary@example.com", mobile_no="+1111111111"
|
||||
)
|
||||
contact2 = create_test_contact(first_name="NonPrimary", email="nonprimary@example.com")
|
||||
|
||||
# Create deal with primary contact - email should be set
|
||||
deal = create_test_deal(
|
||||
organization="No Primary Org",
|
||||
expected_deal_value=1000,
|
||||
expected_closure_date="2026-12-31",
|
||||
)
|
||||
deal.append("contacts", {"contact": contact1.name, "is_primary": 1})
|
||||
deal.save()
|
||||
deal.reload()
|
||||
|
||||
self.assertEqual(deal.email, "primary@example.com")
|
||||
self.assertEqual(deal.mobile_no, "+1111111111")
|
||||
|
||||
# Change to non-primary contact - deal email should be cleared
|
||||
for c in deal.contacts:
|
||||
c.is_primary = 0
|
||||
deal.append("contacts", {"contact": contact2.name, "is_primary": 0})
|
||||
deal.save()
|
||||
deal.reload()
|
||||
|
||||
# Deal-level fields should be cleared since no primary contact
|
||||
self.assertEqual(deal.email, "")
|
||||
self.assertEqual(deal.mobile_no, "")
|
||||
self.assertEqual(deal.phone, "")
|
||||
|
||||
def test_deal_owner_assignment(self):
|
||||
"""Test that deal owner is assigned on creation"""
|
||||
deal = create_test_deal(organization="Owner Test Org", deal_owner="Administrator")
|
||||
|
||||
# Verify deal owner is assigned
|
||||
assignees = deal.get_assigned_users()
|
||||
self.assertIn("Administrator", assignees)
|
||||
|
||||
def test_update_deal_owner(self):
|
||||
"""Test updating deal owner assigns and shares with new owner"""
|
||||
# Create deal without owner
|
||||
deal = create_test_deal(organization="Update Owner Org")
|
||||
self.assertFalse(deal.deal_owner)
|
||||
|
||||
# Update deal owner
|
||||
deal.deal_owner = "Administrator"
|
||||
deal.save()
|
||||
|
||||
# Verify assignment and share
|
||||
deal.reload()
|
||||
self.assertEqual(deal.deal_owner, "Administrator")
|
||||
assignees = deal.get_assigned_users()
|
||||
self.assertIn("Administrator", assignees)
|
||||
|
||||
docshare = frappe.db.exists(
|
||||
"DocShare",
|
||||
{"user": "Administrator", "share_name": deal.name, "share_doctype": "CRM Deal"},
|
||||
)
|
||||
self.assertTrue(docshare)
|
||||
|
||||
# Try to assign same agent again - should not duplicate
|
||||
initial_count = len(assignees)
|
||||
deal.assign_agent("Administrator")
|
||||
assignees_after = deal.get_assigned_users()
|
||||
self.assertEqual(len(assignees_after), initial_count)
|
||||
|
||||
def test_owner_cleared_on_unassign(self):
|
||||
"""Unassigning the current owner clears deal_owner"""
|
||||
deal = create_test_deal(organization="Owner Clear Org", deal_owner="crm.user1@example.com")
|
||||
self.assertEqual(deal.deal_owner, "crm.user1@example.com")
|
||||
|
||||
assign_remove("CRM Deal", deal.name, "crm.user1@example.com")
|
||||
|
||||
self.assertIsNone(frappe.db.get_value("CRM Deal", deal.name, "deal_owner"))
|
||||
|
||||
def test_reassignment_moves_owner(self):
|
||||
"""After the owner is unassigned, assigning a new user makes them the owner"""
|
||||
deal = create_test_deal(organization="Reassign Org", deal_owner="crm.user1@example.com")
|
||||
|
||||
# Frappe assignment rules unassign before assign in one cycle; mirror that order
|
||||
assign_remove("CRM Deal", deal.name, "crm.user1@example.com")
|
||||
self.assertIsNone(frappe.db.get_value("CRM Deal", deal.name, "deal_owner"))
|
||||
|
||||
assign_add({"assign_to": ["crm.user2@example.com"], "doctype": "CRM Deal", "name": deal.name})
|
||||
|
||||
self.assertEqual(frappe.db.get_value("CRM Deal", deal.name, "deal_owner"), "crm.user2@example.com")
|
||||
|
||||
def test_assignment_overrides_owner(self):
|
||||
"""A new assignment takes ownership even when an owner already exists (newest owns)."""
|
||||
deal = create_test_deal(organization="Override Org", deal_owner="crm.user1@example.com")
|
||||
assign_add({"assign_to": ["crm.user2@example.com"], "doctype": "CRM Deal", "name": deal.name})
|
||||
self.assertEqual(frappe.db.get_value("CRM Deal", deal.name, "deal_owner"), "crm.user2@example.com")
|
||||
|
||||
def test_removing_any_assignee_clears_owner(self):
|
||||
"""Accepted simplification: cancelling ANY assignment clears the owner,
|
||||
even when other assignees remain (owner is single-valued, _assign is a list)."""
|
||||
deal = create_test_deal(organization="Wrinkle Org", deal_owner="crm.user1@example.com")
|
||||
assign_add({"assign_to": ["crm.user2@example.com"], "doctype": "CRM Deal", "name": deal.name})
|
||||
# newest assignment owns
|
||||
self.assertEqual(frappe.db.get_value("CRM Deal", deal.name, "deal_owner"), "crm.user2@example.com")
|
||||
|
||||
assign_remove("CRM Deal", deal.name, "crm.user1@example.com") # remove a non-owner assignee
|
||||
self.assertIsNone(frappe.db.get_value("CRM Deal", deal.name, "deal_owner"))
|
||||
|
||||
def test_todo_assignment_requires_deal_access(self):
|
||||
"""A direct ToDo against a deal is rejected when the creator cannot access it"""
|
||||
deal = create_test_deal(organization="No Access Assign Org", deal_owner="crm.user1@example.com")
|
||||
|
||||
frappe.set_user(create_test_user_without_deal_access())
|
||||
with self.assertRaises(frappe.PermissionError):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "ToDo",
|
||||
"description": "Take over",
|
||||
"reference_type": "CRM Deal",
|
||||
"reference_name": deal.name,
|
||||
"allocated_to": frappe.session.user,
|
||||
}
|
||||
).insert()
|
||||
|
||||
self.assertEqual(frappe.db.get_value("CRM Deal", deal.name, "deal_owner"), "crm.user1@example.com")
|
||||
|
||||
def test_owner_can_assign_via_direct_todo(self):
|
||||
"""A user who can access the deal may assign it with a direct ToDo, which moves the owner"""
|
||||
deal = create_test_deal(organization="Direct Assign Org", deal_owner="crm.user1@example.com")
|
||||
|
||||
frappe.set_user("crm.user1@example.com")
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "ToDo",
|
||||
"description": "Hand over",
|
||||
"reference_type": "CRM Deal",
|
||||
"reference_name": deal.name,
|
||||
"allocated_to": "crm.user2@example.com",
|
||||
}
|
||||
).insert()
|
||||
|
||||
self.assertEqual(frappe.db.get_value("CRM Deal", deal.name, "deal_owner"), "crm.user2@example.com")
|
||||
|
||||
def test_task_unassign_does_not_touch_owner(self):
|
||||
"""Cancelling a CRM Task assignment is a no-op for owner fields"""
|
||||
deal = create_test_deal(organization="Task Org")
|
||||
task = frappe.get_doc(
|
||||
{
|
||||
"doctype": "CRM Task",
|
||||
"title": "Owner sync task",
|
||||
"reference_doctype": "CRM Deal",
|
||||
"reference_docname": deal.name,
|
||||
}
|
||||
).insert()
|
||||
|
||||
assign_add({"assign_to": ["crm.user1@example.com"], "doctype": "CRM Task", "name": task.name})
|
||||
# Should not raise
|
||||
assign_remove("CRM Task", task.name, "crm.user1@example.com")
|
||||
|
||||
def test_add_contact_api(self):
|
||||
"""Test add_contact API function"""
|
||||
deal = create_test_deal(organization="Add Contact Org")
|
||||
contact = create_test_contact(first_name="API", last_name="User", email="api@example.com")
|
||||
|
||||
# Add contact using API
|
||||
result = add_contact(deal.name, contact.name)
|
||||
self.assertTrue(result)
|
||||
|
||||
# Verify contact was added
|
||||
deal.reload()
|
||||
contact_names = [c.contact for c in deal.contacts]
|
||||
self.assertIn(contact.name, contact_names)
|
||||
|
||||
def test_remove_contact_api(self):
|
||||
"""Test remove_contact API function"""
|
||||
contact = create_test_contact(first_name="Remove", email="remove@example.com")
|
||||
deal = create_test_deal(organization="Remove Contact Org")
|
||||
deal.append("contacts", {"contact": contact.name})
|
||||
deal.save()
|
||||
|
||||
# Verify contact exists
|
||||
deal.reload()
|
||||
self.assertEqual(len(deal.contacts), 1)
|
||||
|
||||
# Remove contact using API
|
||||
result = remove_contact(deal.name, contact.name)
|
||||
self.assertTrue(result)
|
||||
|
||||
# Verify contact was removed
|
||||
deal.reload()
|
||||
self.assertEqual(len(deal.contacts), 0)
|
||||
|
||||
def test_set_primary_contact_api(self):
|
||||
"""Test set_primary_contact API function"""
|
||||
contact1 = create_test_contact(first_name="First", email="first@example.com")
|
||||
contact2 = create_test_contact(first_name="Second", email="second@example.com")
|
||||
|
||||
deal = create_test_deal(organization="Primary API Org")
|
||||
deal.append("contacts", {"contact": contact1.name, "is_primary": 1})
|
||||
deal.append("contacts", {"contact": contact2.name})
|
||||
deal.save()
|
||||
|
||||
# Change primary contact using API
|
||||
result = set_primary_contact(deal.name, contact2.name)
|
||||
self.assertTrue(result)
|
||||
|
||||
# Verify primary contact was changed
|
||||
deal.reload()
|
||||
for c in deal.contacts:
|
||||
if c.contact == contact2.name:
|
||||
self.assertEqual(c.is_primary, 1)
|
||||
else:
|
||||
self.assertEqual(c.is_primary, 0)
|
||||
|
||||
def test_get_deal_contacts_orders_primary_first(self):
|
||||
"""Test that get_deal_contacts pins the primary contact to the top
|
||||
regardless of the order contacts were added in"""
|
||||
contact1 = create_test_contact(first_name="Alpha", email="alpha@example.com")
|
||||
contact2 = create_test_contact(first_name="Beta", email="beta@example.com")
|
||||
contact3 = create_test_contact(first_name="Gamma", email="gamma@example.com")
|
||||
|
||||
deal = create_test_deal(organization="Contact Order Org")
|
||||
deal.append("contacts", {"contact": contact1.name})
|
||||
deal.append("contacts", {"contact": contact2.name})
|
||||
deal.append("contacts", {"contact": contact3.name, "is_primary": 1})
|
||||
deal.save()
|
||||
|
||||
contacts = get_deal_contacts(deal.name)
|
||||
|
||||
self.assertEqual(contacts[0]["name"], contact3.name)
|
||||
self.assertEqual(contacts[0]["is_primary"], 1)
|
||||
self.assertEqual([c["name"] for c in contacts[1:]], [contact1.name, contact2.name])
|
||||
|
||||
def test_create_deal_api(self):
|
||||
"""Test create_deal API function"""
|
||||
deal_name = create_deal(
|
||||
{
|
||||
"organization_name": "API Deal Org",
|
||||
"annual_revenue": 500000,
|
||||
"first_name": "Deal",
|
||||
"last_name": "Creator",
|
||||
"email": "dealcreator@example.com",
|
||||
"mobile_no": "+5555555555",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(deal_name)
|
||||
|
||||
# Verify deal was created
|
||||
deal = frappe.get_doc("CRM Deal", deal_name)
|
||||
self.assertEqual(deal.annual_revenue, 500000)
|
||||
self.assertTrue(deal.organization)
|
||||
self.assertTrue(len(deal.contacts) > 0)
|
||||
|
||||
# Verify organization was created
|
||||
org = frappe.get_doc("CRM Organization", deal.organization)
|
||||
self.assertEqual(org.organization_name, "API Deal Org")
|
||||
|
||||
# Verify contact was created
|
||||
contact = frappe.get_doc("Contact", deal.contacts[0].contact)
|
||||
self.assertEqual(contact.first_name, "Deal")
|
||||
self.assertEqual(contact.email_id, "dealcreator@example.com")
|
||||
|
||||
def test_create_deal_with_existing_organization(self):
|
||||
"""Test create_deal with existing organization"""
|
||||
# Create organization first
|
||||
org = frappe.get_doc(
|
||||
{
|
||||
"doctype": "CRM Organization",
|
||||
"organization_name": "Existing Org",
|
||||
"annual_revenue": 2000000,
|
||||
}
|
||||
).insert()
|
||||
|
||||
# Create deal with same organization name
|
||||
deal_name = create_deal(
|
||||
{
|
||||
"organization_name": "Existing Org",
|
||||
"first_name": "Existing",
|
||||
"email": "existing@example.com",
|
||||
}
|
||||
)
|
||||
|
||||
deal = frappe.get_doc("CRM Deal", deal_name)
|
||||
self.assertEqual(deal.organization, org.name)
|
||||
|
||||
def test_create_deal_api_propagates_no_of_employees(self):
|
||||
"""Test that no_of_employees is copied onto the organization create_deal creates"""
|
||||
deal_name = create_deal(
|
||||
{
|
||||
"organization_name": "Employees Test Org",
|
||||
"no_of_employees": "51-200",
|
||||
"first_name": "Employees",
|
||||
"email": "employeestest@example.com",
|
||||
}
|
||||
)
|
||||
|
||||
deal = frappe.get_doc("CRM Deal", deal_name)
|
||||
org = frappe.get_doc("CRM Organization", deal.organization)
|
||||
self.assertEqual(org.no_of_employees, "51-200")
|
||||
|
||||
def test_create_deal_with_existing_contact(self):
|
||||
"""Test create_deal with existing contact"""
|
||||
# Create contact first
|
||||
contact = create_test_contact(
|
||||
first_name="Existing", last_name="Contact", email="existingc@example.com"
|
||||
)
|
||||
|
||||
# Create deal with same email
|
||||
deal_name = create_deal(
|
||||
{
|
||||
"organization_name": "Contact Existing Org",
|
||||
"first_name": "Existing",
|
||||
"email": "existingc@example.com",
|
||||
}
|
||||
)
|
||||
|
||||
deal = frappe.get_doc("CRM Deal", deal_name)
|
||||
self.assertEqual(deal.contacts[0].contact, contact.name)
|
||||
|
||||
def test_validate_lost_reason_required(self):
|
||||
"""Test that lost reason is required when status is Lost"""
|
||||
# Create Lost status if not exists
|
||||
if not frappe.db.exists("CRM Deal Status", "Lost"):
|
||||
frappe.get_doc({"doctype": "CRM Deal Status", "name": "Lost", "type": "Lost"}).insert()
|
||||
|
||||
deal = create_test_deal(organization="Lost Deal Org")
|
||||
|
||||
# Try to set status to Lost without lost_reason
|
||||
deal.status = "Lost"
|
||||
with self.assertRaises(frappe.exceptions.ValidationError) as context:
|
||||
deal.save()
|
||||
self.assertIn("reason for losing", str(context.exception))
|
||||
|
||||
def test_validate_lost_reason_other(self):
|
||||
"""Test that lost_notes is required when lost_reason is Other"""
|
||||
if not frappe.db.exists("CRM Deal Status", "Lost"):
|
||||
frappe.get_doc({"doctype": "CRM Deal Status", "name": "Lost", "type": "Lost"}).insert()
|
||||
|
||||
if not frappe.db.exists("CRM Lost Reason", "Other"):
|
||||
frappe.get_doc({"doctype": "CRM Lost Reason", "reason": "Other"}).insert()
|
||||
|
||||
deal = create_test_deal(organization="Lost Notes Org")
|
||||
deal.status = "Lost"
|
||||
deal.lost_reason = "Other"
|
||||
|
||||
with self.assertRaises(frappe.exceptions.ValidationError) as context:
|
||||
deal.save()
|
||||
self.assertIn("specify the reason", str(context.exception))
|
||||
|
||||
def test_closed_date_set_on_won(self):
|
||||
"""Test that closed_date is set when status is Won"""
|
||||
if not frappe.db.exists("CRM Deal Status", "Won"):
|
||||
frappe.get_doc({"doctype": "CRM Deal Status", "name": "Won", "type": "Won"}).insert()
|
||||
|
||||
deal = create_test_deal(
|
||||
organization="Won Deal Org", expected_deal_value=10000, expected_closure_date="2026-12-31"
|
||||
)
|
||||
self.assertFalse(deal.closed_date)
|
||||
|
||||
deal.status = "Won"
|
||||
deal.save()
|
||||
|
||||
deal.reload()
|
||||
self.assertTrue(deal.closed_date)
|
||||
|
||||
def test_forecasting_fields_validation(self):
|
||||
"""Test forecasting fields validation when enabled"""
|
||||
# Enable forecasting
|
||||
settings = frappe.get_single("FCRM Settings")
|
||||
original_value = settings.enable_forecasting
|
||||
settings.enable_forecasting = 1
|
||||
settings.save()
|
||||
|
||||
try:
|
||||
# Should fail without expected_deal_value
|
||||
with self.assertRaises(frappe.exceptions.MandatoryError):
|
||||
create_test_deal(organization="Forecast Org")
|
||||
|
||||
# Should fail without expected_closure_date
|
||||
with self.assertRaises(frappe.exceptions.MandatoryError):
|
||||
create_test_deal(organization="Forecast Org 2", expected_deal_value=5000)
|
||||
|
||||
# Should succeed with both fields
|
||||
deal = create_test_deal(
|
||||
organization="Forecast Org 3",
|
||||
expected_deal_value=5000,
|
||||
expected_closure_date="2026-12-31",
|
||||
)
|
||||
self.assertTrue(deal.name)
|
||||
|
||||
finally:
|
||||
# Restore original setting
|
||||
settings.enable_forecasting = original_value
|
||||
settings.save()
|
||||
|
||||
def test_single_contact_auto_primary(self):
|
||||
"""Test that single contact is automatically set as primary"""
|
||||
contact = create_test_contact(first_name="Auto", email="auto@example.com")
|
||||
deal = create_test_deal(organization="Auto Primary Org")
|
||||
deal.append("contacts", {"contact": contact.name})
|
||||
deal.save()
|
||||
|
||||
deal.reload()
|
||||
self.assertEqual(deal.contacts[0].is_primary, 1)
|
||||
|
||||
def test_negative_currency_fields_rejected(self):
|
||||
"""Test that Currency fields reject negative values"""
|
||||
for fieldname in ("annual_revenue", "deal_value", "expected_deal_value", "total", "net_total"):
|
||||
with self.subTest(fieldname=fieldname), self.assertRaises(frappe.NonNegativeError):
|
||||
create_test_deal(organization=f"Negative {fieldname}", **{fieldname: -100})
|
||||
|
||||
|
||||
class TestGetDealContacts(IntegrationTestCase):
|
||||
def tearDown(self) -> None:
|
||||
frappe.set_user("Administrator")
|
||||
frappe.db.rollback()
|
||||
|
||||
def test_get_deal_contacts_requires_read_permission(self):
|
||||
"""A user without read access on the deal must not get its contact PII"""
|
||||
contact = create_test_contact(
|
||||
first_name="Alice",
|
||||
last_name="Confidential",
|
||||
email="alice@example.com",
|
||||
mobile_no="+919000000000",
|
||||
)
|
||||
deal = create_test_deal(organization="Perm Test Org")
|
||||
deal.append("contacts", {"contact": contact.name})
|
||||
deal.save()
|
||||
|
||||
self.assertEqual(get_deal_contacts(deal.name)[0]["name"], contact.name)
|
||||
|
||||
frappe.set_user(create_test_user_without_deal_access())
|
||||
with self.assertRaises(frappe.PermissionError):
|
||||
get_deal_contacts(deal.name)
|
||||
|
||||
|
||||
def create_test_user_without_deal_access():
|
||||
"""Create (or reuse) a user with no role granting access to CRM Deal"""
|
||||
email = "deal-noperm@example.com"
|
||||
if not frappe.db.exists("User", email):
|
||||
user = frappe.new_doc("User")
|
||||
user.email = email
|
||||
user.first_name = "No"
|
||||
user.last_name = "Perm"
|
||||
user.send_welcome_email = 0
|
||||
user.insert(ignore_permissions=True)
|
||||
return email
|
||||
|
||||
|
||||
def create_test_deal(**kwargs):
|
||||
"""Helper function to create a CRM Deal for testing"""
|
||||
# Create organization if provided as string
|
||||
if "organization" in kwargs and isinstance(kwargs["organization"], str):
|
||||
org_name = kwargs["organization"]
|
||||
if not frappe.db.exists("CRM Organization", {"organization_name": org_name}):
|
||||
org = frappe.get_doc({"doctype": "CRM Organization", "organization_name": org_name}).insert()
|
||||
kwargs["organization"] = org.name
|
||||
else:
|
||||
kwargs["organization"] = frappe.db.get_value(
|
||||
"CRM Organization", {"organization_name": org_name}, "name"
|
||||
)
|
||||
|
||||
data = {"doctype": "CRM Deal"}
|
||||
data.update(kwargs)
|
||||
return frappe.get_doc(data).insert()
|
||||
|
||||
|
||||
def create_test_contact(**kwargs):
|
||||
"""Helper function to create a Contact for testing"""
|
||||
contact = frappe.get_doc({"doctype": "Contact"})
|
||||
contact.update(kwargs)
|
||||
|
||||
if kwargs.get("email"):
|
||||
contact.append("email_ids", {"email_id": kwargs["email"], "is_primary": 1})
|
||||
|
||||
if kwargs.get("mobile_no"):
|
||||
contact.append("phone_nos", {"phone": kwargs["mobile_no"], "is_primary_mobile_no": 1})
|
||||
|
||||
if kwargs.get("phone"):
|
||||
contact.append("phone_nos", {"phone": kwargs["phone"], "is_primary_phone": 1})
|
||||
|
||||
contact.insert(ignore_permissions=True)
|
||||
return contact
|
||||
355
crm/fcrm/doctype/crm_deal/test_records.json
Normal file
355
crm/fcrm/doctype/crm_deal/test_records.json
Normal file
@@ -0,0 +1,355 @@
|
||||
[
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": "Acme Corp",
|
||||
"status": "Qualification",
|
||||
"deal_value": 50000,
|
||||
"currency": "USD",
|
||||
"exchange_rate": 1,
|
||||
"source": "Website",
|
||||
"deal_owner": "Administrator",
|
||||
"probability": 30
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": "Tech Solutions",
|
||||
"status": "Qualification",
|
||||
"deal_value": 75000,
|
||||
"currency": "USD",
|
||||
"exchange_rate": 1,
|
||||
"source": "Referral",
|
||||
"deal_owner": "Administrator",
|
||||
"probability": 40
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": "Global Enterprises",
|
||||
"status": "Qualification",
|
||||
"deal_value": 100000,
|
||||
"currency": "USD",
|
||||
"exchange_rate": 1,
|
||||
"source": "Cold Call",
|
||||
"probability": 25
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": "StartUp Inc",
|
||||
"status": "Qualification",
|
||||
"deal_value": 25000,
|
||||
"currency": "USD",
|
||||
"exchange_rate": 1,
|
||||
"source": "Email Campaign",
|
||||
"deal_owner": "Administrator",
|
||||
"probability": 35
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": "Innovation Labs",
|
||||
"status": "Qualification",
|
||||
"deal_value": 80000,
|
||||
"currency": "USD",
|
||||
"exchange_rate": 1,
|
||||
"source": "Social Media",
|
||||
"probability": 30
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": "Digital Corp",
|
||||
"status": "Qualification",
|
||||
"deal_value": 60000,
|
||||
"currency": "USD",
|
||||
"exchange_rate": 1,
|
||||
"source": "Website",
|
||||
"deal_owner": "Administrator",
|
||||
"probability": 40
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": "Cloud Systems",
|
||||
"status": "Qualification",
|
||||
"deal_value": 90000,
|
||||
"currency": "USD",
|
||||
"exchange_rate": 1,
|
||||
"source": "Referral",
|
||||
"deal_owner": "crm.user1@example.com",
|
||||
"probability": 35
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": "Data Inc",
|
||||
"status": "Qualification",
|
||||
"deal_value": 45000,
|
||||
"currency": "USD",
|
||||
"exchange_rate": 1,
|
||||
"source": "Cold Call",
|
||||
"deal_owner": "Administrator",
|
||||
"probability": 30
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": "Smart Solutions",
|
||||
"status": "Qualification",
|
||||
"deal_value": 70000,
|
||||
"currency": "USD",
|
||||
"exchange_rate": 1,
|
||||
"source": "Email Campaign",
|
||||
"deal_owner": "crm.user1@example.com",
|
||||
"probability": 25
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": "Future Tech",
|
||||
"status": "Qualification",
|
||||
"deal_value": 55000,
|
||||
"currency": "USD",
|
||||
"exchange_rate": 1,
|
||||
"source": "Social Media",
|
||||
"deal_owner": "Administrator",
|
||||
"probability": 40
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": "Alpha Industries",
|
||||
"status": "Qualification",
|
||||
"deal_value": 85000,
|
||||
"currency": "USD",
|
||||
"exchange_rate": 1,
|
||||
"source": "Website",
|
||||
"probability": 35
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": "Beta Corp",
|
||||
"status": "Qualification",
|
||||
"deal_value": 65000,
|
||||
"currency": "USD",
|
||||
"exchange_rate": 1,
|
||||
"source": "Referral",
|
||||
"deal_owner": "Administrator",
|
||||
"probability": 30
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": "Gamma Systems",
|
||||
"status": "Qualification",
|
||||
"deal_value": 95000,
|
||||
"currency": "USD",
|
||||
"exchange_rate": 1,
|
||||
"source": "Cold Call",
|
||||
"probability": 25
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": "Delta Solutions",
|
||||
"status": "Negotiation",
|
||||
"deal_value": 120000,
|
||||
"currency": "USD",
|
||||
"exchange_rate": 1,
|
||||
"source": "Email Campaign",
|
||||
"deal_owner": "Administrator",
|
||||
"probability": 60
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": "Epsilon Tech",
|
||||
"status": "Negotiation",
|
||||
"deal_value": 110000,
|
||||
"currency": "USD",
|
||||
"exchange_rate": 1,
|
||||
"source": "Social Media",
|
||||
"probability": 55
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": "Zeta Corp",
|
||||
"status": "Negotiation",
|
||||
"deal_value": 130000,
|
||||
"currency": "USD",
|
||||
"exchange_rate": 1,
|
||||
"source": "Website",
|
||||
"deal_owner": "Administrator",
|
||||
"probability": 65
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": "Eta Systems",
|
||||
"status": "Negotiation",
|
||||
"deal_value": 105000,
|
||||
"currency": "USD",
|
||||
"exchange_rate": 1,
|
||||
"source": "Referral",
|
||||
"probability": 60
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": "Theta Inc",
|
||||
"status": "Negotiation",
|
||||
"deal_value": 115000,
|
||||
"currency": "USD",
|
||||
"exchange_rate": 1,
|
||||
"source": "Cold Call",
|
||||
"deal_owner": "Administrator",
|
||||
"probability": 70
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": "Iota Labs",
|
||||
"status": "Negotiation",
|
||||
"deal_value": 125000,
|
||||
"currency": "USD",
|
||||
"exchange_rate": 1,
|
||||
"source": "Email Campaign",
|
||||
"probability": 55
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": "Kappa Tech",
|
||||
"status": "Negotiation",
|
||||
"deal_value": 140000,
|
||||
"currency": "USD",
|
||||
"exchange_rate": 1,
|
||||
"source": "Social Media",
|
||||
"deal_owner": "Administrator",
|
||||
"probability": 65
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": "Lambda Corp",
|
||||
"status": "Negotiation",
|
||||
"deal_value": 135000,
|
||||
"currency": "USD",
|
||||
"exchange_rate": 1,
|
||||
"source": "Website",
|
||||
"probability": 60
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": "Mu Systems",
|
||||
"status": "Won",
|
||||
"deal_value": 150000,
|
||||
"currency": "USD",
|
||||
"exchange_rate": 1,
|
||||
"source": "Referral",
|
||||
"deal_owner": "Administrator",
|
||||
"probability": 100,
|
||||
"closed_date": "2026-01-15"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": "Nu Solutions",
|
||||
"status": "Won",
|
||||
"deal_value": 160000,
|
||||
"currency": "USD",
|
||||
"exchange_rate": 1,
|
||||
"source": "Cold Call",
|
||||
"probability": 100,
|
||||
"closed_date": "2026-01-20"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": "Xi Tech",
|
||||
"status": "Won",
|
||||
"deal_value": 145000,
|
||||
"currency": "USD",
|
||||
"exchange_rate": 1,
|
||||
"source": "Email Campaign",
|
||||
"deal_owner": "Administrator",
|
||||
"probability": 100,
|
||||
"closed_date": "2026-01-25"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": "Omicron Inc",
|
||||
"status": "Won",
|
||||
"deal_value": 170000,
|
||||
"currency": "USD",
|
||||
"exchange_rate": 1,
|
||||
"source": "Social Media",
|
||||
"probability": 100,
|
||||
"closed_date": "2026-01-28"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": "Pi Labs",
|
||||
"status": "Won",
|
||||
"deal_value": 155000,
|
||||
"currency": "USD",
|
||||
"exchange_rate": 1,
|
||||
"source": "Website",
|
||||
"deal_owner": "Administrator",
|
||||
"probability": 100,
|
||||
"closed_date": "2026-01-30"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": "Rho Corp",
|
||||
"status": "Won",
|
||||
"deal_value": 165000,
|
||||
"currency": "USD",
|
||||
"exchange_rate": 1,
|
||||
"source": "Referral",
|
||||
"probability": 100,
|
||||
"closed_date": "2026-02-01"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": "Sigma Systems",
|
||||
"status": "Won",
|
||||
"deal_value": 175000,
|
||||
"currency": "USD",
|
||||
"exchange_rate": 1,
|
||||
"source": "Cold Call",
|
||||
"deal_owner": "Administrator",
|
||||
"probability": 100,
|
||||
"closed_date": "2026-02-02"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": "Tau Solutions",
|
||||
"status": "Won",
|
||||
"deal_value": 180000,
|
||||
"currency": "USD",
|
||||
"exchange_rate": 1,
|
||||
"source": "Email Campaign",
|
||||
"probability": 100,
|
||||
"closed_date": "2026-02-03"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": "Upsilon Tech",
|
||||
"status": "Lost",
|
||||
"deal_value": 200000,
|
||||
"currency": "USD",
|
||||
"exchange_rate": 1,
|
||||
"source": "Social Media",
|
||||
"deal_owner": "Administrator",
|
||||
"probability": 0,
|
||||
"closed_date": "2026-01-18",
|
||||
"lost_reason": "Price too high"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": "Phi Corp",
|
||||
"status": "Lost",
|
||||
"deal_value": 190000,
|
||||
"currency": "USD",
|
||||
"exchange_rate": 1,
|
||||
"source": "Website",
|
||||
"probability": 0,
|
||||
"closed_date": "2026-01-22",
|
||||
"lost_reason": "Price too high"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal",
|
||||
"organization": "Chi Systems",
|
||||
"status": "Lost",
|
||||
"deal_value": 210000,
|
||||
"currency": "USD",
|
||||
"exchange_rate": 1,
|
||||
"source": "Referral",
|
||||
"deal_owner": "Administrator",
|
||||
"probability": 0,
|
||||
"closed_date": "2026-01-26",
|
||||
"lost_reason": "Competition"
|
||||
}
|
||||
]
|
||||
0
crm/fcrm/doctype/crm_deal_status/__init__.py
Normal file
0
crm/fcrm/doctype/crm_deal_status/__init__.py
Normal file
8
crm/fcrm/doctype/crm_deal_status/crm_deal_status.js
Normal file
8
crm/fcrm/doctype/crm_deal_status/crm_deal_status.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("CRM Deal Status", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
110
crm/fcrm/doctype/crm_deal_status/crm_deal_status.json
Normal file
110
crm/fcrm/doctype/crm_deal_status/crm_deal_status.json
Normal file
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:deal_status",
|
||||
"creation": "2023-11-29 11:24:55.543387",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"deal_status",
|
||||
"type",
|
||||
"position",
|
||||
"column_break_ojiu",
|
||||
"probability",
|
||||
"color"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"default": "gray",
|
||||
"fieldname": "color",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Color",
|
||||
"options": "black\ngray\nblue\ngreen\nred\npink\norange\namber\nyellow\ncyan\nteal\nviolet\npurple"
|
||||
},
|
||||
{
|
||||
"fieldname": "deal_status",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Status",
|
||||
"reqd": 1,
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "position",
|
||||
"fieldtype": "Int",
|
||||
"in_list_view": 1,
|
||||
"label": "Position"
|
||||
},
|
||||
{
|
||||
"fieldname": "probability",
|
||||
"fieldtype": "Percent",
|
||||
"in_list_view": 1,
|
||||
"label": "Probability"
|
||||
},
|
||||
{
|
||||
"default": "Open",
|
||||
"fieldname": "type",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Type",
|
||||
"options": "Open\nOngoing\nOn Hold\nWon\nLost"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_ojiu",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-03-05 12:42:47.973700",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Deal Status",
|
||||
"naming_rule": "By fieldname",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales User",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"translated_doctype": 1
|
||||
}
|
||||
38
crm/fcrm/doctype/crm_deal_status/crm_deal_status.py
Normal file
38
crm/fcrm/doctype/crm_deal_status/crm_deal_status.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class CRMDealStatus(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
color: DF.Literal[
|
||||
"black",
|
||||
"gray",
|
||||
"blue",
|
||||
"green",
|
||||
"red",
|
||||
"pink",
|
||||
"orange",
|
||||
"amber",
|
||||
"yellow",
|
||||
"cyan",
|
||||
"teal",
|
||||
"violet",
|
||||
"purple",
|
||||
]
|
||||
deal_status: DF.Data
|
||||
position: DF.Int
|
||||
probability: DF.Percent
|
||||
type: DF.Literal["Open", "Ongoing", "On Hold", "Won", "Lost"]
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
9
crm/fcrm/doctype/crm_deal_status/test_crm_deal_status.py
Normal file
9
crm/fcrm/doctype/crm_deal_status/test_crm_deal_status.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestCRMDealStatus(UnitTestCase):
|
||||
pass
|
||||
26
crm/fcrm/doctype/crm_deal_status/test_records.json
Normal file
26
crm/fcrm/doctype/crm_deal_status/test_records.json
Normal file
@@ -0,0 +1,26 @@
|
||||
[
|
||||
{
|
||||
"doctype": "CRM Deal Status",
|
||||
"deal_status": "Qualification",
|
||||
"type": "Open",
|
||||
"position": 1
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal Status",
|
||||
"deal_status": "Negotiation",
|
||||
"type": "Open",
|
||||
"position": 2
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal Status",
|
||||
"deal_status": "Won",
|
||||
"type": "Won",
|
||||
"position": 3
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Deal Status",
|
||||
"deal_status": "Lost",
|
||||
"type": "Lost",
|
||||
"position": 4
|
||||
}
|
||||
]
|
||||
0
crm/fcrm/doctype/crm_dropdown_item/__init__.py
Normal file
0
crm/fcrm/doctype/crm_dropdown_item/__init__.py
Normal file
95
crm/fcrm/doctype/crm_dropdown_item/crm_dropdown_item.json
Normal file
95
crm/fcrm/doctype/crm_dropdown_item/crm_dropdown_item.json
Normal file
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2024-12-27 17:42:33.089685",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"name1",
|
||||
"label",
|
||||
"type",
|
||||
"route",
|
||||
"open_in_new_window",
|
||||
"hidden",
|
||||
"is_standard",
|
||||
"column_break_mvbq",
|
||||
"icon"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "label",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Label",
|
||||
"mandatory_depends_on": "eval:doc.type == 'Route'"
|
||||
},
|
||||
{
|
||||
"fieldname": "type",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Type",
|
||||
"options": "Route\nSeparator",
|
||||
"read_only_depends_on": "eval:doc.is_standard"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.type == 'Route'",
|
||||
"fieldname": "route",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Route",
|
||||
"mandatory_depends_on": "eval:doc.type == 'Route'"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "hidden",
|
||||
"fieldtype": "Check",
|
||||
"in_list_view": 1,
|
||||
"label": "Hidden"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "is_standard",
|
||||
"fieldtype": "Check",
|
||||
"label": "Is Standard",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_mvbq",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"description": "Add svg code or use feather icons e.g 'settings'",
|
||||
"fieldname": "icon",
|
||||
"fieldtype": "Code",
|
||||
"label": "Icon"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"depends_on": "eval:doc.type == 'Route'",
|
||||
"fieldname": "open_in_new_window",
|
||||
"fieldtype": "Check",
|
||||
"label": "Open in new window"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.is_standard",
|
||||
"fieldname": "name1",
|
||||
"fieldtype": "Data",
|
||||
"label": "Name",
|
||||
"read_only": 1,
|
||||
"unique": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-23 12:22:42.798468",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Dropdown Item",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
30
crm/fcrm/doctype/crm_dropdown_item/crm_dropdown_item.py
Normal file
30
crm/fcrm/doctype/crm_dropdown_item/crm_dropdown_item.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class CRMDropdownItem(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
hidden: DF.Check
|
||||
icon: DF.Code | None
|
||||
is_standard: DF.Check
|
||||
label: DF.Data | None
|
||||
name1: DF.Data | None
|
||||
open_in_new_window: DF.Check
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
route: DF.Data | None
|
||||
type: DF.Literal["Route", "Separator"]
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
0
crm/fcrm/doctype/crm_exotel_settings/__init__.py
Normal file
0
crm/fcrm/doctype/crm_exotel_settings/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("CRM Exotel Settings", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
130
crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.json
Normal file
130
crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.json
Normal file
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2025-01-08 15:55:50.710356",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"enabled",
|
||||
"column_break_uxtz",
|
||||
"record_call",
|
||||
"section_break_kfez",
|
||||
"account_sid",
|
||||
"subdomain",
|
||||
"column_break_qwfn",
|
||||
"webhook_verify_token",
|
||||
"section_break_iuct",
|
||||
"api_key",
|
||||
"column_break_hyen",
|
||||
"api_token"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "enabled",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enabled"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_kfez",
|
||||
"fieldtype": "Section Break",
|
||||
"hide_border": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "enabled",
|
||||
"fieldname": "account_sid",
|
||||
"fieldtype": "Data",
|
||||
"label": "Account SID",
|
||||
"mandatory_depends_on": "enabled"
|
||||
},
|
||||
{
|
||||
"depends_on": "enabled",
|
||||
"fieldname": "section_break_iuct",
|
||||
"fieldtype": "Section Break",
|
||||
"hide_border": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_hyen",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "enabled",
|
||||
"fieldname": "api_key",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "API Key",
|
||||
"mandatory_depends_on": "enabled"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_uxtz",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "enabled",
|
||||
"fieldname": "api_token",
|
||||
"fieldtype": "Password",
|
||||
"in_list_view": 1,
|
||||
"label": "API Token",
|
||||
"mandatory_depends_on": "enabled"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "enabled",
|
||||
"fieldname": "record_call",
|
||||
"fieldtype": "Check",
|
||||
"label": "Record Outgoing Calls"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_qwfn",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "enabled",
|
||||
"fieldname": "webhook_verify_token",
|
||||
"fieldtype": "Data",
|
||||
"label": "Webhook Verify Token",
|
||||
"mandatory_depends_on": "enabled"
|
||||
},
|
||||
{
|
||||
"depends_on": "enabled",
|
||||
"fieldname": "subdomain",
|
||||
"fieldtype": "Data",
|
||||
"label": "Subdomain",
|
||||
"mandatory_depends_on": "enabled"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-23 12:31:01.281017",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Exotel Settings",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
43
crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.py
Normal file
43
crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
import requests
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class CRMExotelSettings(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
account_sid: DF.Data | None
|
||||
api_key: DF.Data | None
|
||||
api_token: DF.Password | None
|
||||
enabled: DF.Check
|
||||
record_call: DF.Check
|
||||
subdomain: DF.Data | None
|
||||
webhook_verify_token: DF.Data | None
|
||||
# end: auto-generated types
|
||||
|
||||
def validate(self):
|
||||
self.verify_credentials()
|
||||
|
||||
def verify_credentials(self):
|
||||
if self.enabled:
|
||||
response = requests.get(
|
||||
"https://{subdomain}/v1/Accounts/{sid}".format(
|
||||
subdomain=self.subdomain, sid=self.account_sid
|
||||
),
|
||||
auth=(self.api_key, self.get_password("api_token")),
|
||||
)
|
||||
if response.status_code != 200:
|
||||
frappe.throw(
|
||||
_(f"Please enter valid exotel Account SID, API key & API token: {response.reason}"),
|
||||
title=_("Invalid credentials"),
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
|
||||
# On IntegrationTestCase, the doctype test records and all
|
||||
# link-field test record dependencies are recursively loaded
|
||||
# Use these module variables to add/remove to/from that list
|
||||
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
|
||||
|
||||
class UnitTestCRMExotelSettings(UnitTestCase):
|
||||
"""
|
||||
Unit tests for CRMExotelSettings.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class IntegrationTestCRMExotelSettings(IntegrationTestCase):
|
||||
"""
|
||||
Integration tests for CRMExotelSettings.
|
||||
Use this class for testing interactions between multiple components.
|
||||
"""
|
||||
|
||||
pass
|
||||
0
crm/fcrm/doctype/crm_fields_layout/__init__.py
Normal file
0
crm/fcrm/doctype/crm_fields_layout/__init__.py
Normal file
8
crm/fcrm/doctype/crm_fields_layout/crm_fields_layout.js
Normal file
8
crm/fcrm/doctype/crm_fields_layout/crm_fields_layout.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("CRM Fields Layout", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
94
crm/fcrm/doctype/crm_fields_layout/crm_fields_layout.json
Normal file
94
crm/fcrm/doctype/crm_fields_layout/crm_fields_layout.json
Normal file
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "format:{dt}-{type}",
|
||||
"creation": "2024-06-07 16:42:05.495324",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"dt",
|
||||
"column_break_post",
|
||||
"type",
|
||||
"section_break_ttpm",
|
||||
"layout"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "dt",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Document Type",
|
||||
"options": "DocType"
|
||||
},
|
||||
{
|
||||
"fieldname": "type",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Type",
|
||||
"options": "Quick Entry\nSide Panel\nData Fields\nGrid Row\nRequired Fields"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_ttpm",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "layout",
|
||||
"fieldtype": "Code",
|
||||
"label": "Layout",
|
||||
"options": "JSON"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_post",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-23 12:31:51.415568",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Fields Layout",
|
||||
"naming_rule": "Expression (old style)",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales User",
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
259
crm/fcrm/doctype/crm_fields_layout/crm_fields_layout.py
Normal file
259
crm/fcrm/doctype/crm_fields_layout/crm_fields_layout.py
Normal file
@@ -0,0 +1,259 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import json
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import random_string
|
||||
|
||||
|
||||
class CRMFieldsLayout(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
dt: DF.Link | None
|
||||
layout: DF.Code | None
|
||||
type: DF.Literal["Quick Entry", "Side Panel", "Data Fields", "Grid Row", "Required Fields"]
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_fields_layout(doctype: str, type: str, parent_doctype: str | None = None):
|
||||
tabs = []
|
||||
layout = None
|
||||
|
||||
if frappe.db.exists("CRM Fields Layout", {"dt": doctype, "type": type}):
|
||||
layout = frappe.get_doc("CRM Fields Layout", {"dt": doctype, "type": type})
|
||||
|
||||
if layout and layout.layout:
|
||||
tabs = json.loads(layout.layout)
|
||||
|
||||
if not tabs and type != "Required Fields":
|
||||
tabs = get_default_layout(doctype)
|
||||
|
||||
has_tabs = False
|
||||
if isinstance(tabs, list) and len(tabs) > 0 and isinstance(tabs[0], dict):
|
||||
has_tabs = any("sections" in tab for tab in tabs)
|
||||
|
||||
if not has_tabs:
|
||||
tabs = [{"name": "first_tab", "sections": tabs}]
|
||||
|
||||
allowed_fields = []
|
||||
for tab in tabs:
|
||||
for section in tab.get("sections"):
|
||||
if "columns" not in section:
|
||||
continue
|
||||
for column in section.get("columns"):
|
||||
if not column.get("fields"):
|
||||
continue
|
||||
allowed_fields.extend(column.get("fields"))
|
||||
|
||||
fields = frappe.get_meta(doctype).fields
|
||||
fields = [field for field in fields if field.fieldname in allowed_fields]
|
||||
|
||||
required_fields = []
|
||||
|
||||
if type == "Required Fields":
|
||||
required_fields = [
|
||||
field for field in frappe.get_meta(doctype, False).fields if field.reqd and not field.default
|
||||
]
|
||||
|
||||
for tab in tabs:
|
||||
for section in tab.get("sections"):
|
||||
if section.get("columns"):
|
||||
section["columns"] = [column for column in section.get("columns") if column]
|
||||
for column in section.get("columns") if section.get("columns") else []:
|
||||
column["fields"] = [field for field in column.get("fields") if field]
|
||||
for field in column.get("fields") if column.get("fields") else []:
|
||||
field = next((f for f in fields if f.fieldname == field), None)
|
||||
if field:
|
||||
field = field.as_dict()
|
||||
handle_perm_level_restrictions(field, doctype, parent_doctype)
|
||||
column["fields"][column.get("fields").index(field["fieldname"])] = field
|
||||
|
||||
# remove field from required_fields if it is already present
|
||||
if (
|
||||
type == "Required Fields"
|
||||
and field.reqd
|
||||
and any(f.get("fieldname") == field.get("fieldname") for f in required_fields)
|
||||
):
|
||||
required_fields = [
|
||||
f for f in required_fields if f.get("fieldname") != field.get("fieldname")
|
||||
]
|
||||
|
||||
if type == "Required Fields" and required_fields and tabs:
|
||||
tabs[-1].get("sections").append(
|
||||
{
|
||||
"label": "Required Fields",
|
||||
"name": "required_fields_section_" + str(random_string(4)),
|
||||
"opened": True,
|
||||
"hideLabel": True,
|
||||
"columns": [
|
||||
{
|
||||
"name": "required_fields_column_" + str(random_string(4)),
|
||||
"fields": [field.as_dict() for field in required_fields],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
return tabs or []
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_sidepanel_sections(doctype: str):
|
||||
if not frappe.db.exists("CRM Fields Layout", {"dt": doctype, "type": "Side Panel"}):
|
||||
return []
|
||||
layout = frappe.get_doc("CRM Fields Layout", {"dt": doctype, "type": "Side Panel"}).layout
|
||||
|
||||
if not layout:
|
||||
return []
|
||||
|
||||
layout = json.loads(layout)
|
||||
|
||||
not_allowed_fieldtypes = [
|
||||
"Tab Break",
|
||||
"Section Break",
|
||||
"Column Break",
|
||||
]
|
||||
|
||||
fields = frappe.get_meta(doctype).fields
|
||||
fields = [field for field in fields if field.fieldtype not in not_allowed_fieldtypes]
|
||||
|
||||
for section in layout:
|
||||
section["name"] = section.get("name") or section.get("label")
|
||||
for column in section.get("columns") if section.get("columns") else []:
|
||||
for field in column.get("fields") if column.get("fields") else []:
|
||||
field_obj = next((f for f in fields if f.fieldname == field), None)
|
||||
if field_obj:
|
||||
field_obj = field_obj.as_dict()
|
||||
handle_perm_level_restrictions(field_obj, doctype)
|
||||
column["fields"][column.get("fields").index(field)] = get_field_obj(field_obj)
|
||||
|
||||
fields_meta = {}
|
||||
for field in fields:
|
||||
fields_meta[field.fieldname] = field
|
||||
|
||||
return layout
|
||||
|
||||
|
||||
def handle_perm_level_restrictions(field, doctype, parent_doctype=None):
|
||||
if field.permlevel == 0:
|
||||
return
|
||||
field_has_write_access = field.permlevel in get_permlevel_access("write", doctype, parent_doctype)
|
||||
field_has_read_access = field.permlevel in get_permlevel_access("read", doctype, parent_doctype)
|
||||
|
||||
if not field_has_write_access and field_has_read_access:
|
||||
field.read_only = 1
|
||||
if not field_has_read_access and not field_has_write_access:
|
||||
field.hidden = 1
|
||||
|
||||
|
||||
def get_permlevel_access(permission_type="write", doctype=None, parent_doctype=None):
|
||||
allowed_permlevels = []
|
||||
roles = frappe.get_roles()
|
||||
|
||||
meta = frappe.get_meta(doctype)
|
||||
|
||||
if meta.istable and parent_doctype:
|
||||
meta = frappe.get_meta(parent_doctype)
|
||||
elif meta.istable and not parent_doctype:
|
||||
return [1, 0]
|
||||
|
||||
for perm in meta.permissions:
|
||||
if perm.role in roles and perm.get(permission_type) and perm.permlevel not in allowed_permlevels:
|
||||
allowed_permlevels.append(perm.permlevel)
|
||||
|
||||
return allowed_permlevels
|
||||
|
||||
|
||||
def get_field_obj(field):
|
||||
field["placeholder"] = field.get("placeholder") or "Add " + field.label + "..."
|
||||
|
||||
if field.fieldtype == "Link":
|
||||
field["placeholder"] = field.get("placeholder") or "Select " + field.label + "..."
|
||||
elif field.fieldtype == "Select" and field.options:
|
||||
field["placeholder"] = field.get("placeholder") or "Select " + field.label + "..."
|
||||
field["options"] = [{"label": _(option), "value": option} for option in field.options.split("\n")]
|
||||
|
||||
if field.read_only:
|
||||
field["tooltip"] = "This field is read only and cannot be edited."
|
||||
|
||||
return field
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def save_fields_layout(doctype: str, type: str, layout: str):
|
||||
if not frappe.has_permission("CRM Fields Layout", "write"):
|
||||
frappe.throw(_("Not permitted to modify fields layout"), frappe.PermissionError)
|
||||
|
||||
if frappe.db.exists("CRM Fields Layout", {"dt": doctype, "type": type}):
|
||||
doc = frappe.get_doc("CRM Fields Layout", {"dt": doctype, "type": type})
|
||||
else:
|
||||
doc = frappe.new_doc("CRM Fields Layout")
|
||||
|
||||
doc.update(
|
||||
{
|
||||
"dt": doctype,
|
||||
"type": type,
|
||||
"layout": layout,
|
||||
}
|
||||
)
|
||||
doc.save()
|
||||
|
||||
return doc.layout
|
||||
|
||||
|
||||
def get_default_layout(doctype: str):
|
||||
fields = frappe.get_meta(doctype).fields
|
||||
|
||||
tabs = []
|
||||
|
||||
if fields and fields[0].fieldtype != "Tab Break":
|
||||
sections = []
|
||||
if fields and fields[0].fieldtype != "Section Break":
|
||||
sections.append(
|
||||
{
|
||||
"name": "section_" + str(random_string(4)),
|
||||
"columns": [{"name": "column_" + str(random_string(4)), "fields": []}],
|
||||
}
|
||||
)
|
||||
tabs.append({"name": "tab_" + str(random_string(4)), "sections": sections})
|
||||
|
||||
for field in fields:
|
||||
if field.fieldtype == "Tab Break":
|
||||
tabs.append(
|
||||
{
|
||||
"name": "tab_" + str(random_string(4)),
|
||||
"sections": [
|
||||
{
|
||||
"name": "section_" + str(random_string(4)),
|
||||
"columns": [{"name": "column_" + str(random_string(4)), "fields": []}],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
elif field.fieldtype == "Section Break":
|
||||
tabs[-1]["sections"].append(
|
||||
{
|
||||
"name": "section_" + str(random_string(4)),
|
||||
"columns": [{"name": "column_" + str(random_string(4)), "fields": []}],
|
||||
}
|
||||
)
|
||||
elif field.fieldtype == "Column Break":
|
||||
tabs[-1]["sections"][-1]["columns"].append(
|
||||
{"name": "column_" + str(random_string(4)), "fields": []}
|
||||
)
|
||||
else:
|
||||
tabs[-1]["sections"][-1]["columns"][-1]["fields"].append(field.fieldname)
|
||||
|
||||
return tabs
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestCRMFieldsLayout(UnitTestCase):
|
||||
pass
|
||||
0
crm/fcrm/doctype/crm_form_script/__init__.py
Normal file
0
crm/fcrm/doctype/crm_form_script/__init__.py
Normal file
61
crm/fcrm/doctype/crm_form_script/crm_form_script.js
Normal file
61
crm/fcrm/doctype/crm_form_script/crm_form_script.js
Normal file
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on("CRM Form Script", {
|
||||
refresh(frm) {
|
||||
frm.set_query("dt", {
|
||||
filters: {
|
||||
istable: 0,
|
||||
},
|
||||
});
|
||||
|
||||
if (frm.doc.is_standard && !frappe.boot.developer_mode) {
|
||||
frm.disable_form();
|
||||
frappe.show_alert(
|
||||
__(
|
||||
"Standard Form Scripts can not be modified, duplicate the Form Script instead.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!frappe.boot.developer_mode) {
|
||||
frm.toggle_enable("is_standard", 0);
|
||||
}
|
||||
|
||||
frm.trigger("add_enable_button");
|
||||
},
|
||||
|
||||
add_enable_button(frm) {
|
||||
frm.add_custom_button(
|
||||
frm.doc.enabled ? __("Disable") : __("Enable"),
|
||||
() => {
|
||||
frm.set_value("enabled", !frm.doc.enabled);
|
||||
frm.save();
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
view(frm) {
|
||||
let has_form_boilerplate = frm.doc.script.includes("function setupForm(");
|
||||
let has_list_boilerplate = frm.doc.script.includes("function setupList(");
|
||||
|
||||
if (frm.doc.view == "Form" && !has_form_boilerplate) {
|
||||
frm.doc.script = `
|
||||
function setupForm({ doc }) {
|
||||
return {
|
||||
actions: [],
|
||||
statuses: [],
|
||||
}
|
||||
}`.trim();
|
||||
}
|
||||
if (frm.doc.view == "List" && !has_list_boilerplate) {
|
||||
frm.doc.script = `
|
||||
function setupList({ list }) {
|
||||
return {
|
||||
actions: [],
|
||||
bulk_actions: [],
|
||||
}
|
||||
}`.trim();
|
||||
}
|
||||
},
|
||||
});
|
||||
110
crm/fcrm/doctype/crm_form_script/crm_form_script.json
Normal file
110
crm/fcrm/doctype/crm_form_script/crm_form_script.json
Normal file
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "prompt",
|
||||
"creation": "2023-12-28 14:18:09.329868",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"dt",
|
||||
"view",
|
||||
"column_break_gboh",
|
||||
"enabled",
|
||||
"is_standard",
|
||||
"section_break_xeox",
|
||||
"script"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "column_break_gboh",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_xeox",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "dt",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "DocType",
|
||||
"options": "DocType",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "enabled",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
"label": "Enabled"
|
||||
},
|
||||
{
|
||||
"default": "function setupForm({ doc }) {\n return {\n actions: [],\n }\n}",
|
||||
"documentation_url": "https://docs.frappe.io/crm/custom-actions",
|
||||
"fieldname": "script",
|
||||
"fieldtype": "Code",
|
||||
"label": "Script",
|
||||
"options": "JS"
|
||||
},
|
||||
{
|
||||
"default": "Form",
|
||||
"fieldname": "view",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Apply To",
|
||||
"options": "Form\nList",
|
||||
"set_only_once": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "is_standard",
|
||||
"fieldtype": "Check",
|
||||
"label": "Is Standard",
|
||||
"no_copy": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-23 12:31:42.629014",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Form Script",
|
||||
"naming_rule": "Set by user",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales Manager",
|
||||
"share": 1
|
||||
},
|
||||
{
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales User",
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
57
crm/fcrm/doctype/crm_form_script/crm_form_script.py
Normal file
57
crm/fcrm/doctype/crm_form_script/crm_form_script.py
Normal file
@@ -0,0 +1,57 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class CRMFormScript(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
dt: DF.Link
|
||||
enabled: DF.Check
|
||||
is_standard: DF.Check
|
||||
script: DF.Code | None
|
||||
view: DF.Literal["Form", "List"]
|
||||
# end: auto-generated types
|
||||
|
||||
def validate(self):
|
||||
in_user_env = not (
|
||||
frappe.flags.in_install
|
||||
or frappe.flags.in_patch
|
||||
or frappe.flags.in_test
|
||||
or frappe.flags.in_fixtures
|
||||
)
|
||||
if in_user_env and not self.is_new() and self.is_standard and not frappe.conf.developer_mode:
|
||||
# only enabled can be changed for standard form scripts
|
||||
if self.has_value_changed("enabled"):
|
||||
enabled_value = self.enabled
|
||||
self.reload()
|
||||
self.enabled = enabled_value
|
||||
else:
|
||||
frappe.throw(_("You need to be in developer mode to edit a Standard Form Script"))
|
||||
|
||||
|
||||
def get_form_script(dt, view="Form"):
|
||||
"""Returns the form script for the given doctype"""
|
||||
FormScript = frappe.qb.DocType("CRM Form Script")
|
||||
query = (
|
||||
frappe.qb.from_(FormScript)
|
||||
.select("script")
|
||||
.where(FormScript.dt == dt)
|
||||
.where(FormScript.view == view)
|
||||
.where(FormScript.enabled == 1)
|
||||
)
|
||||
|
||||
doc = query.run(as_dict=True)
|
||||
if doc:
|
||||
return [d.script for d in doc] if len(doc) > 1 else doc[0].script
|
||||
else:
|
||||
return None
|
||||
9
crm/fcrm/doctype/crm_form_script/test_crm_form_script.py
Normal file
9
crm/fcrm/doctype/crm_form_script/test_crm_form_script.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestCRMFormScript(UnitTestCase):
|
||||
pass
|
||||
0
crm/fcrm/doctype/crm_global_settings/__init__.py
Normal file
0
crm/fcrm/doctype/crm_global_settings/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("CRM Global Settings", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"actions": [],
|
||||
"autoname": "hash",
|
||||
"creation": "2025-02-28 14:37:10.002433",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"dt",
|
||||
"column_break_kipp",
|
||||
"type",
|
||||
"section_break_vass",
|
||||
"json"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"default": "DocType",
|
||||
"fieldname": "dt",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "DocType",
|
||||
"options": "DocType",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_kipp",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "type",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Type",
|
||||
"options": "Quick Filters\nSidebar Items",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_vass",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "json",
|
||||
"fieldtype": "JSON",
|
||||
"label": "JSON"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-29 16:27:28.090170",
|
||||
"modified_by": "shariq@frappe.io",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Global Settings",
|
||||
"naming_rule": "Random",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales User",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
22
crm/fcrm/doctype/crm_global_settings/crm_global_settings.py
Normal file
22
crm/fcrm/doctype/crm_global_settings/crm_global_settings.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class CRMGlobalSettings(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
dt: DF.Link
|
||||
json: DF.JSON | None
|
||||
type: DF.Literal["Quick Filters", "Sidebar Items"]
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,29 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
|
||||
# On IntegrationTestCase, the doctype test records and all
|
||||
# link-field test record dependencies are recursively loaded
|
||||
# Use these module variables to add/remove to/from that list
|
||||
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
|
||||
|
||||
class UnitTestCRMGlobalSettings(UnitTestCase):
|
||||
"""
|
||||
Unit tests for CRMGlobalSettings.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class IntegrationTestCRMGlobalSettings(IntegrationTestCase):
|
||||
"""
|
||||
Integration tests for CRMGlobalSettings.
|
||||
Use this class for testing interactions between multiple components.
|
||||
"""
|
||||
|
||||
pass
|
||||
0
crm/fcrm/doctype/crm_holiday/__init__.py
Normal file
0
crm/fcrm/doctype/crm_holiday/__init__.py
Normal file
58
crm/fcrm/doctype/crm_holiday/crm_holiday.json
Normal file
58
crm/fcrm/doctype/crm_holiday/crm_holiday.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2023-12-14 11:16:15.476366",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"date",
|
||||
"column_break_xzyo",
|
||||
"weekly_off",
|
||||
"section_break_zenz",
|
||||
"description"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "date",
|
||||
"fieldtype": "Date",
|
||||
"in_list_view": 1,
|
||||
"label": "Date",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_xzyo",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "weekly_off",
|
||||
"fieldtype": "Check",
|
||||
"label": "Weekly Off"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_zenz",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "description",
|
||||
"fieldtype": "Text Editor",
|
||||
"in_list_view": 1,
|
||||
"label": "Description",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-23 12:14:21.746011",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Holiday",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
25
crm/fcrm/doctype/crm_holiday/crm_holiday.py
Normal file
25
crm/fcrm/doctype/crm_holiday/crm_holiday.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class CRMHoliday(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
date: DF.Date
|
||||
description: DF.TextEditor
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
weekly_off: DF.Check
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
0
crm/fcrm/doctype/crm_holiday_list/__init__.py
Normal file
0
crm/fcrm/doctype/crm_holiday_list/__init__.py
Normal file
19
crm/fcrm/doctype/crm_holiday_list/crm_holiday_list.js
Normal file
19
crm/fcrm/doctype/crm_holiday_list/crm_holiday_list.js
Normal file
@@ -0,0 +1,19 @@
|
||||
// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on("CRM Holiday List", {
|
||||
clear_table(frm) {
|
||||
frm.set_value("holidays", []);
|
||||
frm.refresh_field("holidays");
|
||||
frm.dirty();
|
||||
},
|
||||
add_to_holidays(frm) {
|
||||
frm.call("get_weekly_off_dates").then((r) => {
|
||||
if (r && r.doc && r.doc.holidays) {
|
||||
frm.doc.holidays = r.doc.holidays;
|
||||
}
|
||||
frm.refresh_field("holidays");
|
||||
frm.dirty();
|
||||
});
|
||||
},
|
||||
});
|
||||
138
crm/fcrm/doctype/crm_holiday_list/crm_holiday_list.json
Normal file
138
crm/fcrm/doctype/crm_holiday_list/crm_holiday_list.json
Normal file
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:holiday_list_name",
|
||||
"creation": "2023-12-14 11:09:12.876640",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"holiday_list_name",
|
||||
"from_date",
|
||||
"to_date",
|
||||
"column_break_qwqc",
|
||||
"total_holidays",
|
||||
"add_weekly_holidays_section",
|
||||
"weekly_off",
|
||||
"add_to_holidays",
|
||||
"holidays_section",
|
||||
"holidays",
|
||||
"clear_table"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "holiday_list_name",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Holiday List Name",
|
||||
"reqd": 1,
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "from_date",
|
||||
"fieldtype": "Date",
|
||||
"in_list_view": 1,
|
||||
"label": "From Date",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "to_date",
|
||||
"fieldtype": "Date",
|
||||
"in_list_view": 1,
|
||||
"label": "To Date",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_qwqc",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "total_holidays",
|
||||
"fieldtype": "Int",
|
||||
"label": "Total Holidays"
|
||||
},
|
||||
{
|
||||
"fieldname": "add_weekly_holidays_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Add Weekly Holidays"
|
||||
},
|
||||
{
|
||||
"fieldname": "weekly_off",
|
||||
"fieldtype": "Select",
|
||||
"label": "Weekly Off",
|
||||
"options": "\nMonday\nTuesday\nWednesday\nThursday\nFriday\nSaturday\nSunday"
|
||||
},
|
||||
{
|
||||
"fieldname": "add_to_holidays",
|
||||
"fieldtype": "Button",
|
||||
"label": "Add to Holidays",
|
||||
"options": "add_to_holidays"
|
||||
},
|
||||
{
|
||||
"fieldname": "holidays_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Holidays"
|
||||
},
|
||||
{
|
||||
"fieldname": "clear_table",
|
||||
"fieldtype": "Button",
|
||||
"label": "Clear Table",
|
||||
"options": "clear_table"
|
||||
},
|
||||
{
|
||||
"fieldname": "holidays",
|
||||
"fieldtype": "Table",
|
||||
"label": "Holidays",
|
||||
"options": "CRM Holiday"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-23 12:28:17.390649",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Holiday List",
|
||||
"naming_rule": "By fieldname",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales User",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
87
crm/fcrm/doctype/crm_holiday_list/crm_holiday_list.py
Normal file
87
crm/fcrm/doctype/crm_holiday_list/crm_holiday_list.py
Normal file
@@ -0,0 +1,87 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
|
||||
import frappe
|
||||
from frappe import _, throw
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import cint, formatdate, getdate
|
||||
|
||||
|
||||
class CRMHolidayList(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
from crm.fcrm.doctype.crm_holiday.crm_holiday import CRMHoliday
|
||||
|
||||
from_date: DF.Date
|
||||
holiday_list_name: DF.Data
|
||||
holidays: DF.Table[CRMHoliday]
|
||||
to_date: DF.Date
|
||||
total_holidays: DF.Int
|
||||
weekly_off: DF.Literal[
|
||||
"", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"
|
||||
]
|
||||
# end: auto-generated types
|
||||
|
||||
def validate(self):
|
||||
self.validate_days()
|
||||
self.total_holidays = len(self.holidays)
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_weekly_off_dates(self):
|
||||
self.validate_values()
|
||||
date_list = self.get_weekly_off_date_list(self.from_date, self.to_date)
|
||||
last_idx = max(
|
||||
[cint(d.idx) for d in self.get("holidays")]
|
||||
or [
|
||||
0,
|
||||
]
|
||||
)
|
||||
for i, d in enumerate(date_list):
|
||||
ch = self.append("holidays", {})
|
||||
ch.description = _(self.weekly_off)
|
||||
ch.date = d
|
||||
ch.weekly_off = 1
|
||||
ch.idx = last_idx + i + 1
|
||||
|
||||
def validate_values(self):
|
||||
if not self.weekly_off:
|
||||
throw(_("Please select weekly off day"))
|
||||
|
||||
def validate_days(self):
|
||||
if getdate(self.from_date) > getdate(self.to_date):
|
||||
throw(_("To Date cannot be before From Date"))
|
||||
|
||||
for day in self.get("holidays"):
|
||||
if not (getdate(self.from_date) <= getdate(day.date) <= getdate(self.to_date)):
|
||||
frappe.throw(
|
||||
_("The holiday on {0} is not between From Date and To Date").format(formatdate(day.date))
|
||||
)
|
||||
|
||||
def get_weekly_off_date_list(self, start_date, end_date):
|
||||
start_date, end_date = getdate(start_date), getdate(end_date)
|
||||
|
||||
import calendar
|
||||
from datetime import timedelta
|
||||
|
||||
from dateutil import relativedelta
|
||||
|
||||
date_list = []
|
||||
existing_date_list = []
|
||||
weekday = getattr(calendar, (self.weekly_off).upper())
|
||||
reference_date = start_date + relativedelta.relativedelta(weekday=weekday)
|
||||
|
||||
existing_date_list = [getdate(holiday.date) for holiday in self.get("holidays")]
|
||||
|
||||
while reference_date <= end_date:
|
||||
if reference_date not in existing_date_list:
|
||||
date_list.append(reference_date)
|
||||
reference_date += timedelta(days=7)
|
||||
|
||||
return date_list
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestCRMHolidayList(UnitTestCase):
|
||||
pass
|
||||
0
crm/fcrm/doctype/crm_industry/__init__.py
Normal file
0
crm/fcrm/doctype/crm_industry/__init__.py
Normal file
8
crm/fcrm/doctype/crm_industry/crm_industry.js
Normal file
8
crm/fcrm/doctype/crm_industry/crm_industry.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("CRM Industry", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
74
crm/fcrm/doctype/crm_industry/crm_industry.json
Normal file
74
crm/fcrm/doctype/crm_industry/crm_industry.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_import": 1,
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:industry",
|
||||
"creation": "2023-07-24 19:40:31.980882",
|
||||
"default_view": "List",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"industry"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "industry",
|
||||
"fieldtype": "Data",
|
||||
"label": "Industry",
|
||||
"unique": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-03-05 12:44:24.510102",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Industry",
|
||||
"naming_rule": "By fieldname",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales User",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"translated_doctype": 1
|
||||
}
|
||||
20
crm/fcrm/doctype/crm_industry/crm_industry.py
Normal file
20
crm/fcrm/doctype/crm_industry/crm_industry.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class CRMIndustry(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
industry: DF.Data | None
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
9
crm/fcrm/doctype/crm_industry/test_crm_industry.py
Normal file
9
crm/fcrm/doctype/crm_industry/test_crm_industry.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestCRMIndustry(UnitTestCase):
|
||||
pass
|
||||
0
crm/fcrm/doctype/crm_invitation/__init__.py
Normal file
0
crm/fcrm/doctype/crm_invitation/__init__.py
Normal file
12
crm/fcrm/doctype/crm_invitation/crm_invitation.js
Normal file
12
crm/fcrm/doctype/crm_invitation/crm_invitation.js
Normal file
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on("CRM Invitation", {
|
||||
refresh(frm) {
|
||||
if (frm.doc.status != "Accepted") {
|
||||
frm.add_custom_button(__("Accept Invitation"), () => {
|
||||
return frm.call("accept_invitation");
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
113
crm/fcrm/doctype/crm_invitation/crm_invitation.json
Normal file
113
crm/fcrm/doctype/crm_invitation/crm_invitation.json
Normal file
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2024-09-03 12:19:18.933810",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"email",
|
||||
"role",
|
||||
"key",
|
||||
"invited_by",
|
||||
"column_break_dsuz",
|
||||
"status",
|
||||
"email_sent_at",
|
||||
"accepted_at"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "email",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Email",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "role",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Role",
|
||||
"options": "\nSales User\nSales Manager\nSystem Manager",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "key",
|
||||
"fieldtype": "Data",
|
||||
"label": "Key"
|
||||
},
|
||||
{
|
||||
"fieldname": "invited_by",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Invited By",
|
||||
"options": "User"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_dsuz",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "status",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Status",
|
||||
"options": "\nPending\nAccepted\nExpired"
|
||||
},
|
||||
{
|
||||
"fieldname": "email_sent_at",
|
||||
"fieldtype": "Datetime",
|
||||
"label": "Email Sent At"
|
||||
},
|
||||
{
|
||||
"fieldname": "accepted_at",
|
||||
"fieldtype": "Datetime",
|
||||
"label": "Accepted At"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-23 12:15:54.279434",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Invitation",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales User",
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
119
crm/fcrm/doctype/crm_invitation/crm_invitation.py
Normal file
119
crm/fcrm/doctype/crm_invitation/crm_invitation.py
Normal file
@@ -0,0 +1,119 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class CRMInvitation(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
accepted_at: DF.Datetime | None
|
||||
email: DF.Data
|
||||
email_sent_at: DF.Datetime | None
|
||||
invited_by: DF.Link | None
|
||||
key: DF.Data | None
|
||||
role: DF.Literal["", "Sales User", "Sales Manager", "System Manager"]
|
||||
status: DF.Literal["", "Pending", "Accepted", "Expired"]
|
||||
# end: auto-generated types
|
||||
|
||||
def before_insert(self):
|
||||
frappe.utils.validate_email_address(self.email, True)
|
||||
|
||||
self.key = frappe.generate_hash(length=12)
|
||||
self.invited_by = frappe.session.user
|
||||
self.status = "Pending"
|
||||
|
||||
def after_insert(self):
|
||||
self.invite_via_email()
|
||||
|
||||
def invite_via_email(self):
|
||||
invite_link = frappe.utils.get_url(f"/api/method/crm.api.accept_invitation?key={self.key}")
|
||||
if frappe.local.dev_server:
|
||||
print(f"Invite link for {self.email}: {invite_link}") # nosemgrep
|
||||
|
||||
title = "Frappe CRM"
|
||||
template = "crm_invitation"
|
||||
|
||||
frappe.sendmail(
|
||||
recipients=self.email,
|
||||
subject=f"You have been invited to join {title}",
|
||||
template=template,
|
||||
args={"title": title, "invite_link": invite_link},
|
||||
now=True,
|
||||
)
|
||||
self.db_set("email_sent_at", frappe.utils.now())
|
||||
|
||||
@frappe.whitelist()
|
||||
def accept_invitation(self):
|
||||
frappe.only_for(["System Manager", "Sales Manager"], True)
|
||||
if self.accept():
|
||||
# the invitee was not around to set a password, mail them a link to do it
|
||||
frappe.get_doc("User", self.email).send_welcome_mail_to_user()
|
||||
|
||||
def accept(self):
|
||||
if self.status != "Pending":
|
||||
frappe.throw(_("Invalid or expired key"))
|
||||
|
||||
user, is_new_user = self.create_user_if_not_exists()
|
||||
user.append_roles(self.role)
|
||||
if self.role == "System Manager":
|
||||
user.append_roles("Sales Manager", "Sales User")
|
||||
elif self.role == "Sales Manager":
|
||||
user.append_roles("Sales User")
|
||||
if self.role == "Sales User":
|
||||
self.update_module_in_user(user, "FCRM")
|
||||
user.save(ignore_permissions=True)
|
||||
|
||||
self.status = "Accepted"
|
||||
self.accepted_at = frappe.utils.now()
|
||||
self.key = None
|
||||
self.save(ignore_permissions=True)
|
||||
|
||||
return is_new_user
|
||||
|
||||
def update_module_in_user(self, user, module):
|
||||
block_modules = frappe.get_all(
|
||||
"Module Def",
|
||||
fields=["name as module"],
|
||||
filters={"name": ["!=", module]},
|
||||
)
|
||||
|
||||
if block_modules:
|
||||
user.set("block_modules", block_modules)
|
||||
|
||||
def create_user_if_not_exists(self):
|
||||
if not frappe.db.exists("User", self.email):
|
||||
first_name = self.email.split("@")[0].title()
|
||||
user = frappe.get_doc(
|
||||
doctype="User",
|
||||
user_type="System User",
|
||||
email=self.email,
|
||||
send_welcome_email=0,
|
||||
first_name=first_name,
|
||||
default_app="crm",
|
||||
).insert(ignore_permissions=True)
|
||||
return user, True
|
||||
|
||||
return frappe.get_doc("User", self.email), False
|
||||
|
||||
|
||||
def expire_invitations():
|
||||
"""expire invitations after 3 days"""
|
||||
from frappe.utils import add_days, now
|
||||
|
||||
days = 3
|
||||
invitations_to_expire = frappe.db.get_all(
|
||||
"CRM Invitation", filters={"status": "Pending", "creation": ["<", add_days(now(), -days)]}
|
||||
)
|
||||
for invitation in invitations_to_expire:
|
||||
invitation = frappe.get_doc("CRM Invitation", invitation.name)
|
||||
invitation.status = "Expired"
|
||||
invitation.save(ignore_permissions=True)
|
||||
153
crm/fcrm/doctype/crm_invitation/test_crm_invitation.py
Normal file
153
crm/fcrm/doctype/crm_invitation/test_crm_invitation.py
Normal file
@@ -0,0 +1,153 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.utils import add_days, now
|
||||
|
||||
|
||||
class TestCRMInvitation(FrappeTestCase):
|
||||
def make_invitation(self, email="invitee@example.com", role="Sales User"):
|
||||
"""Create a Pending invitation without actually sending an email."""
|
||||
with patch.object(frappe, "sendmail"):
|
||||
return frappe.get_doc(
|
||||
doctype="CRM Invitation",
|
||||
email=email,
|
||||
role=role,
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
def test_new_invitation_is_pending_with_key(self):
|
||||
invitation = self.make_invitation()
|
||||
self.assertEqual(invitation.status, "Pending")
|
||||
self.assertTrue(invitation.key)
|
||||
self.assertEqual(invitation.invited_by, frappe.session.user)
|
||||
|
||||
def test_accept_pending_invitation(self):
|
||||
invitation = self.make_invitation()
|
||||
|
||||
invitation.accept()
|
||||
|
||||
self.assertEqual(invitation.status, "Accepted")
|
||||
self.assertTrue(invitation.accepted_at)
|
||||
self.assertTrue(frappe.db.exists("User", invitation.email))
|
||||
|
||||
def test_accept_clears_key(self):
|
||||
"""The key is wiped after acceptance so the invite link cannot be reused."""
|
||||
invitation = self.make_invitation()
|
||||
|
||||
invitation.accept()
|
||||
|
||||
self.assertIsNone(invitation.key)
|
||||
self.assertFalse(frappe.db.get_value("CRM Invitation", invitation.name, "key"))
|
||||
|
||||
def test_accept_already_accepted_raises(self):
|
||||
"""An already-accepted invitation cannot be accepted again."""
|
||||
invitation = self.make_invitation()
|
||||
invitation.accept()
|
||||
|
||||
invitation.reload()
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
invitation.accept()
|
||||
|
||||
def test_accept_expired_invitation_raises(self):
|
||||
invitation = self.make_invitation()
|
||||
invitation.status = "Expired"
|
||||
invitation.save(ignore_permissions=True)
|
||||
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
invitation.accept()
|
||||
|
||||
def test_accept_reports_newly_created_user(self):
|
||||
invitation = self.make_invitation(email="brand-new@example.com")
|
||||
|
||||
self.assertTrue(invitation.accept())
|
||||
self.assertEqual(frappe.db.get_value("User", invitation.email, "default_app"), "crm")
|
||||
|
||||
def test_accept_reports_existing_user(self):
|
||||
frappe.get_doc(
|
||||
doctype="User",
|
||||
user_type="System User",
|
||||
email="already-there@example.com",
|
||||
send_welcome_email=0,
|
||||
first_name="Already There",
|
||||
).insert(ignore_permissions=True)
|
||||
invitation = self.make_invitation(email="already-there@example.com")
|
||||
|
||||
self.assertFalse(invitation.accept())
|
||||
|
||||
def test_accept_invitation_sends_new_user_to_set_password(self):
|
||||
"""A new user must set a password instead of being logged in directly."""
|
||||
from crm.api import accept_invitation
|
||||
|
||||
invitation = self.make_invitation(email="new-invitee@example.com")
|
||||
|
||||
accept_invitation(key=invitation.key)
|
||||
|
||||
self.assertEqual(frappe.local.response["type"], "redirect")
|
||||
self.assertIn("/update-password?key=", frappe.local.response["location"])
|
||||
|
||||
def test_accept_invitation_logs_in_existing_user(self):
|
||||
"""An existing user already has a password, so log them straight in."""
|
||||
from crm.api import accept_invitation
|
||||
|
||||
frappe.get_doc(
|
||||
doctype="User",
|
||||
user_type="System User",
|
||||
email="existing-invitee@example.com",
|
||||
send_welcome_email=0,
|
||||
first_name="Existing Invitee",
|
||||
).insert(ignore_permissions=True)
|
||||
invitation = self.make_invitation(email="existing-invitee@example.com")
|
||||
|
||||
with patch.object(frappe.local, "login_manager", create=True) as login_manager:
|
||||
accept_invitation(key=invitation.key)
|
||||
|
||||
login_manager.login_as.assert_called_once_with("existing-invitee@example.com")
|
||||
self.assertEqual(frappe.local.response["location"], "/crm")
|
||||
|
||||
def test_desk_accept_mails_new_user_a_set_password_link(self):
|
||||
invitation = self.make_invitation(email="desk-invitee@example.com")
|
||||
|
||||
with patch("frappe.core.doctype.user.user.User.send_welcome_mail_to_user") as welcome_mail:
|
||||
invitation.accept_invitation()
|
||||
|
||||
welcome_mail.assert_called_once()
|
||||
|
||||
def test_desk_accept_does_not_mail_existing_user(self):
|
||||
frappe.get_doc(
|
||||
doctype="User",
|
||||
user_type="System User",
|
||||
email="desk-existing@example.com",
|
||||
send_welcome_email=0,
|
||||
first_name="Desk Existing",
|
||||
).insert(ignore_permissions=True)
|
||||
invitation = self.make_invitation(email="desk-existing@example.com")
|
||||
|
||||
with patch("frappe.core.doctype.user.user.User.send_welcome_mail_to_user") as welcome_mail:
|
||||
invitation.accept_invitation()
|
||||
|
||||
welcome_mail.assert_not_called()
|
||||
|
||||
def test_expire_invitations_expires_old_pending_invites(self):
|
||||
from crm.fcrm.doctype.crm_invitation.crm_invitation import expire_invitations
|
||||
|
||||
invitation = self.make_invitation(email="stale@example.com")
|
||||
frappe.db.set_value(
|
||||
"CRM Invitation", invitation.name, "creation", add_days(now(), -4), update_modified=False
|
||||
)
|
||||
|
||||
expire_invitations()
|
||||
|
||||
self.assertEqual(frappe.db.get_value("CRM Invitation", invitation.name, "status"), "Expired")
|
||||
|
||||
def test_accept_grants_role_to_user(self):
|
||||
invitation = self.make_invitation(email="manager@example.com", role="Sales Manager")
|
||||
|
||||
invitation.accept()
|
||||
|
||||
user = frappe.get_doc("User", invitation.email)
|
||||
user_roles = {r.role for r in user.roles}
|
||||
self.assertIn("Sales Manager", user_roles)
|
||||
self.assertIn("Sales User", user_roles)
|
||||
0
crm/fcrm/doctype/crm_lead/__init__.py
Normal file
0
crm/fcrm/doctype/crm_lead/__init__.py
Normal file
67
crm/fcrm/doctype/crm_lead/crm_lead.js
Normal file
67
crm/fcrm/doctype/crm_lead/crm_lead.js
Normal file
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on("CRM Lead", {
|
||||
refresh(frm) {
|
||||
frm.add_web_link(`/crm/leads/${frm.doc.name}`, __("Open in Portal"));
|
||||
},
|
||||
update_total: function (frm) {
|
||||
let total = 0;
|
||||
let total_qty = 0;
|
||||
let net_total = 0;
|
||||
frm.doc.products.forEach((d) => {
|
||||
total += d.amount;
|
||||
total_qty += d.qty;
|
||||
net_total += d.net_amount;
|
||||
});
|
||||
|
||||
frappe.model.set_value(frm.doctype, frm.docname, "total", total);
|
||||
frappe.model.set_value(
|
||||
frm.doctype,
|
||||
frm.docname,
|
||||
"net_total",
|
||||
net_total || total,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("CRM Products", {
|
||||
products_add: function (frm, cdt, cdn) {
|
||||
frm.trigger("update_total");
|
||||
},
|
||||
products_remove: function (frm, cdt, cdn) {
|
||||
frm.trigger("update_total");
|
||||
},
|
||||
product_code: function (frm, cdt, cdn) {
|
||||
let d = frappe.get_doc(cdt, cdn);
|
||||
frappe.model.set_value(cdt, cdn, "product_name", d.product_code);
|
||||
},
|
||||
rate: function (frm, cdt, cdn) {
|
||||
let d = frappe.get_doc(cdt, cdn);
|
||||
if (d.rate && d.qty) {
|
||||
frappe.model.set_value(cdt, cdn, "amount", d.rate * d.qty);
|
||||
}
|
||||
frm.trigger("update_total");
|
||||
},
|
||||
qty: function (frm, cdt, cdn) {
|
||||
let d = frappe.get_doc(cdt, cdn);
|
||||
if (d.rate && d.qty) {
|
||||
frappe.model.set_value(cdt, cdn, "amount", d.rate * d.qty);
|
||||
}
|
||||
frm.trigger("update_total");
|
||||
},
|
||||
discount_percentage: function (frm, cdt, cdn) {
|
||||
let d = frappe.get_doc(cdt, cdn);
|
||||
if (d.discount_percentage && d.amount) {
|
||||
discount_amount = (d.discount_percentage / 100) * d.amount;
|
||||
frappe.model.set_value(cdt, cdn, "discount_amount", discount_amount);
|
||||
frappe.model.set_value(
|
||||
cdt,
|
||||
cdn,
|
||||
"net_amount",
|
||||
d.amount - discount_amount,
|
||||
);
|
||||
}
|
||||
frm.trigger("update_total");
|
||||
},
|
||||
});
|
||||
525
crm/fcrm/doctype/crm_lead/crm_lead.json
Normal file
525
crm/fcrm/doctype/crm_lead/crm_lead.json
Normal file
@@ -0,0 +1,525 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_import": 1,
|
||||
"allow_rename": 1,
|
||||
"autoname": "naming_series:",
|
||||
"creation": "2023-07-24 12:19:39.616298",
|
||||
"default_view": "List",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"email_append_to": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"person_tab",
|
||||
"salutation",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"column_break_opsm",
|
||||
"lead_name",
|
||||
"email",
|
||||
"mobile_no",
|
||||
"details",
|
||||
"organization",
|
||||
"website",
|
||||
"organization_logo",
|
||||
"company_description",
|
||||
"linkedin",
|
||||
"twitter",
|
||||
"facebook",
|
||||
"territory",
|
||||
"industry",
|
||||
"job_title",
|
||||
"source",
|
||||
"lead_owner",
|
||||
"organization_tab",
|
||||
"section_break_uixv",
|
||||
"naming_series",
|
||||
"middle_name",
|
||||
"gender",
|
||||
"phone",
|
||||
"column_break_dbsv",
|
||||
"status",
|
||||
"no_of_employees",
|
||||
"annual_revenue",
|
||||
"image",
|
||||
"converted",
|
||||
"products_tab",
|
||||
"products",
|
||||
"section_break_ggwh",
|
||||
"total",
|
||||
"column_break_uisv",
|
||||
"net_total",
|
||||
"sla_tab",
|
||||
"sla",
|
||||
"sla_creation",
|
||||
"column_break_ffnp",
|
||||
"sla_status",
|
||||
"communication_status",
|
||||
"response_details_section",
|
||||
"response_by",
|
||||
"column_break_pweh",
|
||||
"first_response_time",
|
||||
"first_responded_on",
|
||||
"section_break_xnpz",
|
||||
"rolling_responses",
|
||||
"section_break_kikl",
|
||||
"column_break_ygds",
|
||||
"last_response_time",
|
||||
"column_break_tcqb",
|
||||
"last_responded_on",
|
||||
"log_tab",
|
||||
"status_change_log",
|
||||
"syncing_tab",
|
||||
"facebook_lead_id",
|
||||
"column_break_ixmu",
|
||||
"facebook_form_id",
|
||||
"lost_details_tab",
|
||||
"lost_reason",
|
||||
"lost_notes"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"default": "CRM-LEAD-.YYYY.-",
|
||||
"fieldname": "naming_series",
|
||||
"fieldtype": "Select",
|
||||
"label": "Series",
|
||||
"options": "CRM-LEAD-.YYYY.-"
|
||||
},
|
||||
{
|
||||
"fieldname": "salutation",
|
||||
"fieldtype": "Link",
|
||||
"label": "Salutation",
|
||||
"options": "Salutation"
|
||||
},
|
||||
{
|
||||
"fieldname": "first_name",
|
||||
"fieldtype": "Data",
|
||||
"label": "First Name",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "middle_name",
|
||||
"fieldtype": "Data",
|
||||
"label": "Middle Name"
|
||||
},
|
||||
{
|
||||
"fieldname": "last_name",
|
||||
"fieldtype": "Data",
|
||||
"label": "Last Name"
|
||||
},
|
||||
{
|
||||
"fieldname": "gender",
|
||||
"fieldtype": "Link",
|
||||
"label": "Gender",
|
||||
"options": "Gender"
|
||||
},
|
||||
{
|
||||
"fieldname": "status",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Status",
|
||||
"options": "CRM Lead Status",
|
||||
"reqd": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "email",
|
||||
"fieldtype": "Data",
|
||||
"in_standard_filter": 1,
|
||||
"label": "Email",
|
||||
"options": "Email",
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "website",
|
||||
"fieldtype": "Data",
|
||||
"label": "Website"
|
||||
},
|
||||
{
|
||||
"fieldname": "mobile_no",
|
||||
"fieldtype": "Data",
|
||||
"label": "Mobile No.",
|
||||
"options": "Phone"
|
||||
},
|
||||
{
|
||||
"fieldname": "phone",
|
||||
"fieldtype": "Data",
|
||||
"label": "Phone",
|
||||
"options": "Phone"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_uixv",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_dbsv",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "no_of_employees",
|
||||
"fieldtype": "Select",
|
||||
"label": "No. of Employees",
|
||||
"options": "1-10\n11-50\n51-200\n201-500\n501-1000\n1000+"
|
||||
},
|
||||
{
|
||||
"fieldname": "annual_revenue",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Annual Revenue",
|
||||
"non_negative": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "lead_owner",
|
||||
"fieldtype": "Link",
|
||||
"label": "Lead Owner",
|
||||
"options": "User"
|
||||
},
|
||||
{
|
||||
"fieldname": "source",
|
||||
"fieldtype": "Link",
|
||||
"label": "Source",
|
||||
"options": "CRM Lead Source"
|
||||
},
|
||||
{
|
||||
"fieldname": "industry",
|
||||
"fieldtype": "Link",
|
||||
"label": "Industry",
|
||||
"options": "CRM Industry"
|
||||
},
|
||||
{
|
||||
"fieldname": "image",
|
||||
"fieldtype": "Attach Image",
|
||||
"hidden": 1,
|
||||
"label": "Image",
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "lead_name",
|
||||
"fieldtype": "Data",
|
||||
"in_standard_filter": 1,
|
||||
"label": "Full Name",
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "job_title",
|
||||
"fieldtype": "Data",
|
||||
"label": "Job Title"
|
||||
},
|
||||
{
|
||||
"fieldname": "organization_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Others",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "organization",
|
||||
"fieldtype": "Data",
|
||||
"in_standard_filter": 1,
|
||||
"label": "Organization"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "converted",
|
||||
"fieldtype": "Check",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Converted"
|
||||
},
|
||||
{
|
||||
"fieldname": "person_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Person"
|
||||
},
|
||||
{
|
||||
"fieldname": "details",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Details"
|
||||
},
|
||||
{
|
||||
"fieldname": "sla_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "SLA",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "sla",
|
||||
"fieldtype": "Link",
|
||||
"label": "SLA",
|
||||
"options": "CRM Service Level Agreement"
|
||||
},
|
||||
{
|
||||
"fieldname": "sla_creation",
|
||||
"fieldtype": "Datetime",
|
||||
"label": "SLA Creation",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_ffnp",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "sla_status",
|
||||
"fieldtype": "Select",
|
||||
"label": "SLA Status",
|
||||
"options": "\nFirst Response Due\nRolling Response Due\nFailed\nFulfilled",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "response_details_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Response Details"
|
||||
},
|
||||
{
|
||||
"fieldname": "response_by",
|
||||
"fieldtype": "Datetime",
|
||||
"label": "Response By",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_pweh",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "first_response_time",
|
||||
"fieldtype": "Duration",
|
||||
"label": "First Response Time",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "first_responded_on",
|
||||
"fieldtype": "Datetime",
|
||||
"label": "First Responded On",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "Open",
|
||||
"fieldname": "communication_status",
|
||||
"fieldtype": "Link",
|
||||
"label": "Communication Status",
|
||||
"options": "CRM Communication Status"
|
||||
},
|
||||
{
|
||||
"fieldname": "territory",
|
||||
"fieldtype": "Link",
|
||||
"label": "Territory",
|
||||
"options": "CRM Territory"
|
||||
},
|
||||
{
|
||||
"fieldname": "log_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Log",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "status_change_log",
|
||||
"fieldtype": "Table",
|
||||
"label": "Status Change Log",
|
||||
"options": "CRM Status Change Log"
|
||||
},
|
||||
{
|
||||
"fieldname": "products_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Products"
|
||||
},
|
||||
{
|
||||
"fieldname": "products",
|
||||
"fieldtype": "Table",
|
||||
"label": "Products",
|
||||
"options": "CRM Products"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_ggwh",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "total",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Total",
|
||||
"non_negative": 1,
|
||||
"options": "currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_uisv",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"description": "Total after discount",
|
||||
"fieldname": "net_total",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Net Total",
|
||||
"non_negative": 1,
|
||||
"options": "currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "syncing_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Syncing"
|
||||
},
|
||||
{
|
||||
"fieldname": "facebook_lead_id",
|
||||
"fieldtype": "Data",
|
||||
"label": "Facebook Lead ID",
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_ixmu",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "facebook_form_id",
|
||||
"fieldtype": "Data",
|
||||
"label": "Facebook Form ID"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_kikl",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "rolling_responses",
|
||||
"fieldtype": "Table",
|
||||
"label": "Rolling Responses",
|
||||
"options": "CRM Rolling Response Time"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_xnpz",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "last_response_time",
|
||||
"fieldtype": "Duration",
|
||||
"label": "Last Response Time",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_tcqb",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "last_responded_on",
|
||||
"fieldtype": "Datetime",
|
||||
"label": "Last Responded On",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_ygds",
|
||||
"fieldtype": "Column Break",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "lost_details_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Lost Details"
|
||||
},
|
||||
{
|
||||
"fieldname": "lost_reason",
|
||||
"fieldtype": "Link",
|
||||
"label": "Lost Reason",
|
||||
"options": "CRM Lost Reason"
|
||||
},
|
||||
{
|
||||
"fieldname": "lost_notes",
|
||||
"fieldtype": "Text",
|
||||
"label": "Lost Notes",
|
||||
"mandatory_depends_on": "eval: doc.lost_reason == \"Other\""
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_opsm",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "organization_logo",
|
||||
"fieldtype": "Attach Image",
|
||||
"label": "Organization Logo",
|
||||
"no_copy": 1,
|
||||
"description": "Company logo discovered by Domain Enrichment."
|
||||
},
|
||||
{
|
||||
"fieldname": "company_description",
|
||||
"fieldtype": "Small Text",
|
||||
"label": "Company Description",
|
||||
"no_copy": 1,
|
||||
"description": "Company description discovered by Domain Enrichment."
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.linkedin",
|
||||
"fieldname": "linkedin",
|
||||
"fieldtype": "Data",
|
||||
"label": "LinkedIn",
|
||||
"no_copy": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.twitter",
|
||||
"fieldname": "twitter",
|
||||
"fieldtype": "Data",
|
||||
"label": "X (Twitter)",
|
||||
"no_copy": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.facebook",
|
||||
"fieldname": "facebook",
|
||||
"fieldtype": "Data",
|
||||
"label": "Facebook",
|
||||
"no_copy": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"image_field": "image",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-04-04 12:33:17.177897",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Lead",
|
||||
"naming_rule": "By \"Naming Series\" field",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales User",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sender_field": "email",
|
||||
"sender_name_field": "first_name",
|
||||
"show_title_field_in_link": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "lead_name",
|
||||
"track_changes": 1,
|
||||
"track_seen": 1
|
||||
}
|
||||
582
crm/fcrm/doctype/crm_lead/crm_lead.py
Normal file
582
crm/fcrm/doctype/crm_lead/crm_lead.py
Normal file
@@ -0,0 +1,582 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import json
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.desk.form.assign_to import _add as assign
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import validate_email_address
|
||||
|
||||
from crm.fcrm.doctype.crm_service_level_agreement.utils import get_sla
|
||||
from crm.fcrm.doctype.crm_status_change_log.crm_status_change_log import (
|
||||
add_status_change_log,
|
||||
)
|
||||
from crm.fcrm.doctype.utils import add_or_remove_lost_reason_section_in_sidepanel
|
||||
|
||||
LEAD_DEAL_FIELD_MAP = {"lead_owner": "deal_owner"}
|
||||
|
||||
|
||||
class CRMLead(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
from crm.fcrm.doctype.crm_products.crm_products import CRMProducts
|
||||
from crm.fcrm.doctype.crm_rolling_response_time.crm_rolling_response_time import (
|
||||
CRMRollingResponseTime,
|
||||
)
|
||||
from crm.fcrm.doctype.crm_status_change_log.crm_status_change_log import CRMStatusChangeLog
|
||||
|
||||
annual_revenue: DF.Currency
|
||||
communication_status: DF.Link | None
|
||||
converted: DF.Check
|
||||
email: DF.Data | None
|
||||
facebook_form_id: DF.Data | None
|
||||
facebook_lead_id: DF.Data | None
|
||||
first_name: DF.Data
|
||||
first_responded_on: DF.Datetime | None
|
||||
first_response_time: DF.Duration | None
|
||||
gender: DF.Link | None
|
||||
image: DF.AttachImage | None
|
||||
industry: DF.Link | None
|
||||
job_title: DF.Data | None
|
||||
last_name: DF.Data | None
|
||||
last_responded_on: DF.Datetime | None
|
||||
last_response_time: DF.Duration | None
|
||||
lead_name: DF.Data | None
|
||||
lead_owner: DF.Link | None
|
||||
lost_notes: DF.Text | None
|
||||
lost_reason: DF.Link | None
|
||||
middle_name: DF.Data | None
|
||||
mobile_no: DF.Data | None
|
||||
naming_series: DF.Literal["CRM-LEAD-.YYYY.-"]
|
||||
net_total: DF.Currency
|
||||
no_of_employees: DF.Literal["1-10", "11-50", "51-200", "201-500", "501-1000", "1000+"]
|
||||
organization: DF.Data | None
|
||||
phone: DF.Data | None
|
||||
products: DF.Table[CRMProducts]
|
||||
response_by: DF.Datetime | None
|
||||
rolling_responses: DF.Table[CRMRollingResponseTime]
|
||||
salutation: DF.Link | None
|
||||
sla: DF.Link | None
|
||||
sla_creation: DF.Datetime | None
|
||||
sla_status: DF.Literal["", "First Response Due", "Rolling Response Due", "Failed", "Fulfilled"]
|
||||
source: DF.Link | None
|
||||
status: DF.Link
|
||||
status_change_log: DF.Table[CRMStatusChangeLog]
|
||||
territory: DF.Link | None
|
||||
total: DF.Currency
|
||||
website: DF.Data | None
|
||||
# end: auto-generated types
|
||||
|
||||
def before_insert(self):
|
||||
# apply CRM enrichment (source/contact) when created via a web form
|
||||
from crm.api.form import enrich_form_submission
|
||||
|
||||
enrich_form_submission(self)
|
||||
|
||||
def before_validate(self):
|
||||
self.set_sla()
|
||||
|
||||
def validate(self):
|
||||
self.validate_status()
|
||||
self.set_full_name()
|
||||
self.set_lead_name()
|
||||
self.set_title()
|
||||
self.validate_email()
|
||||
self.validate_lost_reason()
|
||||
if not self.is_new() and self.has_value_changed("lead_owner") and self.lead_owner:
|
||||
self.share_with_agent(self.lead_owner)
|
||||
self.assign_agent(self.lead_owner)
|
||||
if self.has_value_changed("status"):
|
||||
add_status_change_log(self)
|
||||
|
||||
def after_insert(self):
|
||||
if self.lead_owner:
|
||||
if self.lead_owner != frappe.session.user:
|
||||
self.share_with_agent(self.lead_owner)
|
||||
self.assign_agent(self.lead_owner)
|
||||
|
||||
# Auto-enrich a new Lead from its website (best-effort, background job).
|
||||
from crm.domain_enrichment.tasks import auto_enrich_on_create
|
||||
|
||||
auto_enrich_on_create(self)
|
||||
|
||||
def before_save(self):
|
||||
self.apply_sla()
|
||||
|
||||
def validate_status(self):
|
||||
if self.is_new() and not self.status:
|
||||
if frappe.db.exists("CRM Lead Status", "New"):
|
||||
self.status = "New"
|
||||
else:
|
||||
self.status = frappe.get_all("CRM Lead Status", {"type": "Open"}, pluck="name")[0]
|
||||
|
||||
def set_full_name(self):
|
||||
if self.first_name:
|
||||
self.lead_name = " ".join(
|
||||
name
|
||||
for name in [
|
||||
self.salutation,
|
||||
self.first_name,
|
||||
self.middle_name,
|
||||
self.last_name,
|
||||
]
|
||||
if name
|
||||
)
|
||||
|
||||
def set_lead_name(self):
|
||||
if not self.lead_name:
|
||||
# Check for leads being created through data import
|
||||
if not self.organization and not self.email and not self.flags.ignore_mandatory:
|
||||
frappe.throw(_("A Lead requires either a person's name or an organization's name"))
|
||||
elif self.organization:
|
||||
self.lead_name = self.organization
|
||||
elif self.email:
|
||||
self.lead_name = self.email.split("@")[0]
|
||||
else:
|
||||
self.lead_name = "Unnamed Lead"
|
||||
|
||||
def set_title(self):
|
||||
self.title = self.organization or self.lead_name
|
||||
|
||||
def validate_email(self):
|
||||
if self.email:
|
||||
if not self.flags.ignore_email_validation:
|
||||
validate_email_address(self.email, throw=True)
|
||||
|
||||
if self.email == self.lead_owner:
|
||||
frappe.throw(_("Lead Owner cannot be same as the Lead Email Address"))
|
||||
|
||||
def validate_lost_reason(self):
|
||||
"""
|
||||
Validate the lost reason if the status is set to "Lost".
|
||||
"""
|
||||
if self.status and frappe.get_cached_value("CRM Lead Status", self.status, "type") == "Lost":
|
||||
if not self.lost_reason:
|
||||
frappe.throw(_("Please specify a reason for losing the lead."), frappe.ValidationError)
|
||||
elif self.lost_reason == "Other" and not self.lost_notes:
|
||||
frappe.throw(_("Please specify the reason for losing the lead."), frappe.ValidationError)
|
||||
if self.has_value_changed("status"):
|
||||
add_or_remove_lost_reason_section_in_sidepanel(self)
|
||||
|
||||
def assign_agent(self, agent):
|
||||
if not agent:
|
||||
return
|
||||
|
||||
assignees = self.get_assigned_users()
|
||||
if assignees:
|
||||
for assignee in assignees:
|
||||
if agent == assignee:
|
||||
# the agent is already set as an assignee
|
||||
return
|
||||
|
||||
assign({"assign_to": [agent], "doctype": "CRM Lead", "name": self.name}, ignore_permissions=True)
|
||||
|
||||
def share_with_agent(self, agent):
|
||||
if not agent:
|
||||
return
|
||||
|
||||
docshares = frappe.get_all(
|
||||
"DocShare",
|
||||
filters={"share_name": self.name, "share_doctype": self.doctype},
|
||||
fields=["name", "user"],
|
||||
)
|
||||
|
||||
shared_with = [d.user for d in docshares] + [agent]
|
||||
|
||||
for user in shared_with:
|
||||
if user == agent and not frappe.db.exists(
|
||||
"DocShare",
|
||||
{"user": agent, "share_name": self.name, "share_doctype": self.doctype},
|
||||
):
|
||||
frappe.share.add_docshare(
|
||||
self.doctype,
|
||||
self.name,
|
||||
agent,
|
||||
write=1,
|
||||
flags={"ignore_share_permission": True},
|
||||
)
|
||||
elif user != agent:
|
||||
frappe.share.remove(
|
||||
self.doctype,
|
||||
self.name,
|
||||
user,
|
||||
flags={"ignore_share_permission": True, "ignore_permissions": True},
|
||||
)
|
||||
|
||||
def create_contact(self, existing_contact=None, throw=True):
|
||||
if not self.lead_name:
|
||||
self.set_full_name()
|
||||
self.set_lead_name()
|
||||
|
||||
existing_contact = existing_contact or self.contact_exists(throw)
|
||||
if existing_contact:
|
||||
self.update_lead_contact(existing_contact)
|
||||
return existing_contact
|
||||
|
||||
contact = frappe.new_doc("Contact")
|
||||
contact.update(
|
||||
{
|
||||
"first_name": self.first_name or self.lead_name,
|
||||
"last_name": self.last_name,
|
||||
"salutation": self.salutation,
|
||||
"gender": self.gender,
|
||||
"designation": self.job_title,
|
||||
"company_name": self.organization,
|
||||
"image": self.image or "",
|
||||
}
|
||||
)
|
||||
|
||||
if self.email:
|
||||
contact.append("email_ids", {"email_id": self.email, "is_primary": 1})
|
||||
|
||||
if self.phone:
|
||||
contact.append("phone_nos", {"phone": self.phone, "is_primary_phone": 1})
|
||||
|
||||
if self.mobile_no:
|
||||
contact.append("phone_nos", {"phone": self.mobile_no, "is_primary_mobile_no": 1})
|
||||
|
||||
contact.insert(ignore_permissions=True)
|
||||
contact.reload() # load changes by hooks on contact
|
||||
|
||||
return contact.name
|
||||
|
||||
def create_organization(self, existing_organization=None):
|
||||
if not self.organization and not existing_organization:
|
||||
return
|
||||
|
||||
existing_organization = existing_organization or frappe.db.exists(
|
||||
"CRM Organization", {"organization_name": self.organization}
|
||||
)
|
||||
if existing_organization:
|
||||
self.db_set("organization", existing_organization)
|
||||
self.copy_enrichment_from_organization()
|
||||
return existing_organization
|
||||
|
||||
organization = frappe.new_doc("CRM Organization")
|
||||
organization.update(
|
||||
{
|
||||
"organization_name": self.organization,
|
||||
"website": self.website,
|
||||
"territory": self.territory,
|
||||
"industry": self.industry,
|
||||
"annual_revenue": self.annual_revenue,
|
||||
"no_of_employees": self.no_of_employees,
|
||||
}
|
||||
)
|
||||
organization.insert(ignore_permissions=True)
|
||||
return organization.name
|
||||
|
||||
def copy_enrichment_from_organization(self):
|
||||
"""Fill-empty copy of a linked enriched Organization's fields onto this Lead.
|
||||
|
||||
Called when an existing (already-enriched) CRM Organization is linked. The
|
||||
shared helper mutates ``self`` in place (fill-empty, user data preserved);
|
||||
any filled native fields are then persisted with ``db_set`` to match this
|
||||
branch's already-saved flow. Best-effort -- never blocks the Lead save.
|
||||
"""
|
||||
from crm.domain_enrichment.cross_record import copy_enrichment_from_organization
|
||||
|
||||
enriched_fields = (
|
||||
"organization_logo",
|
||||
"company_description",
|
||||
"industry",
|
||||
"linkedin",
|
||||
"twitter",
|
||||
"facebook",
|
||||
)
|
||||
before = {f: self.get(f) for f in enriched_fields if self.meta.has_field(f)}
|
||||
|
||||
filled = copy_enrichment_from_organization(self)
|
||||
|
||||
for fieldname, old_value in before.items():
|
||||
new_value = self.get(fieldname)
|
||||
if new_value != old_value:
|
||||
self.db_set(fieldname, new_value)
|
||||
return filled
|
||||
|
||||
def update_lead_contact(self, contact):
|
||||
contact = frappe.get_cached_doc("Contact", contact)
|
||||
frappe.db.set_value(
|
||||
"CRM Lead",
|
||||
self.name,
|
||||
{
|
||||
"salutation": contact.salutation,
|
||||
"first_name": contact.first_name,
|
||||
"last_name": contact.last_name,
|
||||
"email": contact.email_id,
|
||||
"mobile_no": contact.mobile_no,
|
||||
},
|
||||
)
|
||||
|
||||
def contact_exists(self, throw=True):
|
||||
# Match only on email which uniquely identifies a person
|
||||
if not self.email:
|
||||
return False
|
||||
|
||||
email_exist = frappe.db.exists("Contact Email", {"email_id": self.email})
|
||||
if not email_exist:
|
||||
return False
|
||||
|
||||
contact = frappe.db.get_value("Contact Email", email_exist, "parent")
|
||||
|
||||
if throw:
|
||||
frappe.throw(
|
||||
_("Contact already exists with Email: {0}").format(self.email),
|
||||
title=_("Contact Already Exists"),
|
||||
)
|
||||
|
||||
return contact
|
||||
|
||||
def create_deal(self, contact, organization, deal=None):
|
||||
new_deal = frappe.new_doc("CRM Deal")
|
||||
|
||||
restricted_fieldtypes = [
|
||||
"Tab Break",
|
||||
"Section Break",
|
||||
"Column Break",
|
||||
"HTML",
|
||||
"Button",
|
||||
"Attach",
|
||||
]
|
||||
restricted_map_fields = [
|
||||
"name",
|
||||
"naming_series",
|
||||
"creation",
|
||||
"owner",
|
||||
"modified",
|
||||
"modified_by",
|
||||
"idx",
|
||||
"docstatus",
|
||||
"status",
|
||||
"email",
|
||||
"mobile_no",
|
||||
"phone",
|
||||
"sla",
|
||||
"sla_status",
|
||||
"response_by",
|
||||
"first_response_time",
|
||||
"first_responded_on",
|
||||
"communication_status",
|
||||
"sla_creation",
|
||||
"status_change_log",
|
||||
]
|
||||
|
||||
for field in self.meta.fields:
|
||||
if field.fieldtype in restricted_fieldtypes:
|
||||
continue
|
||||
if field.fieldname in restricted_map_fields:
|
||||
continue
|
||||
|
||||
fieldname = get_deal_fieldname(field, new_deal.meta)
|
||||
|
||||
if fieldname:
|
||||
if fieldname == "organization":
|
||||
new_deal.update({fieldname: organization})
|
||||
else:
|
||||
new_deal.update({fieldname: self.get(field.fieldname)})
|
||||
|
||||
new_deal.update(
|
||||
{
|
||||
"lead": self.name,
|
||||
"contacts": [{"contact": contact}],
|
||||
}
|
||||
)
|
||||
|
||||
if self.first_responded_on:
|
||||
new_deal.update(
|
||||
{
|
||||
"sla_creation": self.sla_creation,
|
||||
"response_by": self.response_by,
|
||||
"sla_status": self.sla_status,
|
||||
"communication_status": self.communication_status,
|
||||
"first_response_time": self.first_response_time,
|
||||
"first_responded_on": self.first_responded_on,
|
||||
}
|
||||
)
|
||||
|
||||
if deal:
|
||||
new_deal.update(deal)
|
||||
|
||||
new_deal.insert(ignore_permissions=True)
|
||||
|
||||
for user in self.get_assigned_users():
|
||||
if user and user != new_deal.deal_owner:
|
||||
new_deal.assign_agent(user)
|
||||
|
||||
return new_deal.name
|
||||
|
||||
def set_sla(self):
|
||||
"""
|
||||
Find an SLA to apply to the lead.
|
||||
"""
|
||||
if self.sla:
|
||||
return
|
||||
|
||||
sla = get_sla(self)
|
||||
if not sla:
|
||||
self.first_responded_on = None
|
||||
self.first_response_time = None
|
||||
return
|
||||
self.sla = sla.name
|
||||
|
||||
def apply_sla(self):
|
||||
"""
|
||||
Apply SLA if set.
|
||||
"""
|
||||
if not self.sla:
|
||||
return
|
||||
sla = frappe.get_last_doc("CRM Service Level Agreement", {"name": self.sla})
|
||||
if sla:
|
||||
sla.apply(self)
|
||||
|
||||
def convert_to_deal(self, deal=None):
|
||||
return convert_to_deal(lead=self.name, doc=self, deal=deal)
|
||||
|
||||
@staticmethod
|
||||
def get_non_filterable_fields():
|
||||
return ["converted"]
|
||||
|
||||
@staticmethod
|
||||
def default_list_data():
|
||||
columns = [
|
||||
{
|
||||
"label": "Full Name",
|
||||
"type": "Data",
|
||||
"key": "lead_name",
|
||||
"width": "12rem",
|
||||
},
|
||||
{
|
||||
"label": "Organization",
|
||||
"type": "Link",
|
||||
"key": "organization",
|
||||
"options": "CRM Organization",
|
||||
"width": "10rem",
|
||||
},
|
||||
{
|
||||
"label": "Status",
|
||||
"type": "Link",
|
||||
"options": "CRM Lead Status",
|
||||
"key": "status",
|
||||
"width": "8rem",
|
||||
},
|
||||
{
|
||||
"label": "Email",
|
||||
"type": "Data",
|
||||
"key": "email",
|
||||
"width": "12rem",
|
||||
},
|
||||
{
|
||||
"label": "Mobile No.",
|
||||
"type": "Data",
|
||||
"key": "mobile_no",
|
||||
"width": "11rem",
|
||||
},
|
||||
{
|
||||
"label": "Assigned To",
|
||||
"type": "Text",
|
||||
"key": "_assign",
|
||||
"width": "10rem",
|
||||
},
|
||||
{
|
||||
"label": "Last Modified",
|
||||
"type": "Datetime",
|
||||
"key": "modified",
|
||||
"width": "8rem",
|
||||
},
|
||||
]
|
||||
rows = [
|
||||
"name",
|
||||
"lead_name",
|
||||
"organization",
|
||||
"status",
|
||||
"email",
|
||||
"mobile_no",
|
||||
"lead_owner",
|
||||
"first_name",
|
||||
"sla_status",
|
||||
"response_by",
|
||||
"first_response_time",
|
||||
"first_responded_on",
|
||||
"modified",
|
||||
"_assign",
|
||||
"image",
|
||||
]
|
||||
return {"columns": columns, "rows": rows}
|
||||
|
||||
@staticmethod
|
||||
def default_kanban_settings():
|
||||
return {
|
||||
"column_field": "status",
|
||||
"title_field": "lead_name",
|
||||
"kanban_fields": '["organization", "email", "mobile_no", "_assign", "modified"]',
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def convert_to_deal(
|
||||
lead: str,
|
||||
doc: Document | None = None,
|
||||
deal: str | dict | None = None,
|
||||
existing_contact: str | None = None,
|
||||
existing_organization: str | None = None,
|
||||
):
|
||||
if not (doc and doc.flags.get("ignore_permissions")) and not frappe.has_permission(
|
||||
"CRM Lead", "write", lead
|
||||
):
|
||||
frappe.throw(_("Not allowed to convert Lead to Deal"), frappe.PermissionError)
|
||||
|
||||
lead = frappe.get_cached_doc("CRM Lead", lead)
|
||||
if frappe.get_cached_value("CRM Lead Status", lead.status, "type") == "Lost":
|
||||
frappe.throw(_("Cannot convert a lead with status {0}").format(lead.status))
|
||||
if frappe.db.exists("CRM Lead Status", "Qualified"):
|
||||
lead.db_set("status", "Qualified")
|
||||
lead.db_set("converted", 1)
|
||||
if lead.sla and frappe.db.exists("CRM Communication Status", "Replied"):
|
||||
lead.db_set("communication_status", "Replied")
|
||||
contact = lead.create_contact(existing_contact, False)
|
||||
organization = lead.create_organization(existing_organization)
|
||||
_deal = lead.create_deal(contact, organization, deal)
|
||||
return _deal
|
||||
|
||||
|
||||
def get_deal_fieldname(field, deal_meta):
|
||||
mapped_fieldname = LEAD_DEAL_FIELD_MAP.get(field.fieldname)
|
||||
if mapped_fieldname:
|
||||
return mapped_fieldname if deal_meta.has_field(mapped_fieldname) else None
|
||||
if deal_meta.has_field(field.fieldname):
|
||||
return field.fieldname
|
||||
if not is_custom_field(field):
|
||||
return None
|
||||
return get_matching_custom_deal_field(field, deal_meta)
|
||||
|
||||
|
||||
def get_matching_custom_deal_field(field, deal_meta):
|
||||
matches = [
|
||||
deal_field.fieldname for deal_field in deal_meta.fields if is_matching_custom_field(field, deal_field)
|
||||
]
|
||||
return matches[0] if len(matches) == 1 else None
|
||||
|
||||
|
||||
def is_matching_custom_field(lead_field, deal_field):
|
||||
return (
|
||||
is_custom_field(deal_field)
|
||||
and lead_field.label == deal_field.label
|
||||
and lead_field.fieldtype == deal_field.fieldtype
|
||||
)
|
||||
|
||||
|
||||
def is_custom_field(field):
|
||||
return bool(
|
||||
field.get("is_custom_field")
|
||||
or field.get("custom")
|
||||
or (field.fieldname or "").startswith("custom_")
|
||||
or field.name == f"{field.parent}-{field.fieldname}"
|
||||
)
|
||||
632
crm/fcrm/doctype/crm_lead/test_crm_lead.py
Normal file
632
crm/fcrm/doctype/crm_lead/test_crm_lead.py
Normal file
@@ -0,0 +1,632 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
||||
from frappe.desk.form.assign_to import add as assign_add
|
||||
from frappe.desk.form.assign_to import remove as assign_remove
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
from crm.fcrm.doctype.crm_lead.crm_lead import convert_to_deal
|
||||
|
||||
|
||||
class TestCRMLead(IntegrationTestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Set up test records once for all tests"""
|
||||
if not frappe.db.exists("Salutation", "Mr"):
|
||||
frappe.get_doc({"doctype": "Salutation", "salutation": "Mr"}).insert(ignore_permissions=True)
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
super().setUpClass()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
"""Clean up test records after all tests"""
|
||||
frappe.db.rollback()
|
||||
super().tearDownClass()
|
||||
|
||||
def tearDown(self):
|
||||
frappe.db.rollback()
|
||||
|
||||
def test_lead_creation_with_first_name(self):
|
||||
"""Test creating a lead with first name"""
|
||||
lead = create_lead(
|
||||
first_name="John",
|
||||
last_name="Doe",
|
||||
email="john.doe@example.com",
|
||||
mobile_no="+1234567890",
|
||||
)
|
||||
|
||||
self.assertTrue(lead.name)
|
||||
self.assertEqual(lead.first_name, "John")
|
||||
self.assertEqual(lead.last_name, "Doe")
|
||||
self.assertEqual(lead.email, "john.doe@example.com")
|
||||
self.assertEqual(lead.lead_name, "John Doe")
|
||||
|
||||
def test_lead_name_with_salutation(self):
|
||||
"""Test lead name generation with salutation"""
|
||||
lead = create_lead(
|
||||
salutation="Mr",
|
||||
first_name="James",
|
||||
middle_name="Robert",
|
||||
last_name="Smith",
|
||||
email="james.smith@example.com",
|
||||
)
|
||||
|
||||
self.assertEqual(lead.lead_name, "Mr James Robert Smith")
|
||||
self.assertEqual(lead.first_name, "James")
|
||||
self.assertEqual(lead.middle_name, "Robert")
|
||||
self.assertEqual(lead.last_name, "Smith")
|
||||
|
||||
def test_invalid_email_validation(self):
|
||||
"""Test that invalid email raises validation error"""
|
||||
with self.assertRaises(frappe.exceptions.ValidationError):
|
||||
create_lead(
|
||||
first_name="Invalid",
|
||||
email="not-an-email",
|
||||
)
|
||||
|
||||
def test_set_lead_name_scenarios(self):
|
||||
"""Test various scenarios for setting lead_name"""
|
||||
# Test 1: lead_name from organization when no first_name
|
||||
lead1 = frappe.get_doc({"doctype": "CRM Lead", "organization": "Tech Corp"})
|
||||
lead1.flags.ignore_mandatory = True
|
||||
lead1.insert()
|
||||
self.assertEqual(lead1.lead_name, "Tech Corp")
|
||||
self.assertEqual(lead1.title, "Tech Corp")
|
||||
|
||||
# Test 2: lead_name from email prefix when no first_name or organization
|
||||
lead2 = frappe.get_doc({"doctype": "CRM Lead", "email": "contact@company.com"})
|
||||
lead2.flags.ignore_mandatory = True
|
||||
lead2.insert()
|
||||
self.assertEqual(lead2.lead_name, "contact")
|
||||
self.assertEqual(lead2.title, "contact")
|
||||
|
||||
# Test 3: Throws error without first_name, organization, or email
|
||||
with self.assertRaises(frappe.exceptions.ValidationError) as context:
|
||||
create_lead()
|
||||
self.assertIn(
|
||||
"A Lead requires either a person's name or an organization's name", str(context.exception)
|
||||
)
|
||||
|
||||
# Test 4: lead_name set to 'Unnamed Lead' with ignore_mandatory flag
|
||||
lead3 = frappe.get_doc({"doctype": "CRM Lead"})
|
||||
lead3.flags.ignore_mandatory = True
|
||||
lead3.insert()
|
||||
self.assertEqual(lead3.lead_name, "Unnamed Lead")
|
||||
self.assertEqual(lead3.title, "Unnamed Lead")
|
||||
|
||||
def test_lead_title_generation(self):
|
||||
"""Test that title is set correctly"""
|
||||
lead = create_lead(
|
||||
first_name="Alice",
|
||||
organization="Acme Corp",
|
||||
email="alice@acme.com",
|
||||
)
|
||||
|
||||
# Title should be organization if provided, otherwise lead_name
|
||||
self.assertEqual(lead.title, "Acme Corp")
|
||||
|
||||
lead2 = create_lead(
|
||||
first_name="Bob",
|
||||
email="bob@example.com",
|
||||
)
|
||||
|
||||
self.assertEqual(lead2.title, "Bob")
|
||||
|
||||
def test_lead_owner_cannot_be_same_as_email(self):
|
||||
"""Test that lead owner cannot be same as lead email address"""
|
||||
|
||||
with self.assertRaises(frappe.exceptions.ValidationError) as context:
|
||||
create_lead(
|
||||
first_name="Test",
|
||||
email="crm.user1@example.com",
|
||||
lead_owner="crm.user1@example.com",
|
||||
)
|
||||
self.assertIn("Lead Owner cannot be same as the Lead Email Address", str(context.exception))
|
||||
|
||||
def test_update_lead_owner(self):
|
||||
"""Test that updating lead owner assigns and shares with the new owner"""
|
||||
# Create a lead without owner
|
||||
lead = create_lead(
|
||||
first_name="Owner",
|
||||
last_name="Test",
|
||||
email="ownertest@example.com",
|
||||
)
|
||||
|
||||
self.assertFalse(lead.lead_owner)
|
||||
|
||||
# Update lead owner
|
||||
lead.lead_owner = "Administrator"
|
||||
lead.save()
|
||||
|
||||
# Verify owner was updated
|
||||
lead.reload()
|
||||
self.assertEqual(lead.lead_owner, "Administrator")
|
||||
|
||||
# Verify agent was assigned
|
||||
assignees = lead.get_assigned_users()
|
||||
self.assertIn("Administrator", assignees)
|
||||
initial_assignees_count = len(assignees)
|
||||
|
||||
# Verify document was shared with agent
|
||||
docshare = frappe.db.exists(
|
||||
"DocShare",
|
||||
{"user": "Administrator", "share_name": lead.name, "share_doctype": "CRM Lead"},
|
||||
)
|
||||
self.assertTrue(docshare)
|
||||
|
||||
# Try to assign the same agent again - should not duplicate
|
||||
lead.assign_agent("Administrator")
|
||||
assignees_after = lead.get_assigned_users()
|
||||
self.assertEqual(len(assignees_after), initial_assignees_count)
|
||||
self.assertIn("Administrator", assignees_after)
|
||||
|
||||
# Share with same agent again - should not duplicate docshare
|
||||
initial_docshares = frappe.get_all(
|
||||
"DocShare",
|
||||
filters={"share_name": lead.name, "share_doctype": "CRM Lead"},
|
||||
)
|
||||
initial_docshare_count = len(initial_docshares)
|
||||
lead.share_with_agent("Administrator")
|
||||
after_docshares = frappe.get_all(
|
||||
"DocShare",
|
||||
filters={"share_name": lead.name, "share_doctype": "CRM Lead"},
|
||||
)
|
||||
self.assertEqual(len(after_docshares), initial_docshare_count)
|
||||
|
||||
lead.lead_owner = "crm.user1@example.com"
|
||||
lead.save()
|
||||
lead.reload()
|
||||
|
||||
# Verify new owner is assigned and shared
|
||||
self.assertEqual(lead.lead_owner, "crm.user1@example.com")
|
||||
new_docshare = frappe.db.exists(
|
||||
"DocShare",
|
||||
{"user": "crm.user1@example.com", "share_name": lead.name, "share_doctype": "CRM Lead"},
|
||||
)
|
||||
self.assertTrue(new_docshare)
|
||||
|
||||
# Verify old owner's share was removed
|
||||
old_docshare = frappe.db.exists(
|
||||
"DocShare",
|
||||
{"user": "Administrator", "share_name": lead.name, "share_doctype": "CRM Lead"},
|
||||
)
|
||||
self.assertFalse(old_docshare)
|
||||
|
||||
def test_lead_creation_with_owner(self):
|
||||
"""Test creating a lead with lead owner assigns agent on insert"""
|
||||
lead = create_lead(
|
||||
first_name="Owned",
|
||||
last_name="Lead",
|
||||
email="ownedlead@example.com",
|
||||
lead_owner="Administrator",
|
||||
)
|
||||
|
||||
# Verify lead was created with owner
|
||||
self.assertEqual(lead.lead_owner, "Administrator")
|
||||
|
||||
# Verify agent was assigned during after_insert
|
||||
assignees = lead.get_assigned_users()
|
||||
self.assertIn("Administrator", assignees)
|
||||
|
||||
def test_create_contact_from_lead(self):
|
||||
"""Test creating a contact from lead data"""
|
||||
lead = create_lead(
|
||||
first_name="Michael",
|
||||
last_name="Jordan",
|
||||
email="mj@bulls.com",
|
||||
mobile_no="+1234567890",
|
||||
phone="+0987654321",
|
||||
organization="Chicago Bulls",
|
||||
job_title="Player",
|
||||
salutation="Mr",
|
||||
)
|
||||
|
||||
contact_name = lead.create_contact()
|
||||
self.assertTrue(contact_name)
|
||||
|
||||
contact = frappe.get_doc("Contact", contact_name)
|
||||
self.assertEqual(contact.first_name, "Michael")
|
||||
self.assertEqual(contact.last_name, "Jordan")
|
||||
self.assertEqual(contact.email_id, "mj@bulls.com")
|
||||
self.assertEqual(contact.mobile_no, "+1234567890")
|
||||
self.assertEqual(contact.company_name, "Chicago Bulls")
|
||||
self.assertEqual(contact.designation, "Player")
|
||||
|
||||
def test_create_organization_from_lead(self):
|
||||
"""Test creating an organization from lead data"""
|
||||
lead = create_lead(
|
||||
first_name="Steve",
|
||||
last_name="Jobs",
|
||||
email="steve@apple.com",
|
||||
organization="Apple Inc",
|
||||
website="https://apple.com",
|
||||
annual_revenue=1000000,
|
||||
)
|
||||
|
||||
org_name = lead.create_organization()
|
||||
self.assertTrue(org_name)
|
||||
|
||||
org = frappe.get_doc("CRM Organization", org_name)
|
||||
self.assertEqual(org.organization_name, "Apple Inc")
|
||||
self.assertEqual(org.website, "https://apple.com")
|
||||
self.assertEqual(org.annual_revenue, 1000000)
|
||||
|
||||
def test_create_organization_with_existing_org(self):
|
||||
"""Test that existing organization is reused instead of creating duplicate"""
|
||||
# Create first lead with organization
|
||||
lead1 = create_lead(
|
||||
first_name="Person",
|
||||
last_name="One",
|
||||
email="person1@example.com",
|
||||
organization="Existing Corp",
|
||||
)
|
||||
org_name1 = lead1.create_organization()
|
||||
|
||||
# Create second lead with same organization
|
||||
lead2 = create_lead(
|
||||
first_name="Person",
|
||||
last_name="Two",
|
||||
email="person2@example.com",
|
||||
organization="Existing Corp",
|
||||
)
|
||||
org_name2 = lead2.create_organization()
|
||||
|
||||
# Should return the same organization
|
||||
self.assertEqual(org_name1, org_name2)
|
||||
|
||||
def test_contact_exists_with_email(self):
|
||||
"""Test checking if contact already exists with same email"""
|
||||
lead1 = create_lead(
|
||||
first_name="John",
|
||||
last_name="Existing",
|
||||
email="existing@example.com",
|
||||
mobile_no="+1111111111",
|
||||
)
|
||||
lead1.create_contact()
|
||||
|
||||
lead2 = create_lead(
|
||||
first_name="Jane",
|
||||
last_name="Duplicate",
|
||||
email="existing@example.com",
|
||||
mobile_no="+2222222222",
|
||||
)
|
||||
|
||||
# Should throw error as contact with same email exists
|
||||
with self.assertRaises(frappe.exceptions.ValidationError) as context:
|
||||
lead2.create_contact()
|
||||
self.assertIn("Contact already exists", str(context.exception))
|
||||
|
||||
def test_contact_not_reused_when_only_phone_matches(self):
|
||||
"""A different person sharing only a phone must not be reused as the contact"""
|
||||
lead1 = create_lead(
|
||||
first_name="Jane",
|
||||
last_name="Doe",
|
||||
email="frappe@example.com",
|
||||
mobile_no="+910000000099",
|
||||
)
|
||||
existing_contact = lead1.create_contact()
|
||||
|
||||
# Different person, no email, but the same mobile number
|
||||
lead2 = create_lead(
|
||||
first_name="John",
|
||||
last_name="Doe",
|
||||
mobile_no="+910000000099",
|
||||
)
|
||||
contact_name = lead2.create_contact()
|
||||
|
||||
self.assertNotEqual(contact_name, existing_contact)
|
||||
contact = frappe.get_doc("Contact", contact_name)
|
||||
self.assertEqual(contact.first_name, "John")
|
||||
self.assertEqual(contact.last_name, "Doe")
|
||||
|
||||
def test_convert_lead_to_deal(self):
|
||||
"""Test converting a lead to a deal with new contact and organization"""
|
||||
lead = create_lead(
|
||||
first_name="Deal",
|
||||
last_name="Maker",
|
||||
email="dealmaker@example.com",
|
||||
mobile_no="+1234567890",
|
||||
organization="Deal Corp",
|
||||
annual_revenue=500000,
|
||||
)
|
||||
|
||||
# Convert lead to deal
|
||||
deal_name = lead.convert_to_deal()
|
||||
self.assertTrue(deal_name)
|
||||
|
||||
# Verify lead is marked as converted
|
||||
lead.reload()
|
||||
self.assertEqual(lead.converted, 1)
|
||||
|
||||
# Verify deal was created
|
||||
deal = frappe.get_doc("CRM Deal", deal_name)
|
||||
self.assertEqual(deal.first_name, "Deal")
|
||||
self.assertEqual(deal.last_name, "Maker")
|
||||
self.assertEqual(deal.lead, lead.name)
|
||||
self.assertTrue(deal.organization)
|
||||
|
||||
# Verify contact was created
|
||||
self.assertTrue(len(deal.contacts) > 0)
|
||||
contact_name = deal.contacts[0].contact
|
||||
contact = frappe.get_doc("Contact", contact_name)
|
||||
self.assertEqual(contact.first_name, "Deal")
|
||||
self.assertEqual(contact.last_name, "Maker")
|
||||
self.assertEqual(contact.email_id, "dealmaker@example.com")
|
||||
|
||||
# Verify organization was created
|
||||
org = frappe.get_doc("CRM Organization", deal.organization)
|
||||
self.assertEqual(org.organization_name, "Deal Corp")
|
||||
self.assertEqual(org.annual_revenue, 500000)
|
||||
|
||||
def test_convert_lead_with_existing_contact_and_org(self):
|
||||
"""Test converting lead with existing contact and organization"""
|
||||
# Create existing contact
|
||||
existing_contact = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Contact",
|
||||
"first_name": "Existing",
|
||||
"last_name": "Contact",
|
||||
"email_ids": [{"email_id": "existing@contact.com", "is_primary": 1}],
|
||||
}
|
||||
).insert()
|
||||
|
||||
# Create existing organization
|
||||
existing_org = frappe.get_doc(
|
||||
{
|
||||
"doctype": "CRM Organization",
|
||||
"organization_name": "Existing Org Inc",
|
||||
"annual_revenue": 2000000,
|
||||
}
|
||||
).insert()
|
||||
|
||||
# Create lead
|
||||
lead = create_lead(
|
||||
first_name="Existing",
|
||||
last_name="Contact",
|
||||
email="existing@contact.com",
|
||||
organization="Existing Org Inc",
|
||||
)
|
||||
|
||||
# Convert lead using existing contact and org
|
||||
deal_name = lead.convert_to_deal()
|
||||
|
||||
# Verify deal was created with existing records
|
||||
deal = frappe.get_doc("CRM Deal", deal_name)
|
||||
self.assertTrue(deal.name)
|
||||
|
||||
# Verify existing contact is linked
|
||||
self.assertTrue(len(deal.contacts) > 0)
|
||||
self.assertEqual(deal.contacts[0].contact, existing_contact.name)
|
||||
|
||||
# Verify existing organization is linked
|
||||
self.assertEqual(deal.organization, existing_org.name)
|
||||
|
||||
def test_convert_to_deal_api(self):
|
||||
"""Test convert_to_deal API function"""
|
||||
lead = create_lead(
|
||||
first_name="API",
|
||||
last_name="Test",
|
||||
email="apitest@example.com",
|
||||
mobile_no="+5555555555",
|
||||
organization="API Test Corp",
|
||||
annual_revenue=300000,
|
||||
)
|
||||
|
||||
# Convert lead to deal using API
|
||||
deal_name = convert_to_deal(lead=lead.name)
|
||||
self.assertTrue(deal_name)
|
||||
|
||||
# Verify lead is marked as converted
|
||||
lead.reload()
|
||||
self.assertEqual(lead.converted, 1)
|
||||
|
||||
# Verify deal was created
|
||||
deal = frappe.get_doc("CRM Deal", deal_name)
|
||||
self.assertEqual(deal.first_name, "API")
|
||||
self.assertEqual(deal.last_name, "Test")
|
||||
self.assertEqual(deal.lead, lead.name)
|
||||
self.assertTrue(deal.organization)
|
||||
|
||||
# Verify contact was created
|
||||
self.assertTrue(len(deal.contacts) > 0)
|
||||
contact_name = deal.contacts[0].contact
|
||||
contact = frappe.get_doc("Contact", contact_name)
|
||||
self.assertEqual(contact.first_name, "API")
|
||||
self.assertEqual(contact.email_id, "apitest@example.com")
|
||||
|
||||
# Verify organization was created
|
||||
org = frappe.get_doc("CRM Organization", deal.organization)
|
||||
self.assertEqual(org.organization_name, "API Test Corp")
|
||||
self.assertEqual(org.annual_revenue, 300000)
|
||||
|
||||
def test_cannot_convert_lost_lead_to_deal(self):
|
||||
"""Lost leads cannot be converted to deals."""
|
||||
if not frappe.db.exists("CRM Lost Reason", "Not interested"):
|
||||
frappe.get_doc({"doctype": "CRM Lost Reason", "lost_reason": "Not interested"}).insert()
|
||||
|
||||
for status in ("Junk", "Unqualified"):
|
||||
with self.subTest(status=status):
|
||||
lead = create_lead(first_name=status, status=status, lost_reason="Not interested")
|
||||
with self.assertRaisesRegex(
|
||||
frappe.ValidationError, f"Cannot convert a lead with status {status}"
|
||||
):
|
||||
convert_to_deal(lead=lead.name)
|
||||
lead.reload()
|
||||
self.assertFalse(lead.converted)
|
||||
|
||||
def test_convert_to_deal_api_with_existing_records(self):
|
||||
"""Test convert_to_deal API with existing contact and organization parameters"""
|
||||
# Create existing contact
|
||||
existing_contact = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Contact",
|
||||
"first_name": "API",
|
||||
"last_name": "Contact",
|
||||
"email_ids": [{"email_id": "apicontact@example.com", "is_primary": 1}],
|
||||
}
|
||||
).insert()
|
||||
|
||||
# Create existing organization
|
||||
existing_org = frappe.get_doc(
|
||||
{
|
||||
"doctype": "CRM Organization",
|
||||
"organization_name": "API Org Ltd",
|
||||
"annual_revenue": 1500000,
|
||||
}
|
||||
).insert()
|
||||
|
||||
# Create lead
|
||||
lead = create_lead(
|
||||
first_name="API",
|
||||
last_name="Lead",
|
||||
email="apilead@example.com",
|
||||
organization="Should Be Replaced",
|
||||
)
|
||||
|
||||
# Convert lead using API with existing records
|
||||
deal_name = convert_to_deal(
|
||||
lead=lead.name,
|
||||
existing_contact=existing_contact.name,
|
||||
existing_organization=existing_org.name,
|
||||
)
|
||||
|
||||
# Verify deal was created with existing records
|
||||
deal = frappe.get_doc("CRM Deal", deal_name)
|
||||
self.assertTrue(deal.name)
|
||||
|
||||
# Verify existing contact is linked
|
||||
self.assertTrue(len(deal.contacts) > 0)
|
||||
self.assertEqual(deal.contacts[0].contact, existing_contact.name)
|
||||
|
||||
# Verify existing organization is linked
|
||||
self.assertEqual(deal.organization, existing_org.name)
|
||||
|
||||
def test_lead_fields_copied_to_deal(self):
|
||||
"""Test that relevant lead fields are copied to deal during conversion"""
|
||||
lead = create_lead(
|
||||
first_name="Copy",
|
||||
last_name="Test",
|
||||
email="copytest@example.com",
|
||||
mobile_no="+9999999999",
|
||||
organization="Copy Test Inc",
|
||||
website="https://copytest.com",
|
||||
annual_revenue=750000,
|
||||
job_title="CEO",
|
||||
)
|
||||
|
||||
deal_name = lead.convert_to_deal()
|
||||
deal = frappe.get_doc("CRM Deal", deal_name)
|
||||
|
||||
# Verify fields are copied
|
||||
self.assertEqual(deal.first_name, "Copy")
|
||||
self.assertEqual(deal.last_name, "Test")
|
||||
self.assertEqual(deal.website, "https://copytest.com")
|
||||
self.assertEqual(deal.annual_revenue, 750000)
|
||||
self.assertEqual(deal.job_title, "CEO")
|
||||
|
||||
def test_no_of_employees_propagated_to_organization_on_conversion(self):
|
||||
"""Test that no_of_employees on lead is copied to the organization created on conversion"""
|
||||
lead = create_lead(
|
||||
first_name="Employees",
|
||||
last_name="Test",
|
||||
email="employeestest@example.com",
|
||||
organization="Employees Test Inc",
|
||||
no_of_employees="201-500",
|
||||
)
|
||||
|
||||
deal_name = lead.convert_to_deal()
|
||||
deal = frappe.get_doc("CRM Deal", deal_name)
|
||||
|
||||
org = frappe.get_doc("CRM Organization", deal.organization)
|
||||
self.assertEqual(org.no_of_employees, "201-500")
|
||||
|
||||
def test_custom_fields_copied_to_deal_by_label(self):
|
||||
"""Custom Lead fields map to matching custom Deal fields."""
|
||||
create_lead_deal_custom_fields()
|
||||
lead = create_lead(
|
||||
first_name="Custom",
|
||||
organization="Custom Field Inc",
|
||||
custom_lead_conversion_region="North",
|
||||
)
|
||||
|
||||
deal_name = lead.convert_to_deal()
|
||||
deal = frappe.get_doc("CRM Deal", deal_name)
|
||||
|
||||
self.assertEqual(deal.custom_deal_conversion_region, "North")
|
||||
|
||||
def test_assignees_transferred_on_conversion(self):
|
||||
"""Test that additional assignees are transferred from lead to deal on conversion"""
|
||||
lead = create_lead(
|
||||
first_name="Transfer",
|
||||
lead_owner="Administrator",
|
||||
)
|
||||
|
||||
lead.assign_agent("crm.user1@example.com")
|
||||
|
||||
lead_assignees = lead.get_assigned_users()
|
||||
|
||||
self.assertIn("Administrator", lead_assignees)
|
||||
self.assertIn("crm.user1@example.com", lead_assignees)
|
||||
|
||||
deal_name = lead.convert_to_deal()
|
||||
deal = frappe.get_doc("CRM Deal", deal_name)
|
||||
|
||||
deal_assignees = deal.get_assigned_users()
|
||||
self.assertIn("Administrator", deal_assignees)
|
||||
self.assertIn("crm.user1@example.com", deal_assignees)
|
||||
|
||||
def test_owner_cleared_on_unassign(self):
|
||||
"""Unassigning the current owner clears lead_owner"""
|
||||
lead = create_lead(first_name="Owner", lead_owner="crm.user1@example.com")
|
||||
self.assertEqual(lead.lead_owner, "crm.user1@example.com")
|
||||
|
||||
assign_remove("CRM Lead", lead.name, "crm.user1@example.com")
|
||||
|
||||
self.assertIsNone(frappe.db.get_value("CRM Lead", lead.name, "lead_owner"))
|
||||
|
||||
def test_assignment_overrides_owner(self):
|
||||
"""A new assignment takes ownership even when an owner already exists (newest owns)."""
|
||||
lead = create_lead(first_name="Override", lead_owner="crm.user1@example.com")
|
||||
assign_add({"assign_to": ["crm.user2@example.com"], "doctype": "CRM Lead", "name": lead.name})
|
||||
self.assertEqual(frappe.db.get_value("CRM Lead", lead.name, "lead_owner"), "crm.user2@example.com")
|
||||
|
||||
def test_negative_currency_fields_rejected(self):
|
||||
"""Test that Currency fields reject negative values"""
|
||||
for fieldname in ("annual_revenue", "total", "net_total"):
|
||||
with self.subTest(fieldname=fieldname), self.assertRaises(frappe.NonNegativeError):
|
||||
create_lead(
|
||||
first_name="Negative",
|
||||
email=f"negative.{fieldname}@example.com",
|
||||
**{fieldname: -100},
|
||||
)
|
||||
|
||||
|
||||
def create_lead(**kwargs):
|
||||
"""Helper function to create a CRM Lead for testing"""
|
||||
data = {"doctype": "CRM Lead"}
|
||||
data.update(kwargs)
|
||||
return frappe.get_doc(data).insert()
|
||||
|
||||
|
||||
def create_lead_deal_custom_fields():
|
||||
create_custom_fields(
|
||||
{
|
||||
"CRM Lead": [conversion_region_field("custom_lead_conversion_region")],
|
||||
"CRM Deal": [conversion_region_field("custom_deal_conversion_region")],
|
||||
},
|
||||
ignore_validate=True,
|
||||
)
|
||||
frappe.clear_cache(doctype="CRM Lead")
|
||||
frappe.clear_cache(doctype="CRM Deal")
|
||||
|
||||
|
||||
def conversion_region_field(fieldname):
|
||||
return {
|
||||
"fieldname": fieldname,
|
||||
"fieldtype": "Data",
|
||||
"insert_after": "source",
|
||||
"label": "Conversion Region",
|
||||
}
|
||||
408
crm/fcrm/doctype/crm_lead/test_records.json
Normal file
408
crm/fcrm/doctype/crm_lead/test_records.json
Normal file
@@ -0,0 +1,408 @@
|
||||
[
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "John",
|
||||
"last_name": "Smith",
|
||||
"email": "john.smith@example.com",
|
||||
"mobile_no": "+1-555-0101",
|
||||
"organization": "Tech Innovations Inc",
|
||||
"job_title": "CTO",
|
||||
"source": "Website",
|
||||
"status": "New Lead",
|
||||
"lead_owner": "Administrator"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Jane",
|
||||
"last_name": "Doe",
|
||||
"email": "jane.doe@example.com",
|
||||
"mobile_no": "+1-555-0102",
|
||||
"organization": "Global Solutions Ltd",
|
||||
"job_title": "VP Sales",
|
||||
"source": "Referral",
|
||||
"status": "New Lead",
|
||||
"lead_owner": "Administrator"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Robert",
|
||||
"last_name": "Johnson",
|
||||
"email": "robert.johnson@example.com",
|
||||
"mobile_no": "+1-555-0103",
|
||||
"organization": "Digital Marketing Co",
|
||||
"job_title": "Marketing Director",
|
||||
"source": "Cold Call",
|
||||
"status": "New Lead",
|
||||
"lead_owner": "crm.user1@example.com"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Emily",
|
||||
"last_name": "Williams",
|
||||
"email": "emily.williams@example.com",
|
||||
"mobile_no": "+1-555-0104",
|
||||
"organization": "Enterprise Systems",
|
||||
"job_title": "IT Manager",
|
||||
"source": "Email Campaign",
|
||||
"status": "New Lead",
|
||||
"lead_owner": "Administrator"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Michael",
|
||||
"last_name": "Brown",
|
||||
"email": "michael.brown@example.com",
|
||||
"mobile_no": "+1-555-0105",
|
||||
"organization": "Cloud Services Inc",
|
||||
"job_title": "CEO",
|
||||
"source": "Social Media",
|
||||
"status": "New Lead",
|
||||
"lead_owner": "crm.user1@example.com"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Sarah",
|
||||
"last_name": "Davis",
|
||||
"email": "sarah.davis@example.com",
|
||||
"mobile_no": "+1-555-0106",
|
||||
"organization": "Data Analytics Corp",
|
||||
"job_title": "Data Scientist",
|
||||
"source": "Website",
|
||||
"status": "New Lead",
|
||||
"lead_owner": "Administrator"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "David",
|
||||
"last_name": "Miller",
|
||||
"email": "david.miller@example.com",
|
||||
"mobile_no": "+1-555-0107",
|
||||
"organization": "Startup Ventures",
|
||||
"job_title": "Founder",
|
||||
"source": "Referral",
|
||||
"status": "New Lead",
|
||||
"lead_owner": "crm.user1@example.com"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Lisa",
|
||||
"last_name": "Wilson",
|
||||
"email": "lisa.wilson@example.com",
|
||||
"mobile_no": "+1-555-0108",
|
||||
"organization": "Consulting Group",
|
||||
"job_title": "Senior Consultant",
|
||||
"source": "Cold Call",
|
||||
"status": "New Lead",
|
||||
"lead_owner": "Administrator"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "James",
|
||||
"last_name": "Moore",
|
||||
"email": "james.moore@example.com",
|
||||
"mobile_no": "+1-555-0109",
|
||||
"organization": "Innovation Labs",
|
||||
"job_title": "Research Lead",
|
||||
"source": "Email Campaign",
|
||||
"status": "New Lead"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Jennifer",
|
||||
"last_name": "Taylor",
|
||||
"email": "jennifer.taylor@example.com",
|
||||
"mobile_no": "+1-555-0110",
|
||||
"organization": "Business Solutions",
|
||||
"job_title": "Operations Manager",
|
||||
"source": "Social Media",
|
||||
"status": "New Lead",
|
||||
"lead_owner": "Administrator"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "William",
|
||||
"last_name": "Anderson",
|
||||
"email": "william.anderson@example.com",
|
||||
"mobile_no": "+1-555-0111",
|
||||
"organization": "Tech Corp",
|
||||
"job_title": "Product Manager",
|
||||
"source": "Website",
|
||||
"status": "New Lead"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Mary",
|
||||
"last_name": "Thomas",
|
||||
"email": "mary.thomas@example.com",
|
||||
"mobile_no": "+1-555-0112",
|
||||
"organization": "Software Systems",
|
||||
"job_title": "Engineering Lead",
|
||||
"source": "Referral",
|
||||
"status": "New Lead",
|
||||
"lead_owner": "Administrator"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Richard",
|
||||
"last_name": "Jackson",
|
||||
"email": "richard.jackson@example.com",
|
||||
"mobile_no": "+1-555-0113",
|
||||
"organization": "Digital Solutions",
|
||||
"job_title": "Business Analyst",
|
||||
"source": "Cold Call",
|
||||
"status": "New Lead"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Patricia",
|
||||
"last_name": "White",
|
||||
"email": "patricia.white@example.com",
|
||||
"mobile_no": "+1-555-0114",
|
||||
"organization": "Enterprise Tech",
|
||||
"job_title": "Project Manager",
|
||||
"source": "Email Campaign",
|
||||
"status": "New Lead",
|
||||
"lead_owner": "Administrator"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Charles",
|
||||
"last_name": "Harris",
|
||||
"email": "charles.harris@example.com",
|
||||
"mobile_no": "+1-555-0115",
|
||||
"organization": "Cloud Innovations",
|
||||
"job_title": "Solutions Architect",
|
||||
"source": "Social Media",
|
||||
"status": "New Lead"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Linda",
|
||||
"last_name": "Martin",
|
||||
"email": "linda.martin@example.com",
|
||||
"mobile_no": "+1-555-0116",
|
||||
"organization": "AI Systems",
|
||||
"job_title": "AI Engineer",
|
||||
"source": "Website",
|
||||
"status": "New Lead",
|
||||
"lead_owner": "Administrator"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Thomas",
|
||||
"last_name": "Thompson",
|
||||
"email": "thomas.thompson@example.com",
|
||||
"mobile_no": "+1-555-0117",
|
||||
"organization": "Mobile Apps Inc",
|
||||
"job_title": "Mobile Lead",
|
||||
"source": "Referral",
|
||||
"status": "New Lead"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Barbara",
|
||||
"last_name": "Garcia",
|
||||
"email": "barbara.garcia@example.com",
|
||||
"mobile_no": "+1-555-0118",
|
||||
"organization": "Security Solutions",
|
||||
"job_title": "Security Officer",
|
||||
"source": "Cold Call",
|
||||
"status": "New Lead",
|
||||
"lead_owner": "Administrator"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Christopher",
|
||||
"last_name": "Martinez",
|
||||
"email": "christopher.martinez@example.com",
|
||||
"mobile_no": "+1-555-0119",
|
||||
"organization": "Data Services",
|
||||
"job_title": "Data Engineer",
|
||||
"source": "Email Campaign",
|
||||
"status": "New Lead"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Susan",
|
||||
"last_name": "Robinson",
|
||||
"email": "susan.robinson@example.com",
|
||||
"mobile_no": "+1-555-0120",
|
||||
"organization": "Analytics Corp",
|
||||
"job_title": "Analytics Manager",
|
||||
"source": "Social Media",
|
||||
"status": "New Lead",
|
||||
"lead_owner": "Administrator"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Daniel",
|
||||
"last_name": "Clark",
|
||||
"email": "daniel.clark@example.com",
|
||||
"mobile_no": "+1-555-0121",
|
||||
"organization": "Platform Tech",
|
||||
"job_title": "Platform Engineer",
|
||||
"source": "Website",
|
||||
"status": "New Lead"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Karen",
|
||||
"last_name": "Rodriguez",
|
||||
"email": "karen.rodriguez@example.com",
|
||||
"mobile_no": "+1-555-0122",
|
||||
"organization": "IoT Solutions",
|
||||
"job_title": "IoT Specialist",
|
||||
"source": "Referral",
|
||||
"status": "New Lead",
|
||||
"lead_owner": "Administrator"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Matthew",
|
||||
"last_name": "Lewis",
|
||||
"email": "matthew.lewis@example.com",
|
||||
"mobile_no": "+1-555-0123",
|
||||
"organization": "Blockchain Corp",
|
||||
"job_title": "Blockchain Developer",
|
||||
"source": "Cold Call",
|
||||
"status": "New Lead"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Nancy",
|
||||
"last_name": "Lee",
|
||||
"email": "nancy.lee@example.com",
|
||||
"mobile_no": "+1-555-0124",
|
||||
"organization": "DevOps Services",
|
||||
"job_title": "DevOps Lead",
|
||||
"source": "Email Campaign",
|
||||
"status": "New Lead",
|
||||
"lead_owner": "Administrator"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Paul",
|
||||
"last_name": "Walker",
|
||||
"email": "paul.walker@example.com",
|
||||
"mobile_no": "+1-555-0125",
|
||||
"organization": "Network Systems",
|
||||
"job_title": "Network Admin",
|
||||
"source": "Social Media",
|
||||
"status": "New Lead"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Betty",
|
||||
"last_name": "Hall",
|
||||
"email": "betty.hall@example.com",
|
||||
"mobile_no": "+1-555-0126",
|
||||
"organization": "Database Solutions",
|
||||
"job_title": "DBA",
|
||||
"source": "Website",
|
||||
"status": "New Lead",
|
||||
"lead_owner": "Administrator"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Mark",
|
||||
"last_name": "Allen",
|
||||
"email": "mark.allen@example.com",
|
||||
"mobile_no": "+1-555-0127",
|
||||
"organization": "Web Services",
|
||||
"job_title": "Web Developer",
|
||||
"source": "Referral",
|
||||
"status": "New Lead"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Helen",
|
||||
"last_name": "Young",
|
||||
"email": "helen.young@example.com",
|
||||
"mobile_no": "+1-555-0128",
|
||||
"organization": "API Systems",
|
||||
"job_title": "API Designer",
|
||||
"source": "Cold Call",
|
||||
"status": "New Lead",
|
||||
"lead_owner": "Administrator"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Donald",
|
||||
"last_name": "Hernandez",
|
||||
"email": "donald.hernandez@example.com",
|
||||
"mobile_no": "+1-555-0129",
|
||||
"organization": "Testing Labs",
|
||||
"job_title": "QA Manager",
|
||||
"source": "Email Campaign",
|
||||
"status": "New Lead"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Sandra",
|
||||
"last_name": "King",
|
||||
"email": "sandra.king@example.com",
|
||||
"mobile_no": "+1-555-0130",
|
||||
"organization": "UX Design Co",
|
||||
"job_title": "UX Designer",
|
||||
"source": "Social Media",
|
||||
"status": "New Lead",
|
||||
"lead_owner": "Administrator"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "George",
|
||||
"last_name": "Wright",
|
||||
"email": "george.wright@example.com",
|
||||
"mobile_no": "+1-555-0131",
|
||||
"organization": "Frontend Systems",
|
||||
"job_title": "Frontend Lead",
|
||||
"source": "Website",
|
||||
"status": "New Lead"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Ashley",
|
||||
"last_name": "Lopez",
|
||||
"email": "ashley.lopez@example.com",
|
||||
"mobile_no": "+1-555-0132",
|
||||
"organization": "Backend Solutions",
|
||||
"job_title": "Backend Engineer",
|
||||
"source": "Referral",
|
||||
"status": "New Lead",
|
||||
"lead_owner": "Administrator"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Kevin",
|
||||
"last_name": "Hill",
|
||||
"email": "kevin.hill@example.com",
|
||||
"mobile_no": "+1-555-0133",
|
||||
"organization": "Full Stack Corp",
|
||||
"job_title": "Full Stack Developer",
|
||||
"source": "Cold Call",
|
||||
"status": "New Lead"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Donna",
|
||||
"last_name": "Scott",
|
||||
"email": "donna.scott@example.com",
|
||||
"mobile_no": "+1-555-0134",
|
||||
"organization": "Architecture Firm",
|
||||
"job_title": "Enterprise Architect",
|
||||
"source": "Email Campaign",
|
||||
"status": "New Lead",
|
||||
"lead_owner": "Administrator"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
"first_name": "Steven",
|
||||
"last_name": "Green",
|
||||
"email": "steven.green@example.com",
|
||||
"mobile_no": "+1-555-0135",
|
||||
"organization": "Microservices Inc",
|
||||
"job_title": "Microservices Lead",
|
||||
"source": "Social Media",
|
||||
"status": "New Lead"
|
||||
}
|
||||
]
|
||||
0
crm/fcrm/doctype/crm_lead_source/__init__.py
Normal file
0
crm/fcrm/doctype/crm_lead_source/__init__.py
Normal file
8
crm/fcrm/doctype/crm_lead_source/crm_lead_source.js
Normal file
8
crm/fcrm/doctype/crm_lead_source/crm_lead_source.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("CRM Lead Source", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
80
crm/fcrm/doctype/crm_lead_source/crm_lead_source.json
Normal file
80
crm/fcrm/doctype/crm_lead_source/crm_lead_source.json
Normal file
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_import": 1,
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:source_name",
|
||||
"creation": "2023-07-24 19:47:01.063203",
|
||||
"default_view": "List",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"source_name",
|
||||
"details"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "source_name",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Source Name",
|
||||
"reqd": 1,
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "details",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "Details"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-03-05 13:05:26.007219",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Lead Source",
|
||||
"naming_rule": "By fieldname",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales User",
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"translated_doctype": 1
|
||||
}
|
||||
21
crm/fcrm/doctype/crm_lead_source/crm_lead_source.py
Normal file
21
crm/fcrm/doctype/crm_lead_source/crm_lead_source.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class CRMLeadSource(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
details: DF.TextEditor | None
|
||||
source_name: DF.Data
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
9
crm/fcrm/doctype/crm_lead_source/test_crm_lead_source.py
Normal file
9
crm/fcrm/doctype/crm_lead_source/test_crm_lead_source.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestCRMLeadSource(UnitTestCase):
|
||||
pass
|
||||
22
crm/fcrm/doctype/crm_lead_source/test_records.json
Normal file
22
crm/fcrm/doctype/crm_lead_source/test_records.json
Normal file
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{
|
||||
"doctype": "CRM Lead Source",
|
||||
"source_name": "Website"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead Source",
|
||||
"source_name": "Referral"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead Source",
|
||||
"source_name": "Cold Call"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead Source",
|
||||
"source_name": "Email Campaign"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lead Source",
|
||||
"source_name": "Social Media"
|
||||
}
|
||||
]
|
||||
0
crm/fcrm/doctype/crm_lead_status/__init__.py
Normal file
0
crm/fcrm/doctype/crm_lead_status/__init__.py
Normal file
8
crm/fcrm/doctype/crm_lead_status/crm_lead_status.js
Normal file
8
crm/fcrm/doctype/crm_lead_status/crm_lead_status.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("CRM Lead Status", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
103
crm/fcrm/doctype/crm_lead_status/crm_lead_status.json
Normal file
103
crm/fcrm/doctype/crm_lead_status/crm_lead_status.json
Normal file
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:lead_status",
|
||||
"creation": "2023-11-29 11:09:53.678414",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"lead_status",
|
||||
"type",
|
||||
"column_break_qwtp",
|
||||
"color",
|
||||
"position"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"default": "gray",
|
||||
"fieldname": "color",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Color",
|
||||
"options": "black\ngray\nblue\ngreen\nred\npink\norange\namber\nyellow\ncyan\nteal\nviolet\npurple"
|
||||
},
|
||||
{
|
||||
"fieldname": "lead_status",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Status",
|
||||
"reqd": 1,
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "position",
|
||||
"fieldtype": "Int",
|
||||
"in_list_view": 1,
|
||||
"label": "Position"
|
||||
},
|
||||
{
|
||||
"default": "Open",
|
||||
"fieldname": "type",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Type",
|
||||
"options": "Open\nOngoing\nOn Hold\nWon\nLost"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_qwtp",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-03-05 12:42:24.932212",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Lead Status",
|
||||
"naming_rule": "By fieldname",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales User",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"translated_doctype": 1
|
||||
}
|
||||
37
crm/fcrm/doctype/crm_lead_status/crm_lead_status.py
Normal file
37
crm/fcrm/doctype/crm_lead_status/crm_lead_status.py
Normal file
@@ -0,0 +1,37 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class CRMLeadStatus(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
color: DF.Literal[
|
||||
"black",
|
||||
"gray",
|
||||
"blue",
|
||||
"green",
|
||||
"red",
|
||||
"pink",
|
||||
"orange",
|
||||
"amber",
|
||||
"yellow",
|
||||
"cyan",
|
||||
"teal",
|
||||
"violet",
|
||||
"purple",
|
||||
]
|
||||
lead_status: DF.Data
|
||||
position: DF.Int
|
||||
type: DF.Literal["Open", "Ongoing", "On Hold", "Won", "Lost"]
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
9
crm/fcrm/doctype/crm_lead_status/test_crm_lead_status.py
Normal file
9
crm/fcrm/doctype/crm_lead_status/test_crm_lead_status.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestCRMLeadStatus(UnitTestCase):
|
||||
pass
|
||||
6
crm/fcrm/doctype/crm_lead_status/test_records.json
Normal file
6
crm/fcrm/doctype/crm_lead_status/test_records.json
Normal file
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{
|
||||
"doctype": "CRM Lead Status",
|
||||
"lead_status": "New Lead"
|
||||
}
|
||||
]
|
||||
0
crm/fcrm/doctype/crm_lost_reason/__init__.py
Normal file
0
crm/fcrm/doctype/crm_lost_reason/__init__.py
Normal file
8
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.js
Normal file
8
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("CRM Lost Reason", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
80
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.json
Normal file
80
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.json
Normal file
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:lost_reason",
|
||||
"creation": "2025-06-30 16:51:31.082360",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"lost_reason",
|
||||
"description"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "lost_reason",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Lost Reason",
|
||||
"reqd": 1,
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "description",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "Description"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-03-05 13:04:31.907824",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Lost Reason",
|
||||
"naming_rule": "By fieldname",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales User",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"translated_doctype": 1
|
||||
}
|
||||
21
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.py
Normal file
21
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class CRMLostReason(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
description: DF.TextEditor | None
|
||||
lost_reason: DF.Data
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
29
crm/fcrm/doctype/crm_lost_reason/test_crm_lost_reason.py
Normal file
29
crm/fcrm/doctype/crm_lost_reason/test_crm_lost_reason.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
|
||||
# On IntegrationTestCase, the doctype test records and all
|
||||
# link-field test record dependencies are recursively loaded
|
||||
# Use these module variables to add/remove to/from that list
|
||||
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
|
||||
|
||||
class UnitTestCRMLostReason(UnitTestCase):
|
||||
"""
|
||||
Unit tests for CRMLostReason.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class IntegrationTestCRMLostReason(IntegrationTestCase):
|
||||
"""
|
||||
Integration tests for CRMLostReason.
|
||||
Use this class for testing interactions between multiple components.
|
||||
"""
|
||||
|
||||
pass
|
||||
22
crm/fcrm/doctype/crm_lost_reason/test_records.json
Normal file
22
crm/fcrm/doctype/crm_lost_reason/test_records.json
Normal file
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{
|
||||
"doctype": "CRM Lost Reason",
|
||||
"lost_reason": "Price too high"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lost Reason",
|
||||
"lost_reason": "Competition"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lost Reason",
|
||||
"lost_reason": "Budget constraints"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lost Reason",
|
||||
"lost_reason": "No response"
|
||||
},
|
||||
{
|
||||
"doctype": "CRM Lost Reason",
|
||||
"lost_reason": "Not interested"
|
||||
}
|
||||
]
|
||||
0
crm/fcrm/doctype/crm_notification/__init__.py
Normal file
0
crm/fcrm/doctype/crm_notification/__init__.py
Normal file
8
crm/fcrm/doctype/crm_notification/crm_notification.js
Normal file
8
crm/fcrm/doctype/crm_notification/crm_notification.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("CRM Notification", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
166
crm/fcrm/doctype/crm_notification/crm_notification.json
Normal file
166
crm/fcrm/doctype/crm_notification/crm_notification.json
Normal file
@@ -0,0 +1,166 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2024-01-29 19:31:13.613929",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"notification_text",
|
||||
"section_break_hace",
|
||||
"from_user",
|
||||
"type",
|
||||
"column_break_dduu",
|
||||
"to_user",
|
||||
"read",
|
||||
"section_break_pbvx",
|
||||
"reference_doctype",
|
||||
"reference_name",
|
||||
"column_break_eant",
|
||||
"notification_type_doctype",
|
||||
"notification_type_doc",
|
||||
"comment",
|
||||
"section_break_vpwa",
|
||||
"message"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "from_user",
|
||||
"fieldtype": "Link",
|
||||
"label": "From User",
|
||||
"options": "User"
|
||||
},
|
||||
{
|
||||
"fieldname": "type",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Type",
|
||||
"options": "Mention\nTask\nAssignment\nWhatsApp",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_dduu",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "to_user",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "To User",
|
||||
"options": "User",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "comment",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 1,
|
||||
"label": "Comment",
|
||||
"options": "Comment"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "read",
|
||||
"fieldtype": "Check",
|
||||
"label": "Read"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_vpwa",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "message",
|
||||
"fieldtype": "HTML Editor",
|
||||
"in_list_view": 1,
|
||||
"label": "Message"
|
||||
},
|
||||
{
|
||||
"fieldname": "reference_name",
|
||||
"fieldtype": "Dynamic Link",
|
||||
"label": "Reference Doc",
|
||||
"options": "reference_doctype"
|
||||
},
|
||||
{
|
||||
"fieldname": "reference_doctype",
|
||||
"fieldtype": "Link",
|
||||
"label": "Reference Doctype",
|
||||
"options": "DocType"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_pbvx",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_eant",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "notification_type_doctype",
|
||||
"fieldtype": "Link",
|
||||
"label": "Notification Type Doctype",
|
||||
"options": "DocType"
|
||||
},
|
||||
{
|
||||
"fieldname": "notification_type_doc",
|
||||
"fieldtype": "Dynamic Link",
|
||||
"label": "Notification Type Doc",
|
||||
"options": "notification_type_doctype"
|
||||
},
|
||||
{
|
||||
"fieldname": "notification_text",
|
||||
"fieldtype": "Text",
|
||||
"label": "Notification Text"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_hace",
|
||||
"fieldtype": "Section Break"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-23 12:13:22.477210",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Notification",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales User",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user