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

This commit is contained in:
upstream-mirror
2026-09-18 01:36:01 +05:00
commit db69de1c11
905 changed files with 102713 additions and 0 deletions

0
crm/fcrm/__init__.py Normal file
View File

View File

View 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) {
// },
// });

View 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": []
}

View 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

View 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

View 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 Communication Status", {
// refresh(frm) {
// },
// });

View File

@@ -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": []
}

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

View 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 TestCRMCommunicationStatus(UnitTestCase):
pass

View 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": []
}

View 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

View 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) {
// },
// });

View 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"
}

View 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

View 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

View File

View 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

View 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");
},
});

View 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
}

View 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

View 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

View 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"
}
]

View 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) {
// },
// });

View 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
}

View 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

View 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

View 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
}
]

View 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": []
}

View 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

View 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) {
// },
// });

View 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": []
}

View 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"),
)

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

View 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) {
// },
// });

View 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": []
}

View 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

View File

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

View 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();
}
},
});

View 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": []
}

View 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

View 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

View 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) {
// },
// });

View File

@@ -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": []
}

View 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

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

View File

View 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": []
}

View 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

View 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();
});
},
});

View 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": []
}

View 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

View 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 TestCRMHolidayList(UnitTestCase):
pass

View 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) {
// },
// });

View 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
}

View 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

View 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

View 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");
});
}
},
});

View 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": []
}

View 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)

View 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)

View File

View 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");
},
});

View 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
}

View 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}"
)

View 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",
}

View 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"
}
]

View 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) {
// },
// });

View 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
}

View 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

View 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

View 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"
}
]

View 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) {
// },
// });

View 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
}

View 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

View 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

View File

@@ -0,0 +1,6 @@
[
{
"doctype": "CRM Lead Status",
"lead_status": "New Lead"
}
]

View 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) {
// },
// });

View 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
}

View 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

View 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

View 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"
}
]

View 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) {
// },
// });

Some files were not shown because too many files have changed in this diff Show More