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
Regenerate POT file (translatable strings) / Regenerate POT file (develop) (push) Failing after 1m13s
Regenerate POT file (translatable strings) / Regenerate POT file (main-hotfix) (push) Failing after 6s
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

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

179
crm/api/__init__.py Normal file
View File

@@ -0,0 +1,179 @@
import frappe
from bs4 import BeautifulSoup
from frappe import _
from frappe.core.api.file import get_max_file_size
from frappe.rate_limiter import rate_limit
from frappe.translate import get_all_translations
from frappe.utils import cstr, split_emails, validate_email_address
from crm.utils import is_frappe_version
@frappe.whitelist(allow_guest=True)
def get_translations():
if frappe.session.user != "Guest":
language = frappe.db.get_value("User", frappe.session.user, "language")
else:
language = frappe.db.get_single_value("System Settings", "language")
return get_all_translations(language)
@frappe.whitelist()
def get_user_signature():
user = frappe.session.user
user_email_signature = (
frappe.db.get_value(
"User",
user,
"email_signature",
)
if user
else None
)
signature = user_email_signature or frappe.db.get_value(
"Email Account",
{"default_outgoing": 1, "add_signature": 1},
"signature",
)
if not signature:
return
soup = BeautifulSoup(signature, "html.parser")
html_signature = soup.find("div", {"class": "ql-editor read-mode"})
_signature = None
if html_signature:
_signature = html_signature.renderContents()
content = ""
if cstr(_signature) or signature:
content = f'<br><p class="signature">{signature}</p>'
return content
def check_app_permission():
if frappe.session.user == "Administrator":
return True
allowed_modules = []
if is_frappe_version("15"):
allowed_modules = frappe.config.get_modules_from_all_apps_for_user()
elif is_frappe_version("16", above=True):
from frappe.utils.modules import get_modules_from_all_apps_for_user
allowed_modules = get_modules_from_all_apps_for_user()
allowed_modules = [x["module_name"] for x in allowed_modules]
if "FCRM" not in allowed_modules:
return False
roles = frappe.get_roles()
if any(role in ["System Manager", "Sales User", "Sales Manager"] for role in roles):
return True
return False
@frappe.whitelist(allow_guest=True)
@rate_limit(limit=10, seconds=60 * 60)
def accept_invitation(key: str | None = None):
if not key:
frappe.throw(_("Invalid or expired key"))
result = frappe.db.get_all("CRM Invitation", filters={"key": key}, pluck="name")
if not result:
frappe.throw(_("Invalid or expired key"))
invitation = frappe.get_doc("CRM Invitation", result[0])
is_new_user = invitation.accept()
invitation.reload()
# this is a GET request, which is rolled back unless a commit is requested
frappe.local.flags.commit = True
if invitation.status == "Accepted":
frappe.local.response["type"] = "redirect"
if is_new_user:
# a new user has no password yet, send them to the set password page
# which logs them in and redirects to /crm once the password is set
user = frappe.get_doc("User", invitation.email)
frappe.local.response["location"] = user._reset_password()
else:
frappe.local.login_manager.login_as(invitation.email)
frappe.local.response["location"] = "/crm"
@frappe.whitelist()
def invite_by_email(emails: str, role: str):
frappe.only_for(["Sales Manager", "System Manager"], True)
user_roles = frappe.get_roles(frappe.session.user)
if role == "System Manager" and "System Manager" not in user_roles:
frappe.throw(_("You are not allowed to invite System Managers"), frappe.PermissionError)
if role == "Sales Manager" and "System Manager" not in user_roles:
frappe.throw(_("You are not allowed to invite Sales Managers"), frappe.PermissionError)
if role not in ["System Manager", "Sales Manager", "Sales User"]:
frappe.throw(_("Cannot invite for this role"), frappe.PermissionError)
if not emails:
return
email_string = validate_email_address(emails, throw=False)
email_list = split_emails(email_string)
if not email_list:
return
existing_members = frappe.db.get_all("User", filters={"email": ["in", email_list]}, pluck="email")
existing_invites = frappe.db.get_all(
"CRM Invitation",
filters={
"email": ["in", email_list],
"role": ["in", ["System Manager", "Sales Manager", "Sales User"]],
},
pluck="email",
)
to_invite = list(set(email_list) - set(existing_members) - set(existing_invites))
for email in to_invite:
frappe.get_doc(doctype="CRM Invitation", email=email, role=role).insert(ignore_permissions=True)
return {
"existing_members": existing_members,
"existing_invites": existing_invites,
"to_invite": to_invite,
}
@frappe.whitelist(methods=["DELETE", "POST"])
def delete_attachment(doctype: str, docname: str, file_url: str):
if not frappe.has_permission(doctype, doc=docname, ptype="write"):
frappe.throw(_("You don't have permission to delete this attachment"), frappe.PermissionError)
file_name = frappe.db.get_value(
"File",
{"file_url": file_url, "attached_to_doctype": doctype, "attached_to_name": docname},
"name",
)
if file_name:
frappe.delete_doc("File", file_name)
@frappe.whitelist()
def get_file_uploader_defaults(doctype: str):
max_number_of_files = None
make_attachments_public = False
if doctype:
meta = frappe.get_meta(doctype)
max_number_of_files = meta.get("max_attachments")
make_attachments_public = meta.get("make_attachments_public")
return {
"allowed_file_types": frappe.get_system_settings("allowed_file_extensions"),
"max_file_size": get_max_file_size(),
"max_number_of_files": max_number_of_files,
"make_attachments_public": bool(make_attachments_public),
}

536
crm/api/activities.py Normal file
View File

@@ -0,0 +1,536 @@
import json
import frappe
from bs4 import BeautifulSoup
from frappe import _
from frappe.desk.form.load import get_docinfo
from frappe.query_builder import JoinType
from frappe.translate import get_translated_doctypes
from crm.fcrm.doctype.crm_call_log.crm_call_log import parse_call_log
from crm.fcrm.doctype.crm_fields_layout.crm_fields_layout import get_permlevel_access
@frappe.whitelist()
def get_activities(name: str):
if frappe.db.exists("CRM Deal", name):
return get_deal_activities(name)
elif frappe.db.exists("CRM Lead", name):
return get_lead_activities(name)
else:
frappe.throw(_("Document not found"), frappe.DoesNotExistError)
def get_deal_activities(name: str):
if not frappe.has_permission("CRM Deal", "read", name):
frappe.throw(_("Not permitted"), frappe.PermissionError)
get_docinfo("", "CRM Deal", name)
docinfo = frappe.response["docinfo"]
deal_fields = get_readable_fields("CRM Deal")
avoid_fields = [
"lead",
"response_by",
"sla_creation",
"sla",
"first_response_time",
"first_responded_on",
]
doc = frappe.db.get_values("CRM Deal", name, ["creation", "owner", "lead"])[0]
lead = doc[2]
activities = []
calls = []
notes = []
tasks = []
attachments = []
creation_text = _("created this deal")
if lead:
creation_text = _("converted the lead to this deal")
# a user can have access to the deal but not the lead it came from, so
# skip the lead's history instead of failing the whole timeline
if frappe.has_permission("CRM Lead", "read", lead):
activities, calls, notes, tasks, attachments = get_lead_activities(lead)
activities.append(
{
"activity_type": "creation",
"creation": doc[0],
"owner": doc[1],
"data": creation_text,
"is_lead": False,
}
)
docinfo.versions.reverse()
for version in docinfo.versions:
data = json.loads(version.data)
if not data.get("changed"):
continue
if change := data.get("changed")[0]:
field = deal_fields.get(change[0], None)
if not field or change[0] in avoid_fields or (not change[1] and not change[2]):
continue
field_label = field.get("label") or change[0]
field_option = field.get("options") or None
activity_type = "changed"
data = {
"field": change[0],
"field_label": field_label,
"old_value": change[1],
"value": change[2],
}
if not change[1] and change[2]:
activity_type = "added"
data = {
"field": change[0],
"field_label": field_label,
"value": change[2],
}
elif change[1] and not change[2]:
activity_type = "removed"
data = {
"field": change[0],
"field_label": field_label,
"value": change[1],
}
if data.get("value") and field_option and is_translatable(field_option):
data["value"] = _(data["value"])
if data.get("old_value"):
data["old_value"] = _(data["old_value"])
activity = {
"activity_type": activity_type,
"creation": version.creation,
"owner": version.owner,
"data": data,
"is_lead": False,
"options": field_option,
}
activities.append(activity)
for comment in docinfo.comments:
activity = {
"name": comment.name,
"activity_type": "comment",
"creation": comment.creation,
"owner": comment.owner,
"content": comment.content,
"attachments": get_attachments("Comment", comment.name),
"is_lead": False,
}
activities.append(activity)
for communication in docinfo.communications + docinfo.automated_messages:
activity = {
"activity_type": "communication",
"communication_type": communication.communication_type,
"communication_date": communication.communication_date or communication.creation,
"creation": communication.creation,
"data": {
"subject": communication.subject,
"content": communication.content,
"sender_full_name": communication.sender_full_name,
"sender": communication.sender,
"recipients": communication.recipients,
"cc": communication.cc,
"bcc": communication.bcc,
"attachments": get_attachments("Communication", communication.name),
"read_by_recipient": communication.read_by_recipient,
"delivery_status": communication.delivery_status,
},
"is_lead": False,
}
activities.append(activity)
for attachment_log in docinfo.attachment_logs:
activity = {
"name": attachment_log.name,
"activity_type": "attachment_log",
"creation": attachment_log.creation,
"owner": attachment_log.owner,
"data": parse_attachment_log(attachment_log.content, attachment_log.comment_type),
"is_lead": False,
}
activities.append(activity)
calls = calls + get_linked_calls(name).get("calls", [])
notes = notes + get_linked_notes(name) + get_linked_calls(name).get("notes", [])
tasks = tasks + get_linked_tasks(name) + get_linked_calls(name).get("tasks", [])
attachments = attachments + get_attachments("CRM Deal", name)
activities.sort(key=lambda x: x["creation"], reverse=True)
activities = handle_multiple_versions(activities)
return activities, calls, notes, tasks, attachments
def get_lead_activities(name: str):
if not frappe.has_permission("CRM Lead", "read", name):
frappe.throw(_("Not permitted"), frappe.PermissionError)
get_docinfo("", "CRM Lead", name)
docinfo = frappe.response["docinfo"]
lead_fields = get_readable_fields("CRM Lead")
avoid_fields = [
"converted",
"response_by",
"sla_creation",
"sla",
"first_response_time",
"first_responded_on",
]
doc = frappe.db.get_values("CRM Lead", name, ["creation", "owner"])[0]
activities = [
{
"activity_type": "creation",
"creation": doc[0],
"owner": doc[1],
"data": _("created this lead"),
"is_lead": True,
}
]
docinfo.versions.reverse()
for version in docinfo.versions:
data = json.loads(version.data)
if not data.get("changed"):
continue
if change := data.get("changed")[0]:
field = lead_fields.get(change[0], None)
if not field or change[0] in avoid_fields or (not change[1] and not change[2]):
continue
field_label = field.get("label") or change[0]
field_option = field.get("options") or None
activity_type = "changed"
data = {
"field": change[0],
"field_label": field_label,
"old_value": change[1],
"value": change[2],
}
if not change[1] and change[2]:
activity_type = "added"
data = {
"field": change[0],
"field_label": field_label,
"value": change[2],
}
elif change[1] and not change[2]:
activity_type = "removed"
data = {
"field": change[0],
"field_label": field_label,
"value": change[1],
}
if data.get("value") and field_option and is_translatable(field_option):
data["value"] = _(data["value"])
if data.get("old_value"):
data["old_value"] = _(data["old_value"])
activity = {
"activity_type": activity_type,
"creation": version.creation,
"owner": version.owner,
"data": data,
"is_lead": True,
"options": field_option,
}
activities.append(activity)
for comment in docinfo.comments:
activity = {
"name": comment.name,
"activity_type": "comment",
"creation": comment.creation,
"owner": comment.owner,
"content": comment.content,
"attachments": get_attachments("Comment", comment.name),
"is_lead": True,
}
activities.append(activity)
for communication in docinfo.communications + docinfo.automated_messages:
activity = {
"activity_type": "communication",
"communication_type": communication.communication_type,
"communication_date": communication.communication_date or communication.creation,
"creation": communication.creation,
"data": {
"subject": communication.subject,
"content": communication.content,
"sender_full_name": communication.sender_full_name,
"sender": communication.sender,
"recipients": communication.recipients,
"cc": communication.cc,
"bcc": communication.bcc,
"attachments": get_attachments("Communication", communication.name),
"read_by_recipient": communication.read_by_recipient,
"delivery_status": communication.delivery_status,
},
"is_lead": True,
}
activities.append(activity)
for attachment_log in docinfo.attachment_logs:
activity = {
"name": attachment_log.name,
"activity_type": "attachment_log",
"creation": attachment_log.creation,
"owner": attachment_log.owner,
"data": parse_attachment_log(attachment_log.content, attachment_log.comment_type),
"is_lead": True,
}
activities.append(activity)
calls = get_linked_calls(name).get("calls", [])
notes = get_linked_notes(name) + get_linked_calls(name).get("notes", [])
tasks = get_linked_tasks(name) + get_linked_calls(name).get("tasks", [])
attachments = get_attachments("CRM Lead", name)
activities.sort(key=lambda x: x["creation"], reverse=True)
activities = handle_multiple_versions(activities)
return activities, calls, notes, tasks, attachments
def get_readable_fields(doctype: str):
"""Map of fieldname to label & options, skipping fields the user cannot read.
Permlevel restrictions already hide these fields on the form layout, so the
activity timeline has to hide them too instead of leaking their values.
"""
allowed_permlevels = get_permlevel_access("read", doctype)
return {
field.fieldname: {"label": field.label, "options": field.options}
for field in frappe.get_meta(doctype).fields
if field.permlevel == 0 or field.permlevel in allowed_permlevels
}
def get_attachments(doctype: str, name: str):
return (
frappe.db.get_all(
"File",
filters={"attached_to_doctype": doctype, "attached_to_name": name},
fields=[
"name",
"file_name",
"file_type",
"file_url",
"file_size",
"is_private",
"modified",
"creation",
"owner",
],
)
or []
)
def handle_multiple_versions(versions: list):
activities = []
grouped_versions = []
old_version = None
for version in versions:
is_version = version["activity_type"] in ["changed", "added", "removed"]
if not is_version:
activities.append(version)
if not old_version:
old_version = version
if is_version:
grouped_versions.append(version)
continue
if is_version and old_version.get("owner") and version["owner"] == old_version["owner"]:
grouped_versions.append(version)
else:
if grouped_versions:
activities.append(parse_grouped_versions(grouped_versions))
grouped_versions = []
if is_version:
grouped_versions.append(version)
old_version = version
if version == versions[-1] and grouped_versions:
activities.append(parse_grouped_versions(grouped_versions))
return activities
def parse_grouped_versions(versions: list):
version = versions[0]
if len(versions) == 1:
return version
other_versions = versions[1:]
version["other_versions"] = other_versions
return version
def get_linked_calls(name: str):
calls = frappe.db.get_all(
"CRM Call Log",
filters={"reference_docname": name},
fields=[
"name",
"caller",
"receiver",
"from",
"to",
"duration",
"start_time",
"end_time",
"status",
"type",
"recording_url",
"creation",
"note",
],
)
linked_calls = frappe.db.get_all(
"Dynamic Link", filters={"link_name": name, "parenttype": "CRM Call Log"}, pluck="parent"
)
notes = []
tasks = []
if linked_calls:
CallLog = frappe.qb.DocType("CRM Call Log")
Link = frappe.qb.DocType("Dynamic Link")
query = (
frappe.qb.from_(CallLog)
.select(
CallLog.name,
CallLog.caller,
CallLog.receiver,
CallLog["from"],
CallLog.to,
CallLog.duration,
CallLog.start_time,
CallLog.end_time,
CallLog.status,
CallLog.type,
CallLog.recording_url,
CallLog.creation,
CallLog.note,
Link.link_doctype,
Link.link_name,
)
.join(Link, JoinType.inner)
.on(Link.parent == CallLog.name)
.where(CallLog.name.isin(linked_calls))
)
_calls = query.run(as_dict=True)
for call in _calls:
if call.get("link_doctype") == "FCRM Note":
notes.append(call.link_name)
elif call.get("link_doctype") == "CRM Task":
tasks.append(call.link_name)
_calls = [call for call in _calls if call.get("link_doctype") not in ["FCRM Note", "CRM Task"]]
if _calls:
calls = calls + _calls
if notes:
notes = frappe.db.get_all(
"FCRM Note",
filters={"name": ("in", notes)},
fields=["name", "title", "content", "owner", "modified"],
)
if tasks:
tasks = frappe.db.get_all(
"CRM Task",
filters={"name": ("in", tasks)},
fields=[
"name",
"title",
"description",
"assigned_to",
"due_date",
"priority",
"status",
"modified",
],
)
calls = [parse_call_log(call) for call in calls] if calls else []
return {"calls": calls, "notes": notes, "tasks": tasks}
def get_linked_notes(name: str):
notes = frappe.db.get_all(
"FCRM Note",
filters={"reference_docname": name},
fields=["name", "title", "content", "owner", "modified", "creation"],
)
return notes or []
def get_linked_tasks(name: str):
tasks = frappe.db.get_all(
"CRM Task",
filters={"reference_docname": name},
fields=[
"name",
"title",
"description",
"assigned_to",
"due_date",
"priority",
"status",
"modified",
"creation",
],
)
return tasks or []
def parse_attachment_log(html: str, type: str):
soup = BeautifulSoup(html, "html.parser")
a_tag = soup.find("a")
type = "added" if type == "Attachment" else "removed"
if not a_tag:
return {
"type": type,
"file_name": html.replace("Removed ", ""),
"file_url": "",
"is_private": False,
}
is_private = False
if "private/files" in a_tag["href"]:
is_private = True
return {
"type": type,
"file_name": a_tag.text,
"file_url": a_tag["href"],
"is_private": is_private,
}
def is_translatable(doctype: str) -> bool:
return doctype in get_translated_doctypes()

View File

@@ -0,0 +1,32 @@
import frappe
@frappe.whitelist()
def get_assignment_rules_list():
assignment_rules = []
for docname in frappe.get_all(
"Assignment Rule", filters={"document_type": ["in", ["CRM Lead", "CRM Deal"]]}
):
doc = frappe.get_value(
"Assignment Rule",
docname,
fieldname=[
"name",
"description",
"disabled",
"priority",
],
as_dict=True,
)
users_exists = bool(frappe.db.exists("Assignment Rule User", {"parent": docname.name}))
assignment_rules.append({**doc, "users_exists": users_exists})
return assignment_rules
@frappe.whitelist()
def duplicate_assignment_rule(docname: str, new_name: str):
doc = frappe.get_doc("Assignment Rule", docname)
doc.name = new_name
doc.assignment_rule_name = new_name
doc.insert()
return doc

39
crm/api/auth.py Normal file
View File

@@ -0,0 +1,39 @@
import frappe
@frappe.whitelist(allow_guest=True)
def oauth_providers():
from frappe.utils.html_utils import get_icon_html
from frappe.utils.oauth import get_oauth2_authorize_url, get_oauth_keys
from frappe.utils.password import get_decrypted_password
out = []
providers = frappe.get_all(
"Social Login Key",
filters={"enable_social_login": 1},
fields=["name", "client_id", "base_url", "provider_name", "icon"],
order_by="name",
)
for provider in providers:
client_secret = get_decrypted_password("Social Login Key", provider.name, "client_secret")
if not client_secret:
continue
icon = None
if provider.icon:
if provider.provider_name == "Custom":
icon = get_icon_html(provider.icon, small=True)
else:
icon = f"<img src='{provider.icon}' alt={provider.provider_name}>"
if provider.client_id and provider.base_url and get_oauth_keys(provider.name):
out.append(
{
"name": provider.name,
"provider_name": provider.provider_name,
"auth_url": get_oauth2_authorize_url(provider.name, "/crm"),
"icon": icon,
}
)
return out

128
crm/api/comment.py Normal file
View File

@@ -0,0 +1,128 @@
from collections.abc import Iterable
import frappe
from bs4 import BeautifulSoup
from frappe import _
from frappe.desk.form.utils import add_comment as frappe_add_comment
from frappe.utils import get_fullname
from crm.fcrm.doctype.crm_notification.crm_notification import notify_user
def on_update(self, method):
notify_mentions(self)
def notify_mentions(doc):
"""
Extract mentions from `content`, and notify.
`content` must have `HTML` content.
"""
if doc.reference_doctype not in ["CRM Lead", "CRM Deal"]:
return
content = getattr(doc, "content", None)
if not content:
return
mentions = extract_mentions(content)
reference_doc = frappe.get_doc(doc.reference_doctype, doc.reference_name)
for mention in mentions:
owner = frappe.get_cached_value("User", doc.owner, "full_name")
doctype = doc.reference_doctype
if doctype.startswith("CRM "):
doctype = doctype[4:].lower()
name = (
reference_doc.lead_name
if doctype == "lead"
else reference_doc.organization or reference_doc.lead_name
)
notification_text = f"""
<div class="mb-2 leading-5 text-ink-gray-5">
<span class="font-medium text-ink-gray-9">{owner}</span>
<span>{_("mentioned you in {0}").format(doctype)}</span>
<span class="font-medium text-ink-gray-9">{name}</span>
</div>
"""
notify_user(
{
"owner": doc.owner,
"assigned_to": mention.email,
"notification_type": "Mention",
"message": doc.content,
"notification_text": notification_text,
"reference_doctype": "Comment",
"reference_docname": doc.name,
"redirect_to_doctype": doc.reference_doctype,
"redirect_to_docname": doc.reference_name,
}
)
def extract_mentions(html):
if not html:
return []
soup = BeautifulSoup(html, "html.parser")
mentions = []
for d in soup.find_all("span", attrs={"data-type": "mention"}):
mentions.append(frappe._dict(full_name=d.get("data-label"), email=d.get("data-id")))
return mentions
@frappe.whitelist()
def add_comment(reference_doctype: str, reference_name: str, content: str, attachments: list | None = None):
"""Add a comment to the given document
:param reference_doctype: Reference Doctype
:param reference_name: Reference Document Name
:param content: Comment Content (HTML)
:param attachments: List of File names or dicts with keys "fname" and "fcontent"
:return: Comment Document
"""
comment = frappe_add_comment(
reference_doctype,
reference_name,
content,
comment_email=frappe.session.user,
comment_by=get_fullname(frappe.session.user),
)
if attachments and comment.name:
add_attachments(comment.name, attachments)
return comment
def add_attachments(name: str, attachments: Iterable[str | dict]) -> None:
"""Add attachments to the given Comment
:param name: Comment name
:param attachments: File names or dicts with keys "fname" and "fcontent"
"""
# loop through attachments
for a in attachments:
if isinstance(a, str):
attach = frappe.db.get_value("File", {"name": a}, ["file_url", "is_private"], as_dict=1)
file_args = {
"file_url": attach.file_url,
"is_private": attach.is_private,
}
elif isinstance(a, dict) and "fcontent" in a and "fname" in a:
# dict returned by frappe.attach_print()
file_args = {
"file_name": a["fname"],
"content": a["fcontent"],
"is_private": 1,
}
else:
continue
file_args.update(
{
"attached_to_doctype": "Comment",
"attached_to_name": name,
"folder": "Home/Attachments",
}
)
_file = frappe.new_doc("File")
_file.update(file_args)
_file.save(ignore_permissions=True)

145
crm/api/contact.py Normal file
View File

@@ -0,0 +1,145 @@
import frappe
from frappe import _
def validate(doc, method):
update_deals_email_mobile_no(doc)
def update_deals_email_mobile_no(doc):
linked_deals = frappe.get_all(
"CRM Contacts",
filters={"contact": doc.name, "is_primary": 1},
fields=["parent"],
)
for linked_deal in linked_deals:
deal = frappe.db.get_values("CRM Deal", linked_deal.parent, ["email", "mobile_no"], as_dict=True)[0]
if deal.email != doc.email_id or deal.mobile_no != doc.mobile_no:
frappe.db.set_value(
"CRM Deal",
linked_deal.parent,
{
"email": doc.email_id,
"mobile_no": doc.mobile_no,
},
)
@frappe.whitelist()
def get_linked_deals(contact: str):
"""Get linked deals for a contact"""
if not frappe.has_permission("Contact", "read", contact):
frappe.throw(_("Not permitted"), frappe.PermissionError)
deal_names = frappe.get_all(
"CRM Contacts",
filters={"contact": contact, "parenttype": "CRM Deal"},
fields=["parent"],
distinct=True,
)
# get deals data
deals = []
for d in deal_names:
deal = frappe.get_cached_doc(
"CRM Deal",
d.parent,
fields=[
"name",
"organization",
"currency",
"deal_value",
"status",
"email",
"mobile_no",
"deal_owner",
"modified",
],
)
deals.append(deal.as_dict())
return deals
@frappe.whitelist()
def create_new(contact: str, field: str, value: str):
"""Create new email or phone for a contact"""
if not frappe.has_permission("Contact", "write", contact):
frappe.throw(_("Not permitted"), frappe.PermissionError)
contact = frappe.get_cached_doc("Contact", contact)
if field == "email":
email = {"email_id": value, "is_primary": 1 if len(contact.email_ids) == 0 else 0}
contact.append("email_ids", email)
elif field in ("mobile_no", "phone"):
mobile_no = {"phone": value, "is_primary_mobile_no": 1 if len(contact.phone_nos) == 0 else 0}
contact.append("phone_nos", mobile_no)
else:
frappe.throw(_("Invalid field"))
contact.save()
return True
@frappe.whitelist()
def set_as_primary(contact: str, field: str, value: str):
"""Set email or phone as primary for a contact"""
if not frappe.has_permission("Contact", "write", contact):
frappe.throw(_("Not permitted"), frappe.PermissionError)
contact = frappe.get_doc("Contact", contact)
if field == "email":
for email in contact.email_ids:
if email.email_id == value:
email.is_primary = 1
else:
email.is_primary = 0
elif field in ("mobile_no", "phone"):
name = "is_primary_mobile_no" if field == "mobile_no" else "is_primary_phone"
for phone in contact.phone_nos:
if phone.phone == value:
phone.set(name, 1)
else:
phone.set(name, 0)
else:
frappe.throw(_("Invalid field"))
contact.save()
return True
@frappe.whitelist()
def search_emails(txt: str):
doctype = "Contact"
meta = frappe.get_meta(doctype)
filters = [["Contact", "email_id", "is", "set"]]
if meta.get("fields", {"fieldname": "enabled", "fieldtype": "Check"}):
filters.append([doctype, "enabled", "=", 1])
if meta.get("fields", {"fieldname": "disabled", "fieldtype": "Check"}):
filters.append([doctype, "disabled", "!=", 1])
or_filters = []
search_fields = ["full_name", "email_id", "name"]
if txt:
for f in search_fields:
or_filters.append([doctype, f.strip(), "like", f"%{txt}%"])
results = frappe.get_list(
doctype,
filters=filters,
fields=search_fields,
or_filters=or_filters,
limit_start=0,
limit_page_length=20,
order_by="email_id, full_name, name",
ignore_permissions=False,
as_list=True,
strict=False,
)
return results

1323
crm/api/dashboard.py Normal file

File diff suppressed because it is too large Load Diff

857
crm/api/doc.py Normal file
View File

@@ -0,0 +1,857 @@
import json
import frappe
from frappe import _
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
from frappe.desk.form.assign_to import set_status
from frappe.model import no_value_fields
from frappe.model.delete_doc import get_dynamic_linked_docs, get_linked_docs
from frappe.model.document import get_controller
from frappe.utils import make_filter_tuple
from pypika import Criterion
from crm.api.views import get_views
from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script
from crm.utils import is_frappe_version
COUNT_NAME = (
{"COUNT": "name", "as": "total_count"}
if is_frappe_version("16", above=True)
else "count(name) as total_count"
)
@frappe.whitelist()
def sort_options(doctype: str):
fields = frappe.get_meta(doctype).fields
fields = [field for field in fields if field.fieldtype not in no_value_fields]
fields = [
{
"label": _(field.label),
"value": field.fieldname,
"fieldname": field.fieldname,
}
for field in fields
if field.label and field.fieldname
]
standard_fields = [
{"label": "Name", "fieldname": "name"},
{"label": "Created On", "fieldname": "creation"},
{"label": "Last Modified", "fieldname": "modified"},
{"label": "Modified By", "fieldname": "modified_by"},
{"label": "Owner", "fieldname": "owner"},
]
for field in standard_fields:
field["label"] = _(field["label"])
field["value"] = field["fieldname"]
fields.append(field)
return fields
@frappe.whitelist()
def get_filterable_fields(doctype: str):
allowed_fieldtypes = [
"Check",
"Data",
"Float",
"Int",
"Currency",
"Dynamic Link",
"Link",
"Long Text",
"Select",
"Small Text",
"Text Editor",
"Text",
"Duration",
"Rating",
"Date",
"Datetime",
]
c = get_controller(doctype)
restricted_fields = []
if hasattr(c, "get_non_filterable_fields"):
restricted_fields = c.get_non_filterable_fields()
fields = []
meta = frappe.get_meta(doctype).as_dict()
# append standard fields (getting error when using frappe.model.std_fields)
standard_fields = [
{"fieldname": "name", "fieldtype": "Link", "label": "Name", "options": doctype},
{"fieldname": "owner", "fieldtype": "Link", "label": "Created By", "options": "User"},
{
"fieldname": "modified_by",
"fieldtype": "Link",
"label": "Last Updated By",
"options": "User",
},
{"fieldname": "_user_tags", "fieldtype": "Data", "label": "Tags"},
{"fieldname": "_liked_by", "fieldtype": "Data", "label": "Like"},
{"fieldname": "_comments", "fieldtype": "Text", "label": "Comments"},
{"fieldname": "_assign", "fieldtype": "Text", "label": "Assigned To"},
{"fieldname": "creation", "fieldtype": "Datetime", "label": "Created On"},
{"fieldname": "modified", "fieldtype": "Datetime", "label": "Last Updated On"},
]
for field in standard_fields + meta.get("fields", []):
if field.get("fieldname") not in restricted_fields and field.get("fieldtype") in allowed_fieldtypes:
field["name"] = field.get("fieldname")
field["label"] = _(field.get("label"))
field["value"] = field.get("fieldname")
fields.append(field)
return fields
@frappe.whitelist()
def get_group_by_fields(doctype: str):
allowed_fieldtypes = [
"Check",
"Data",
"Float",
"Int",
"Currency",
"Dynamic Link",
"Link",
"Select",
"Duration",
"Date",
"Datetime",
]
fields = frappe.get_meta(doctype).fields
fields = [
field
for field in fields
if field.fieldtype not in no_value_fields and field.fieldtype in allowed_fieldtypes
]
fields = [
{
"label": _(field.label),
"fieldname": field.fieldname,
}
for field in fields
if field.label and field.fieldname
]
standard_fields = [
{"label": "Name", "fieldname": "name"},
{"label": "Created On", "fieldname": "creation"},
{"label": "Last Modified", "fieldname": "modified"},
{"label": "Modified By", "fieldname": "modified_by"},
{"label": "Owner", "fieldname": "owner"},
{"label": "Like", "fieldname": "_liked_by"},
{"label": "Assigned To", "fieldname": "_assign"},
{"label": "Comments", "fieldname": "_comments"},
{"label": "Created On", "fieldname": "creation"},
{"label": "Modified On", "fieldname": "modified"},
]
for field in standard_fields:
field["label"] = _(field["label"])
fields.append(field)
return fields
@frappe.whitelist()
def get_quick_filters(doctype: str, cached: bool = True):
meta = frappe.get_meta(doctype, cached)
quick_filters = []
if global_settings := frappe.db.exists("CRM Global Settings", {"dt": doctype, "type": "Quick Filters"}):
_quick_filters = frappe.db.get_value("CRM Global Settings", global_settings, "json")
_quick_filters = json.loads(_quick_filters) or []
fields = []
for filter in _quick_filters:
if filter == "name":
fields.append({"label": "Name", "fieldname": "name", "fieldtype": "Data"})
else:
field = next((f for f in meta.fields if f.fieldname == filter), None)
if field:
fields.append(field)
else:
fields = [field for field in meta.fields if field.in_standard_filter]
for field in fields:
options = field.get("options")
if field.get("fieldtype") == "Select" and options and isinstance(options, str):
options = options.split("\n")
options = [{"label": option, "value": option} for option in options]
if not any([not option.get("value") for option in options]):
options.insert(0, {"label": "", "value": ""})
quick_filters.append(
{
"label": _(field.get("label")),
"fieldname": field.get("fieldname"),
"fieldtype": field.get("fieldtype"),
"options": options,
}
)
if doctype == "CRM Lead":
quick_filters = [filter for filter in quick_filters if filter.get("fieldname") != "converted"]
return quick_filters
@frappe.whitelist()
def update_quick_filters(quick_filters: str, old_filters: str, doctype: str):
quick_filters = json.loads(quick_filters)
old_filters = json.loads(old_filters)
new_filters = [filter for filter in quick_filters if filter not in old_filters]
removed_filters = [filter for filter in old_filters if filter not in quick_filters]
# update or create global quick filter settings
create_update_global_settings(doctype, quick_filters)
# remove old filters
for filter in removed_filters:
update_in_standard_filter(filter, doctype, 0)
# add new filters
for filter in new_filters:
update_in_standard_filter(filter, doctype, 1)
def create_update_global_settings(doctype, quick_filters):
if global_settings := frappe.db.exists("CRM Global Settings", {"dt": doctype, "type": "Quick Filters"}):
frappe.db.set_value("CRM Global Settings", global_settings, "json", json.dumps(quick_filters))
else:
# create CRM Global Settings doc
doc = frappe.new_doc("CRM Global Settings")
doc.dt = doctype
doc.type = "Quick Filters"
doc.json = json.dumps(quick_filters)
doc.insert()
def update_in_standard_filter(fieldname, doctype, value):
if property_name := frappe.db.exists(
"Property Setter",
{"doc_type": doctype, "field_name": fieldname, "property": "in_standard_filter"},
):
frappe.db.set_value("Property Setter", property_name, "value", value)
else:
make_property_setter(
doctype,
fieldname,
"in_standard_filter",
value,
"Check",
validate_fields_for_doctype=False,
)
@frappe.whitelist()
def get_data(
doctype: str,
filters: dict,
order_by: str,
page_length: int = 20,
page_length_count: int = 20,
column_field: str | None = None,
title_field: str | None = None,
columns: str | list | None = None,
rows: str | list | None = None,
kanban_columns: str | list | None = None,
kanban_fields: str | list | None = None,
view: str | dict | None = None,
default_filters: dict | None = None,
):
custom_view = False
filters = frappe._dict(filters)
rows = frappe.parse_json(rows or "[]")
columns = frappe.parse_json(columns or "[]")
kanban_fields = frappe.parse_json(kanban_fields or "[]")
kanban_columns = frappe.parse_json(kanban_columns or "[]")
custom_view_name = view.get("custom_view_name") if view else None
view_type = view.get("view_type") if view else None
group_by_field = view.get("group_by_field") if view else None
for key in filters:
value = filters[key]
if isinstance(value, list):
if "@me" in value:
value[value.index("@me")] = frappe.session.user
elif "%@me%" in value:
index = [i for i, v in enumerate(value) if v == "%@me%"]
for i in index:
value[i] = "%" + frappe.session.user + "%"
elif value == "@me":
filters[key] = frappe.session.user
if default_filters:
default_filters = frappe.parse_json(default_filters)
filters.update(default_filters)
is_default = True
data = []
_list = get_controller(doctype)
default_rows = []
default_column_keys = []
if hasattr(_list, "default_list_data"):
default_list_data = _list.default_list_data()
default_rows = default_list_data.get("rows")
default_column_keys = [column.get("key") for column in default_list_data.get("columns", [])]
meta = frappe.get_meta(doctype)
if view_type != "kanban":
if columns or rows:
custom_view = True
is_default = False
columns = frappe.parse_json(columns)
rows = frappe.parse_json(rows)
if not columns:
columns = [
{"label": "Name", "type": "Data", "key": "name", "width": "16rem"},
{"label": "Last Modified", "type": "Datetime", "key": "modified", "width": "8rem"},
]
if not rows:
rows = ["name"]
default_view_filters = {
"dt": doctype,
"type": view_type or "list",
"is_standard": 1,
"user": frappe.session.user,
}
if not custom_view and frappe.db.exists("CRM View Settings", default_view_filters):
list_view_settings = frappe.get_doc("CRM View Settings", default_view_filters)
columns = frappe.parse_json(list_view_settings.columns)
rows = frappe.parse_json(list_view_settings.rows)
is_default = False
elif not custom_view or (is_default and hasattr(_list, "default_list_data")):
rows = default_rows
columns = _list.default_list_data().get("columns")
visible_columns = []
for column in columns:
key = column.get("key")
# hidden fields are dropped unless the doctype lists them in its own defaults
column_meta = meta.get_field(key)
if column_meta and column_meta.get("hidden") and key not in default_column_keys:
continue
if key not in rows:
rows.append(key)
column["label"] = _(column.get("label"))
if key == "_liked_by" and column.get("width") == "10rem":
column["width"] = "50px"
visible_columns.append(column)
columns = visible_columns
# check if rows has group_by_field if not add it
if group_by_field and group_by_field not in rows:
rows.append(group_by_field)
if meta.track_seen and "_seen" not in rows:
rows.append("_seen")
data = (
frappe.get_list(
doctype,
fields=rows,
filters=filters,
order_by=order_by,
page_length=page_length,
)
or []
)
data = parse_list_data(data, doctype)
if view_type == "kanban":
if not rows:
rows = default_rows
if not kanban_columns and column_field:
field_meta = frappe.get_meta(doctype).get_field(column_field)
if field_meta.fieldtype == "Link":
kanban_columns = frappe.get_all(
field_meta.options,
fields=["name"],
order_by="modified asc",
)
elif field_meta.fieldtype == "Select":
kanban_columns = [{"name": option} for option in field_meta.options.split("\n")]
if not title_field:
title_field = "name"
if hasattr(_list, "default_kanban_settings"):
title_field = _list.default_kanban_settings().get("title_field")
if title_field not in rows:
rows.append(title_field)
if not kanban_fields:
kanban_fields = ["name"]
if hasattr(_list, "default_kanban_settings"):
kanban_fields = json.loads(_list.default_kanban_settings().get("kanban_fields"))
for field in kanban_fields:
if field not in rows:
rows.append(field)
for kc in kanban_columns:
# Start with base filters
column_filters = []
# Convert and add the main filters first
if filters:
base_filters = convert_filter_to_tuple(doctype, filters)
column_filters.extend(base_filters)
# Add the column-specific filter
if column_field and kc.get("name"):
column_filters.append([doctype, column_field, "=", kc.get("name")])
order = kc.get("order")
if kc.get("delete"):
column_data = []
else:
page_length = kc.get("page_length", 20)
if order:
column_data = get_records_based_on_order(
doctype, rows, column_filters, page_length, order
)
else:
column_data = frappe.get_list(
doctype,
fields=rows,
filters=column_filters,
order_by=order_by,
page_length=page_length,
)
all_count = frappe.get_list(
doctype,
filters=column_filters,
fields=[COUNT_NAME],
)[0].total_count
kc["all_count"] = all_count
kc["count"] = len(column_data)
if order:
column_data = sorted(
column_data,
key=lambda x: order.index(x.get("name")) if x.get("name") in order else len(order),
)
data.append({"column": kc, "fields": kanban_fields, "data": column_data})
fields = frappe.get_meta(doctype).fields
fields = [field for field in fields if field.fieldtype not in no_value_fields]
fields = [
{
"label": _(field.label),
"fieldtype": field.fieldtype,
"fieldname": field.fieldname,
"options": field.options,
}
for field in fields
if field.label and field.fieldname
]
std_fields = [
{"label": "Name", "fieldtype": "Data", "fieldname": "name"},
{"label": "Created On", "fieldtype": "Datetime", "fieldname": "creation"},
{"label": "Last Modified", "fieldtype": "Datetime", "fieldname": "modified"},
{
"label": "Modified By",
"fieldtype": "Link",
"fieldname": "modified_by",
"options": "User",
},
{"label": "Assigned To", "fieldtype": "Text", "fieldname": "_assign"},
{"label": "Owner", "fieldtype": "Link", "fieldname": "owner", "options": "User"},
{"label": "Like", "fieldtype": "Data", "fieldname": "_liked_by"},
]
for field in std_fields:
if field.get("fieldname") not in rows:
rows.append(field.get("fieldname"))
if field not in fields:
field["label"] = _(field["label"])
fields.append(field)
if not is_default and custom_view_name:
is_default = frappe.db.get_value("CRM View Settings", custom_view_name, "load_default_columns")
if group_by_field and view_type == "group_by":
def get_options(type, options):
if type == "Select":
return [option for option in options.split("\n")]
else:
has_empty_values = any([not d.get(group_by_field) for d in data])
options = list(set([d.get(group_by_field) for d in data]))
options = [u for u in options if u]
if has_empty_values:
options.append("")
if order_by and group_by_field in order_by:
order_by_fields = order_by.split(",")
order_by_fields = [
(field.split(" ")[0], field.split(" ")[1]) for field in order_by_fields
]
if (group_by_field, "asc") in order_by_fields:
options.sort()
elif (group_by_field, "desc") in order_by_fields:
options.sort(reverse=True)
else:
options.sort()
return options
for field in fields:
if field.get("fieldname") == group_by_field:
group_by_field = {
"label": field.get("label"),
"fieldname": field.get("fieldname"),
"fieldtype": field.get("fieldtype"),
"options": get_options(field.get("fieldtype"), field.get("options")),
}
return {
"data": data,
"columns": columns,
"rows": rows,
"fields": fields,
"column_field": column_field,
"title_field": title_field,
"kanban_columns": kanban_columns,
"kanban_fields": kanban_fields,
"group_by_field": group_by_field,
"page_length": page_length,
"page_length_count": page_length_count,
"is_default": is_default,
"views": get_views(doctype),
"total_count": frappe.get_list(doctype, filters=filters, fields=[COUNT_NAME])[0].total_count,
"row_count": len(data),
"form_script": get_form_script(doctype),
"list_script": get_form_script(doctype, "List"),
"view_type": view_type,
}
def parse_list_data(data, doctype):
_list = get_controller(doctype)
if hasattr(_list, "parse_list_data"):
data = _list.parse_list_data(data)
return data
def convert_filter_to_tuple(doctype, filters):
if isinstance(filters, dict):
filters_items = filters.items()
filters = []
for key, value in filters_items:
filters.append(make_filter_tuple(doctype, key, value))
return filters
def get_records_based_on_order(doctype, rows, filters, page_length, order):
records = []
filters = convert_filter_to_tuple(doctype, filters)
in_filters = filters.copy()
in_filters.append([doctype, "name", "in", order[:page_length]])
records = frappe.get_list(
doctype,
fields=rows,
filters=in_filters,
order_by="creation desc",
page_length=page_length,
)
if len(records) < page_length:
not_in_filters = filters.copy()
not_in_filters.append([doctype, "name", "not in", order])
remaining_records = frappe.get_list(
doctype,
fields=rows,
filters=not_in_filters,
order_by="creation desc",
page_length=page_length - len(records),
)
for record in remaining_records:
records.append(record)
return records
@frappe.whitelist()
def remove_assignments(doctype: str, name: str, assignees: str | list, ignore_permissions: bool = False):
assignees = frappe.parse_json(assignees)
if not assignees:
return
for assign_to in assignees:
set_status(
doctype,
name,
todo=None,
assign_to=assign_to,
status="Cancelled",
ignore_permissions=ignore_permissions,
)
@frappe.whitelist()
def add_seen(doctype: str, name: str):
doc = frappe.get_doc(doctype, name)
doc.check_permission("read")
doc.add_seen()
@frappe.whitelist()
def get_assigned_users(doctype: str, name: str | int, default_assigned_to: str | None = None):
assigned_users = frappe.get_all(
"ToDo",
fields=["allocated_to"],
filters={
"reference_type": doctype,
"reference_name": name,
"status": ("not in", ("Closed", "Cancelled")),
},
pluck="allocated_to",
)
users = list(set(assigned_users))
# if users is empty, add default_assigned_to
if not users and default_assigned_to:
users = [default_assigned_to]
return users
@frappe.whitelist()
def get_fields(doctype: str, allow_all_fieldtypes: bool = False):
not_allowed_fieldtypes = [*list(frappe.model.no_value_fields), "Read Only"]
if allow_all_fieldtypes:
not_allowed_fieldtypes = []
fields = frappe.get_meta(doctype).fields
_fields = []
for field in fields:
if field.fieldtype not in not_allowed_fieldtypes and field.fieldname:
_fields.append(field)
return _fields
def getCounts(d, doctype):
d["_email_count"] = (
frappe.db.count(
"Communication",
filters={
"reference_doctype": doctype,
"reference_name": d.get("name"),
"communication_type": "Communication",
},
)
or 0
)
d["_email_count"] = d["_email_count"] + frappe.db.count(
"Communication",
filters={
"reference_doctype": doctype,
"reference_name": d.get("name"),
"communication_type": "Automated Message",
},
)
d["_comment_count"] = frappe.db.count(
"Comment",
filters={"reference_doctype": doctype, "reference_name": d.get("name"), "comment_type": "Comment"},
)
d["_task_count"] = frappe.db.count(
"CRM Task", filters={"reference_doctype": doctype, "reference_docname": d.get("name")}
)
d["_note_count"] = frappe.db.count(
"FCRM Note", filters={"reference_doctype": doctype, "reference_docname": d.get("name")}
)
return d
@frappe.whitelist()
def get_linked_docs_of_document(doctype: str, docname: str):
try:
doc = frappe.get_doc(doctype, docname)
except frappe.DoesNotExistError:
return []
linked_docs = get_linked_docs(doc)
dynamic_linked_docs = get_dynamic_linked_docs(doc)
linked_docs.extend(dynamic_linked_docs)
linked_docs = list({doc["reference_docname"]: doc for doc in linked_docs}.values())
docs_data = []
for doc in linked_docs:
if not doc.get("reference_doctype") or not doc.get("reference_docname"):
continue
try:
data = frappe.get_doc(doc["reference_doctype"], doc["reference_docname"])
except (frappe.DoesNotExistError, frappe.ValidationError):
continue
title = data.get("title")
if data.doctype == "CRM Call Log":
title = f"Call from {data.get('from')} to {data.get('to')}"
if data.doctype == "CRM Deal":
title = data.get("organization")
if data.doctype == "CRM Notification":
title = data.get("message")
docs_data.append(
{
"doc": data.doctype,
"title": title or data.get("name"),
"reference_docname": doc["reference_docname"],
"reference_doctype": doc["reference_doctype"],
}
)
return docs_data
def remove_doc_link(doctype, docname):
if not doctype or not docname:
return
try:
linked_doc_data = frappe.get_doc(doctype, docname)
if doctype == "CRM Notification":
delete_notification_type = {
"notification_type_doctype": "",
"notification_type_doc": "",
}
delete_references = {
"reference_doctype": "",
"reference_name": "",
}
if linked_doc_data.get("notification_type_doctype") == linked_doc_data.get("reference_doctype"):
delete_references.update(delete_notification_type)
linked_doc_data.update(delete_references)
else:
linked_doc_data.update(
{
"reference_doctype": "",
"reference_docname": "",
}
)
linked_doc_data.save(ignore_permissions=True)
except (frappe.DoesNotExistError, frappe.ValidationError):
pass
def remove_contact_link(doctype, docname):
if not doctype or not docname:
return
try:
linked_doc_data = frappe.get_doc(doctype, docname)
linked_doc_data.update(
{
"contact": None,
"contacts": [],
}
)
linked_doc_data.save(ignore_permissions=True)
except (frappe.DoesNotExistError, frappe.ValidationError):
pass
@frappe.whitelist()
def remove_linked_doc_reference(items: str | list, remove_contact: bool = False, delete: bool = False):
if isinstance(items, str):
items = frappe.parse_json(items)
for item in items:
if not item.get("doctype") or not item.get("docname"):
continue
if not frappe.has_permission(item["doctype"], "write", item["docname"]):
continue
try:
if remove_contact:
remove_contact_link(item["doctype"], item["docname"])
else:
remove_doc_link(item["doctype"], item["docname"])
if delete:
frappe.delete_doc(item["doctype"], item["docname"])
except (frappe.DoesNotExistError, frappe.ValidationError):
# Skip if document doesn't exist or has validation errors
continue
return "success"
@frappe.whitelist()
def delete_bulk_docs(doctype: str, items: str | list, delete_linked: bool = False):
from frappe.desk.reportview import delete_bulk
if not doctype:
frappe.throw(_("Doctype is required"))
if not items:
frappe.throw(_("Items are required"))
items = frappe.parse_json(items)
if not isinstance(items, list):
frappe.throw(_("Items must be a list"))
for doc in items:
try:
if not frappe.db.exists(doctype, doc):
frappe.log_error(f"Document {doctype} {doc} does not exist", "Bulk Delete Error")
continue
linked_docs = get_linked_docs_of_document(doctype, doc)
for linked_doc in linked_docs:
if not linked_doc.get("reference_doctype") or not linked_doc.get("reference_docname"):
continue
remove_linked_doc_reference(
[
{
"doctype": linked_doc["reference_doctype"],
"docname": linked_doc["reference_docname"],
}
],
remove_contact=doctype == "Contact",
delete=delete_linked,
)
except Exception as e:
frappe.log_error(f"Error processing linked docs for {doctype} {doc}: {e!s}", "Bulk Delete Error")
if len(items) > 10:
frappe.enqueue("frappe.desk.reportview.delete_bulk", doctype=doctype, items=items)
else:
delete_bulk(doctype, items)
return "success"

372
crm/api/event.py Normal file
View File

@@ -0,0 +1,372 @@
"""
Event notification handling for CRM.
This module handles event notifications for the CRM system, supporting both:
1. Custom event notifications set on individual events
2. Global default notifications from CRM Settings for events without custom notifications
The notification system processes events that are either:
- Starting from now or in the future (normal case)
- Currently in progress (between starts_on and ends_on) to handle delayed notifications
Global notifications are configured in CRM Settings under the Calendar tab and are applied
to events that don't have custom notifications set. This ensures all events can receive
reminders even if users don't configure them individually.
"""
from datetime import datetime, timedelta
import frappe
from frappe.utils import add_to_date, now_datetime
def trigger_offset_event_notifications():
"""Trigger event notifications for offset-based intervals (minutes)."""
_process_event_notifications_by_interval("minutes")
_process_event_notifications_by_interval("hours")
def trigger_hourly_event_notifications():
"""Trigger event notifications for hourly intervals."""
_process_event_notifications_by_interval("hours")
def trigger_daily_event_notifications():
"""Trigger event notifications for daily intervals."""
_process_event_notifications_by_interval("days")
def trigger_weekly_event_notifications():
"""Trigger event notifications for weekly intervals."""
_process_event_notifications_by_interval("weeks")
def _process_event_notifications_by_interval(interval):
"""
Process event notifications for a specific interval.
Args:
interval (str): The interval type ('minutes', 'hours', 'days', 'weeks')
"""
if frappe.flags.in_import or frappe.flags.in_patch:
return
current_time = now_datetime()
current_user = frappe.session.user
all_events_data = frappe.db.sql(
"""
SELECT
e.name as event_name,
e.subject,
e.starts_on,
e.ends_on,
e.owner,
e.description,
e.all_day as all_day_event,
en.type as notification_type,
en.before as before_value,
en.time as time_of_day,
en.interval as notification_interval,
ep.email as participant_email,
ep_all.participant_emails_csv,
CASE WHEN en.parent IS NULL THEN 0 ELSE 1 END as has_custom_notifications
FROM `tabEvent` e
LEFT JOIN `tabEvent Notifications` en ON e.name = en.parent AND en.interval = %s
LEFT JOIN `tabEvent Participants` ep ON e.name = ep.parent AND ep.email = %s
LEFT JOIN (
SELECT parent, GROUP_CONCAT(email) AS participant_emails_csv
FROM `tabEvent Participants`
GROUP BY parent
) AS ep_all ON ep_all.parent = e.name
WHERE (e.starts_on >= %s OR (%s >= e.starts_on AND %s < e.ends_on))
AND (e.owner = %s OR ep.email = %s)
AND e.status != 'Cancelled'
ORDER BY e.starts_on, e.name
""",
(interval, current_user, current_time, current_time, current_time, current_user, current_user),
as_dict=True,
)
for event_data in all_events_data:
participant_emails_csv = event_data.pop("participant_emails_csv", None)
event_data["event_participants"] = _split_participant_emails(participant_emails_csv)
notifications = _process_unified_event_data(all_events_data, interval)
for notification in notifications:
try:
event_start = notification.get("starts_on")
event_end = notification.get("ends_on")
before_value = notification.get("before_value", 1)
trigger_datetime = _calculate_trigger_datetime(
event_start,
before_value,
interval,
notification.get("all_day_event"),
notification.get("time_of_day"),
)
event_is_in_progress = event_start <= current_time < event_end
trigger_time_passed = current_time > trigger_datetime
if event_is_in_progress and trigger_time_passed:
trigger_window_start = trigger_datetime
trigger_window_end = event_end
else:
window_duration = _get_trigger_window_duration(interval)
trigger_window_start = add_to_date(
trigger_datetime, **{k: -v for k, v in window_duration.items()}
)
trigger_window_end = add_to_date(trigger_datetime, **window_duration)
if not (trigger_window_start <= current_time <= trigger_window_end):
continue
if notification.get("notification_type") == "Email":
_send_email_notification(notification, event_start, before_value, interval)
elif notification.get("notification_type") == "Notification":
_send_system_notification(notification)
except Exception as e:
frappe.log_error(
f"Error processing {interval} notification for event {notification.get('event_name', 'Unknown')}: {e!s}"
)
continue
def _process_unified_event_data(all_events_data, interval):
"""
Process unified event data that includes both events with and without custom notifications.
Args:
all_events_data (list): List of event data from the unified query
interval (str): The interval type ('minutes', 'hours', 'days', 'weeks')
Returns:
list: List of processed notifications ready for sending
"""
notifications = []
events_without_notifications = []
for event_data in all_events_data:
if event_data.get("has_custom_notifications") == 1:
notifications.append(event_data)
else:
event_key = event_data.get("event_name")
if not any(e.get("event_name") == event_key for e in events_without_notifications):
events_without_notifications.append(event_data)
if events_without_notifications:
global_notifications = _apply_global_notifications_to_events(events_without_notifications, interval)
notifications.extend(global_notifications)
return notifications
def _apply_global_notifications_to_events(events_without_notifications, interval):
"""
Apply global CRM Settings notifications to events that don't have custom notifications.
Args:
events_without_notifications (list): List of events without custom notifications
interval (str): The interval type ('minutes', 'hours', 'days', 'weeks')
Returns:
list: List of notification dictionaries using global settings
"""
fcrm_settings = frappe.get_single("FCRM Settings")
global_notifications = []
if hasattr(fcrm_settings, "event_notifications"):
for notification in fcrm_settings.event_notifications:
if notification.interval == interval:
notification._table_type = "regular"
global_notifications.append(notification)
if hasattr(fcrm_settings, "all_day_event_notifications"):
for notification in fcrm_settings.all_day_event_notifications:
if notification.interval == interval:
notification._table_type = "all_day"
global_notifications.append(notification)
if not global_notifications:
return []
notifications = []
for event in events_without_notifications:
for global_notification in global_notifications:
if (global_notification._table_type == "all_day" and not event.all_day_event) or (
global_notification._table_type == "regular" and event.all_day_event
):
continue
notification = {
"event_name": event.event_name,
"notification_type": global_notification.type,
"before_value": global_notification.before,
"time_of_day": global_notification.time,
"subject": event.subject,
"starts_on": event.starts_on,
"ends_on": event.ends_on,
"owner": event.owner,
"description": event.description,
"all_day_event": event.all_day_event,
"event_participants": event.get("event_participants", []),
}
notifications.append(notification)
return notifications
def _calculate_trigger_datetime(event_start, before_value, interval, all_day_event, time_of_day):
"""
Calculate when the notification should be triggered based on event start time and interval.
Args:
event_start (datetime): Event start datetime
before_value (int): How many units before the event
interval (str): The interval type ('minutes', 'hours', 'days', 'weeks')
all_day_event (bool): Whether this is an all-day event
time_of_day (time): Specific time to send notification for all-day events
Returns:
datetime: When the notification should be triggered
"""
if all_day_event and time_of_day and interval in ["days", "weeks"]:
if interval == "days":
trigger_date = event_start.date() - timedelta(days=before_value)
elif interval == "weeks":
trigger_date = event_start.date() - timedelta(weeks=before_value)
trigger_datetime = datetime.combine(trigger_date, time_of_day)
else:
interval_kwargs = _get_interval_kwargs(interval, before_value)
trigger_datetime = add_to_date(event_start, **interval_kwargs)
return trigger_datetime
def _get_interval_kwargs(interval, before_value):
"""
Get the appropriate keyword arguments for add_to_date based on interval type.
Args:
interval (str): The interval type ('minutes', 'hours', 'days', 'weeks')
before_value (int): How many units before the event
Returns:
dict: Keyword arguments for add_to_date with negative values
"""
interval_mapping = {
"minutes": {"minutes": -before_value},
"hours": {"hours": -before_value},
"days": {"days": -before_value},
"weeks": {"weeks": -before_value},
}
return interval_mapping.get(interval, {"hours": -before_value})
def _get_trigger_window_duration(interval):
"""
Get the trigger window duration based on interval type.
Args:
interval (str): The interval type ('minutes', 'hours', 'days', 'weeks')
Returns:
dict: Window duration to be used symmetrically around the trigger time
"""
window_mapping = {
"minutes": {"minutes": 5},
"hours": {"hours": 1},
"days": {"hours": 8},
"weeks": {"days": 4},
}
return window_mapping.get(interval, {"hours": 1})
def _split_participant_emails(participant_emails_csv):
"""Return a clean list of participant emails from a comma-separated string."""
if not participant_emails_csv:
return []
return [email.strip() for email in participant_emails_csv.split(",") if email and email.strip()]
def _send_email_notification(notification, event_start, before_value, interval):
"""Send email notification for an event"""
try:
recipients = set()
subject = f"Event Reminder: {notification.subject}"
if notification.owner and notification.owner != "Administrator":
recipients.add(notification.owner)
participant_emails = notification.get("event_participants") or []
if participant_emails:
recipients.update(participant_emails)
else:
event_doc = frappe.get_doc("Event", notification.event_name)
for participant in event_doc.get("event_participants", []):
email = getattr(participant, "email", None)
if email:
recipients.add(email)
recipients = [email for email in recipients if email]
if not recipients:
return
time_remaining_text = _format_time_remaining(before_value, interval)
message = f"""
<div style="font-family: Arial, sans-serif; max-width: 600px;">
<h2 style="color: #333;">Event Reminder</h2>
<p>This is a reminder for your upcoming event:</p>
<div style="background-color: #f8f9fa; padding: 15px; border-left: 4px solid #007bff; margin: 20px 0;">
<h3 style="margin: 0; color: #007bff;">{notification.subject}</h3>
{f'<p style="margin: 10px 0; color: #666;">{notification.description}</p>' if notification.description else ""}
<p style="margin: 5px 0;"><strong>Start Time:</strong> {event_start.strftime("%Y-%m-%d %H:%M:%S")}</p>
<p style="margin: 5px 0;"><strong>Time Remaining:</strong> {time_remaining_text}</p>
</div>
<p style="color: #666; font-size: 12px;">This is an automated reminder from your calendar system.</p>
</div>
"""
frappe.sendmail(
recipients=recipients,
subject=subject,
message=message,
reference_doctype="Event",
reference_name=notification.event_name,
now=True,
)
except Exception as e:
frappe.log_error(f"Failed to send email for event {notification.event_name}: {e!s}")
def _format_time_remaining(before_value, interval):
"""
Format the time remaining message based on the interval and before value.
Args:
before_value (int): The number of units before the event
interval (str): The interval type ('minutes', 'hours', 'days', 'weeks')
Returns:
str: Formatted time remaining message
"""
interval_labels = {"minutes": "minute(s)", "hours": "hour(s)", "days": "day(s)", "weeks": "week(s)"}
interval_label = interval_labels.get(interval, "unit(s)")
return f"{before_value} {interval_label}"
def _send_system_notification(notification):
"""Send system notification for an event"""
frappe.publish_realtime("event_notification", notification)

158
crm/api/exchange_rate.py Normal file
View File

@@ -0,0 +1,158 @@
import frappe
import requests
from frappe import _
from crm.fcrm.doctype.fcrm_settings.fcrm_settings import FCRMSettings
@frappe.whitelist()
def get_exchange_rate(from_currency: str, to_currency: str, date: str | None = None):
if not date:
date = "latest"
# "latest" is keyed by today's date so tomorrow's call automatically misses the cache
cache_date = frappe.utils.today() if date == "latest" else date
cache_key = f"exchange_rate_{from_currency}_{to_currency}_{cache_date}"
cached_rate = frappe.cache().get_value(cache_key)
if cached_rate is not None:
return cached_rate
rate, api_used = _fetch_exchange_rate(from_currency, to_currency, date)
if rate is not None:
frappe.cache().set_value(cache_key, rate)
return rate
_raise_exchange_rate_error(from_currency, to_currency, date, api_used)
def _fetch_exchange_rate(from_currency: str, to_currency: str, date: str):
settings = frappe.get_single("FCRM Settings")
provider = settings.service_provider
# Paid providers — no fallback, fail explicitly
if provider == "exchangerate.host":
return _fetch_from_exchangerate_host(settings, from_currency, to_currency, date), provider
if provider == "exchangerate-api":
return _fetch_from_exchangerate_api(settings, from_currency, to_currency), provider
# Free providers — try both as fallbacks, regardless of which is "primary"
if provider == "frankfurter.app":
rate = _fetch_from_frankfurter(from_currency, to_currency, date)
if rate is not None:
return rate, provider
# Frankfurter is down — silently fall back to fawaz
rate = _fetch_from_fawaz_api(from_currency, to_currency, date)
return rate, "fawazahmed-exchange-api"
if provider == "fawazahmed-exchange-api":
rate = _fetch_from_fawaz_api(from_currency, to_currency, date)
if rate is not None:
return rate, provider
# fawaz is down — silently fall back to frankfurter
rate = _fetch_from_frankfurter(from_currency, to_currency, date)
return rate, "frankfurter"
# Unknown provider — try both free ones
rate = _fetch_from_frankfurter(from_currency, to_currency, date)
if rate is not None:
return rate, "frankfurter"
return _fetch_from_fawaz_api(from_currency, to_currency, date), "fawazahmed-exchange-api"
def _fetch_from_frankfurter(from_currency: str, to_currency: str, date: str):
try:
res = requests.get(
f"https://api.frankfurter.app/{date}?from={from_currency}&to={to_currency}", timeout=5
)
if res.ok:
return res.json().get("rates", {}).get(to_currency)
except Exception:
pass
return None
def _fetch_from_fawaz_api(from_currency: str, to_currency: str, date: str):
from_lower = from_currency.lower()
to_lower = to_currency.lower()
date_str = "latest" if date == "latest" else date
urls = [
f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{date_str}/v1/currencies/{from_lower}.json",
f"https://{date_str}.currency-api.pages.dev/v1/currencies/{from_lower}.json",
]
for url in urls:
try:
res = requests.get(url, timeout=5)
if res.ok:
return res.json()[from_lower][to_lower]
except Exception:
continue
return None
def _fetch_from_exchangerate_host(settings: FCRMSettings, from_currency: str, to_currency: str, date: str):
if not settings.access_key:
frappe.throw(
_("Access Key is required for Service Provider: {0}").format(
frappe.bold(settings.service_provider)
)
)
params = {"access_key": settings.access_key, "from": from_currency, "to": to_currency, "amount": 1}
if date != "latest":
params["date"] = date
res = requests.get("https://api.exchangerate.host/convert", params=params, timeout=5)
if res.ok:
return res.json()["result"]
return None
def _fetch_from_exchangerate_api(settings: FCRMSettings, from_currency: str, to_currency: str):
if not settings.access_key:
frappe.throw(
_("Access Key is required for Service Provider: {0}").format(
frappe.bold(settings.service_provider)
)
)
res = requests.get(
f"https://v6.exchangerate-api.com/v6/{settings.access_key}/pair/{from_currency}/{to_currency}",
timeout=5,
)
if res.ok:
data = res.json()
if data["result"] == "success":
return data["conversion_rate"]
return None
def _raise_exchange_rate_error(from_currency: str, to_currency: str, date: str, api_used: str):
frappe.log_error(
title="Exchange Rate Fetch Error",
message=f"Failed to fetch exchange rate from {from_currency} to {to_currency} using {api_used} API.",
)
if api_used == "frankfurter":
user = frappe.session.user
is_manager = (
"System Manager" in frappe.get_roles(user)
or "Sales Manager" in frappe.get_roles(user)
or user == "Administrator"
)
if not is_manager:
frappe.throw(
_(
"Ask your manager to set up the Exchange Rate Provider, as default provider does not support currency conversion for {0} to {1}."
).format(from_currency, to_currency)
)
frappe.throw(
_(
"Setup the Exchange Rate Provider other than 'Frankfurter' in settings, as default provider does not support currency conversion for {0} to {1}."
).format(from_currency, to_currency)
)
frappe.throw(
_(
"Failed to fetch exchange rate from {0} to {1} on {2}. Please check your internet connection or try again later."
).format(from_currency, to_currency, date)
)

638
crm/api/form.py Normal file
View File

@@ -0,0 +1,638 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
"""Hybrid forms API.
Storage/submission/validation is delegated to Frappe's built-in `Web Form`
doctype; this module only:
1. curates which target fields a CRM form may collect,
2. gives the CRM builder a small, scoped CRUD surface over Web Form records,
3. re-applies CRM-specific enrichment (source, organization, primary contact)
on submission, which the framework's generic insert does not do.
"""
import json
import frappe
from frappe import _
from frappe.utils.telemetry import capture
ALLOWED_DOCTYPES = ("CRM Lead", "CRM Deal")
FORM_SOURCE = "Web Form"
FORM_MODULE = "FCRM"
def ensure_form_source() -> str:
"""Return the 'Web Form' CRM Lead Source, creating it once if needed."""
if not frappe.db.exists("CRM Lead Source", FORM_SOURCE):
frappe.get_doc({"doctype": "CRM Lead Source", "source_name": FORM_SOURCE}).insert(
ignore_permissions=True
)
return FORM_SOURCE
# Fieldtypes a form can render/collect. A `Link` renders as a server-populated,
# pick-only dropdown (see crm_form.py); excludes types needing a live widget or file
# upload (Dynamic Link, Table, Attach, …).
SUPPORTED_FIELDTYPES = (
"Data",
"Small Text",
"Text",
"Long Text",
"Text Editor",
"HTML Editor",
"Markdown Editor",
"Select",
"Link",
"Int",
"Float",
"Currency",
"Percent",
"Check",
"Date",
"Datetime",
"Time",
"Phone",
"Color",
)
# Never expose these as mappable fields even if their type is supported.
DENIED_FIELDNAMES = (
"naming_series",
# a mandatory Link the system sets — seeded as a hidden field with a default, not
# offered in the picker.
"status",
"lead_name",
"converted",
"sla_status",
"response_by",
"first_response_time",
"first_responded_on",
"facebook_form_id",
"facebook_lead_id",
)
LAYOUT_BREAKS = ("Section Break", "Column Break")
# Starting layout of a brand-new form, per target doctype: labelled sections,
# each a list of columns, each column a list of fieldnames. A sensible
# contact-capture starting point the author can then edit.
SEED_LAYOUT = {
"CRM Lead": [
{
"label": "Personal Details",
"columns": [["first_name", "email"], ["last_name", "phone"]],
},
],
"CRM Deal": [
{
"label": "Personal Details",
"columns": [["first_name", "email"], ["last_name", "phone"]],
},
{
"label": "Organization Details",
"columns": [["organization_name"]],
},
],
}
def _seeded_visible_fieldnames(document_type: str) -> set:
names = set()
for section in SEED_LAYOUT.get(document_type, []):
for col in section["columns"]:
names.update(col)
return names
def guest_can_select(doctype: str) -> bool:
"""Whether Guest may reference `doctype` in a Link field. Gates on `select` (never
`read`) — Frappe's minimal tier that lists record names without exposing full
documents — so a doctype is linkable only when Guest select is deliberately granted."""
return bool(doctype) and frappe.has_permission(doctype, ptype="select", user="Guest")
def _link_target_doctypes() -> set:
"""Doctypes reachable via a Link field on any form-mappable target — the only
doctypes `grant_guest_link_access` is allowed to touch, so the endpoint can't be
used to open Guest select on an arbitrary doctype."""
targets = set()
for document_type in ALLOWED_DOCTYPES:
for df in frappe.get_meta(document_type).fields:
if df.fieldtype == "Link" and df.options:
targets.add(df.options)
return targets
def _mappable_fields(document_type: str) -> list[dict]:
"""Fields of a target DocType a form may collect (shared by the picker and by
the brand-new-form seeding)."""
meta = frappe.get_meta(document_type)
fields = []
for df in meta.fields:
if df.fieldtype not in SUPPORTED_FIELDTYPES:
continue
if not df.fieldname or df.fieldname in DENIED_FIELDNAMES:
continue
if df.hidden or df.read_only:
continue
# a Link is offered even when guests can't select the target yet; the builder
# warns and offers a one-click grant (see grant_guest_link_access).
fields.append(
{
"fieldname": df.fieldname,
"label": df.label or df.fieldname,
"fieldtype": df.fieldtype,
"options": df.options,
"reqd": df.reqd,
"default": df.default,
}
)
return fields
def _default_status(document_type: str) -> str | None:
"""The status a new Lead/Deal defaults to — mirrors the doctype controllers so
the hidden 'Status' field is pre-filled with the value the CRM would use."""
status_dt = "CRM Lead Status" if document_type == "CRM Lead" else "CRM Deal Status"
preferred = "New" if document_type == "CRM Lead" else "Qualification"
if frappe.db.exists(status_dt, preferred):
return preferred
rows = frappe.get_all(status_dt, {"type": "Open"}, pluck="name")
return rows[0] if rows else None
def _seed_visible_fields(document_type: str) -> list[dict]:
"""Web-form rows for a new form's visible layout: the curated contact set,
arranged into labelled sections and columns (with Section/Column breaks)."""
catalog = {f["fieldname"]: f for f in _mappable_fields(document_type)}
def _break(fieldtype, i, label=""):
prefix = "section_break" if fieldtype == "Section Break" else "column_break"
return {
"fieldname": f"{prefix}_seed{i}",
"label": label,
"fieldtype": fieldtype,
"options": "",
"reqd": 0,
"placeholder": "",
"field_description": "",
}
rows = []
n = 0
for section in SEED_LAYOUT.get(document_type, []):
n += 1
rows.append(_break("Section Break", n, section.get("label") or ""))
for ci, col in enumerate(section["columns"]):
if ci > 0:
n += 1
rows.append(_break("Column Break", n))
for fn in col:
f = catalog.get(fn)
if not f:
continue
rows.append(
{
"fieldname": f["fieldname"],
"label": f["label"],
"fieldtype": f["fieldtype"],
"options": f["options"],
"reqd": 1 if f["reqd"] else 0,
"placeholder": "",
"field_description": "",
}
)
return rows
def _seed_hidden_fields(document_type: str) -> list[dict]:
"""Mandatory fields a public visitor should not fill (e.g. Status, a Link the
system sets). Seeded into the hidden section with a sensible default so the
author sees them and can override the value applied on submission."""
meta = frappe.get_meta(document_type)
visible = _seeded_visible_fieldnames(document_type)
hidden = []
for df in meta.fields:
if not df.reqd or df.fieldname in visible:
continue
if df.fieldtype in SUPPORTED_FIELDTYPES and df.fieldname not in DENIED_FIELDNAMES:
continue # a fillable mandatory field belongs in the visible layout
default = _default_status(document_type) if df.fieldname == "status" else (df.default or "")
hidden.append(
{
"fieldname": df.fieldname,
"label": df.label or df.fieldname,
"fieldtype": df.fieldtype,
"options": df.options or "",
"default": default,
}
)
return hidden
def _check_manager():
"""CRM forms are managed by CRM managers, not Website Managers. Gate here and
then write the Web Form with ignore_permissions (the role mismatch, in code)."""
roles = set(frappe.get_roles())
if not roles & {"System Manager", "Sales Manager"}:
frappe.throw(_("Not permitted"), frappe.PermissionError)
@frappe.whitelist()
def get_form_fields(document_type: str) -> list[dict]:
"""Mappable fields of a target DocType, for the builder's field picker."""
if document_type not in ALLOWED_DOCTYPES:
frappe.throw(_("Forms can only map to: {0}").format(", ".join(ALLOWED_DOCTYPES)))
return _mappable_fields(document_type)
@frappe.whitelist()
def get_hidden_seed(document_type: str) -> list[dict]:
"""The system-managed hidden required fields for a doctype (e.g. Status) with
their default value. Used by the builder to reconcile on a doctype switch."""
if document_type not in ALLOWED_DOCTYPES:
frappe.throw(_("Forms can only map to: {0}").format(", ".join(ALLOWED_DOCTYPES)))
return _seed_hidden_fields(document_type)
@frappe.whitelist()
def link_field_guest_access(doctype: str) -> dict:
"""Whether guests can already select `doctype`. Drives the builder's Link-field
notice: if False, the author is warned that the public dropdown will be empty and
is offered a one-click grant (grant_guest_link_access)."""
_check_manager()
return {"doctype": doctype, "guest_can_select": guest_can_select(doctype)}
@frappe.whitelist()
def grant_guest_link_access(doctype: str) -> dict:
"""Grant Guest `select` on `doctype` so a public Link dropdown can list its records — a
deliberate choice by a form manager (the builder warns that anyone with the form link
will then see them). Limited to doctypes that are Link targets on a CRM form; stored as
a site-level Custom DocPerm."""
_check_manager()
if doctype not in _link_target_doctypes():
frappe.throw(_("{0} isn't a linkable field on a CRM form.").format(doctype))
if not guest_can_select(doctype):
from frappe.permissions import add_permission
# a Sales Manager may run the builder but can't normally write Custom DocPerm; do
# this narrow, doctype-scoped grant with permission checks off (as this module
# already does to write the Web Form).
had_flag = frappe.flags.ignore_permissions
frappe.flags.ignore_permissions = True
try:
perm_name = frappe.db.get_value(
"Custom DocPerm",
{"parent": doctype, "role": "Guest", "permlevel": 0, "if_owner": 0},
)
if not perm_name:
add_permission(doctype, "Guest", 0, ptype="select")
perm_name = frappe.db.get_value(
"Custom DocPerm",
{"parent": doctype, "role": "Guest", "permlevel": 0, "if_owner": 0},
)
# add_permission defaults the row to read/export; a public Link only lists
# names, so trim the Guest row to `select` alone.
perm = frappe.get_doc("Custom DocPerm", perm_name)
perm.update(
{
"select": 1,
"read": 0,
"write": 0,
"create": 0,
"delete": 0,
"submit": 0,
"cancel": 0,
"amend": 0,
"report": 0,
"export": 0,
"import": 0,
"print": 0,
"email": 0,
"share": 0,
}
)
perm.save(ignore_permissions=True)
finally:
frappe.flags.ignore_permissions = had_flag
frappe.clear_cache(doctype=doctype)
return {"doctype": doctype, "guest_can_select": guest_can_select(doctype)}
@frappe.whitelist()
def list_forms() -> list[dict]:
"""CRM forms only (native Web Form records mapped to Lead/Deal)."""
_check_manager()
return frappe.get_all(
"Web Form",
filters={"module": FORM_MODULE, "doc_type": ["in", ALLOWED_DOCTYPES]},
fields=[
"name",
"title",
"route",
"doc_type as document_type",
"crm_published as published",
"modified",
],
order_by="modified desc",
)
@frappe.whitelist()
def get_form_config(name: str) -> dict:
"""Full config for the builder, read from the native Web Form record."""
_check_manager()
doc = _get_crm_form(name)
return {
"name": doc.name,
"title": doc.title,
"route": doc.route,
"document_type": doc.doc_type,
"published": doc.crm_published,
"submit_button_label": doc.button_label or _("Submit"),
"description": doc.introduction_text or "",
"success_message": doc.success_message or "",
"redirect_url": doc.success_url or "",
"allowed_embedding_domains": doc.allowed_embedding_domains or "",
"fields": [
{
"fieldname": f.fieldname,
"label": f.label,
"fieldtype": f.fieldtype,
"options": f.options,
"reqd": f.reqd,
"placeholder": f.placeholder,
"field_description": f.description,
"depends_on": f.depends_on or "",
"mandatory_depends_on": f.mandatory_depends_on or "",
"read_only_depends_on": f.read_only_depends_on or "",
}
for f in doc.web_form_fields
],
"hidden_fields": _load_hidden_fields(doc),
}
def _load_hidden_fields(doc) -> list[dict]:
try:
return json.loads(doc.get("crm_hidden_defaults") or "[]")
except Exception:
return []
def _assert_hidden_defaults_set(hidden: list[dict]):
"""Publishing requires every hidden required field to carry a default — a blank
one would break record creation on submission. Drafts may save without them."""
missing = [
h.get("label") or h.get("fieldname") for h in hidden if not str(h.get("default") or "").strip()
]
if missing:
frappe.throw(_("Set a default value before publishing for: {0}").format(", ".join(missing)))
def _validated_visible_fields(document_type: str, fields: list[dict]) -> list[dict]:
"""Re-apply the catalog the picker filters by: `accept()` lets a public visitor
write anything listed in `web_form_fields`."""
meta = frappe.get_meta(document_type)
catalog = {f["fieldname"]: f for f in _mappable_fields(document_type)}
rows = []
for f in fields:
fieldname = f.get("fieldname")
if f.get("fieldtype") in LAYOUT_BREAKS:
# a break carries no data, but `accept()` still writes any row naming a field
if meta.has_field(fieldname):
frappe.throw(_("{0} can't be used as a layout break").format(fieldname))
rows.append(f)
continue
allowed = catalog.get(fieldname)
if not allowed:
frappe.throw(_("{0} can't be collected by a form").format(fieldname or _("Unnamed field")))
rows.append({**f, "fieldtype": allowed["fieldtype"], "options": allowed["options"]})
return rows
def _validated_hidden_fields(document_type: str, hidden: list[dict]) -> list[dict]:
"""Same gate for the hidden section: `_apply_hidden_defaults` writes these onto
every submission."""
catalog = {h["fieldname"]: h for h in _seed_hidden_fields(document_type)}
rows = []
for h in hidden:
fieldname = h.get("fieldname")
allowed = catalog.get(fieldname)
if not allowed:
frappe.throw(_("{0} can't be set as a hidden field").format(fieldname or _("Unnamed field")))
rows.append({**h, "fieldtype": allowed["fieldtype"], "options": allowed["options"]})
return rows
@frappe.whitelist()
def save_form(name: str | None, form: dict | str) -> dict:
"""Create/update a native Web Form scoped to CRM doctypes."""
_check_manager()
if isinstance(form, str):
form = json.loads(form or "{}")
if form.get("document_type") not in ALLOWED_DOCTYPES:
frappe.throw(_("Forms can only map to: {0}").format(", ".join(ALLOWED_DOCTYPES)))
doc = _get_crm_form(name) if name else frappe.new_doc("Web Form")
doc.title = form.get("title")
doc.route = form.get("route")
doc.doc_type = form.get("document_type")
doc.introduction_text = form.get("description")
doc.button_label = form.get("submit_button_label") or "Submit"
doc.success_message = form.get("success_message")
# redirect visitors here after a successful submission (native Web Form field);
# blank falls back to showing the success message
doc.success_url = form.get("redirect_url") or ""
doc.allowed_embedding_domains = form.get("allowed_embedding_domains")
# `crm_published` drives the CRM's own branded page at /crm-form/<route>; mirror
# it onto the native `published` so the Desk Web Form view shows the right state.
# (This also makes Frappe serve its generic form at /<route> — an additional,
# unbranded public URL alongside /crm-form/<route>.)
doc.crm_published = 1 if form.get("published") else 0
doc.published = doc.crm_published
# CRM forms are public, single-purpose lead/deal capture
doc.login_required = 0
doc.allow_multiple = 1
doc.is_standard = 0
doc.module = FORM_MODULE
# brand-new form → seed a starting contact-capture layout (only on create)
fields = form.get("fields")
if not name and not fields:
fields = _seed_visible_fields(form["document_type"])
# only rewrite the layout when fields were actually sent — an update that omits
# `fields` (e.g. a settings-only save) must not wipe the existing layout
if fields is not None:
fields = _validated_visible_fields(form["document_type"], fields)
doc.set("web_form_fields", [])
for i, f in enumerate(fields):
doc.append(
"web_form_fields",
{
"fieldname": f.get("fieldname"),
"label": f.get("label"),
"fieldtype": f.get("fieldtype"),
"options": f.get("options"),
"reqd": 1 if f.get("reqd") else 0,
"placeholder": f.get("placeholder"),
"description": f.get("field_description"),
# conditional-logic rules authored per field in the builder (same as
# the framework's Web Form fields; evaluated client-side)
"depends_on": f.get("depends_on") or "",
"mandatory_depends_on": f.get("mandatory_depends_on") or "",
"read_only_depends_on": f.get("read_only_depends_on") or "",
"idx": i + 1,
},
)
# hidden required fields: mandatory fields kept out of the visible form, with
# the default value applied on submission (see enrich_form_submission). Seeded
# once on create (e.g. Status); thereafter whatever the builder sends wins.
hidden = form.get("hidden_fields")
if hidden is None and not name:
hidden = _seed_hidden_fields(form["document_type"])
if isinstance(hidden, str):
hidden = json.loads(hidden or "[]")
hidden = _validated_hidden_fields(form["document_type"], hidden or [])
if doc.crm_published:
_assert_hidden_defaults_set(hidden)
doc.crm_hidden_defaults = json.dumps(hidden) if hidden else ""
doc.save(ignore_permissions=True)
return {"name": doc.name, "route": doc.route}
def _get_crm_form(name: str):
doc = frappe.get_doc("Web Form", name)
# scope to CRM's own forms — a Web Form from another app that happens to target
# CRM Lead/Deal must not be readable/mutable/deletable through this API
if doc.module != FORM_MODULE or doc.doc_type not in ALLOWED_DOCTYPES:
frappe.throw(_("Not a CRM form"))
return doc
@frappe.whitelist()
def set_published(name: str, published: int) -> None:
"""Publish/unpublish from the list, bypassing native Web Form role perms."""
_check_manager()
doc = _get_crm_form(name)
if int(published):
_assert_hidden_defaults_set(_load_hidden_fields(doc))
doc.crm_published = 1 if int(published) else 0
doc.published = doc.crm_published # mirror onto native flag (see save_form)
doc.save(ignore_permissions=True)
@frappe.whitelist()
def delete_form(name: str) -> None:
_check_manager()
_get_crm_form(name)
frappe.delete_doc("Web Form", name, ignore_permissions=True)
@frappe.whitelist()
def test_submit_form(name: str, values: dict | str) -> dict:
"""Dry-run a submission for an author previewing a draft: validate required
fields the same way the live form does, but create no record."""
_check_manager()
if isinstance(values, str):
values = json.loads(values or "{}")
doc = _get_crm_form(name)
for f in doc.web_form_fields:
if f.fieldtype in ("Section Break", "Column Break"):
continue
value = values.get(f.fieldname)
if f.reqd and (value is None or value == ""):
frappe.throw(_("{0} is required").format(f.label or f.fieldname))
return {"test": True}
# Public form serving + submission run through the framework's own Web Form engine:
# the CRM page (`www/crm_form.py`) renders the published form and posts to the
# built-in `accept()`, which triggers `enrich_form_submission` below via the
# `in_web_form` flag. No CRM-owned guest endpoint is needed for that path.
def enrich_form_submission(doc):
"""Called from the CRM Lead/Deal `before_insert`: when the record is created via a
web form, apply the same enrichment the CRM applies on manual creation.
The framework's Web Form `accept()` just inserts the target doc, so without this
web submissions would miss Source / Organization / primary Contact. `accept()`
sets `frappe.flags.in_web_form`, which is how we scope this to web submissions.
"""
if not frappe.flags.get("in_web_form"):
return
if doc.doctype not in ALLOWED_DOCTYPES:
return
_apply_hidden_defaults(doc)
# stamp the source so form records are identifiable/filterable
if doc.meta.has_field("source") and not doc.get("source"):
doc.source = ensure_form_source()
capture(
"web_form_submitted",
"crm",
properties={"doctype": doc.doctype, "form": frappe.form_dict.get("web_form")},
)
if doc.doctype != "CRM Deal":
return
from crm.fcrm.doctype.crm_deal.crm_deal import create_contact, create_organization
# auto-create the linked Organization from the org name (as the CRM does for deals)
if doc.get("organization_name") and not doc.get("organization"):
created = create_organization(doc)
if created:
doc.organization = created
# auto-create & link a primary Contact from the person's details
if not doc.get("contacts") and (
doc.get("first_name") or doc.get("last_name") or doc.get("email") or doc.get("mobile_no")
):
contact = create_contact(doc)
if contact:
doc.append("contacts", {"contact": contact, "is_primary": 1})
def _apply_hidden_defaults(doc):
"""Apply the submitting form's hidden-field defaults (e.g. Status) to fields the
visitor didn't fill, so mandatory values are present before the record is saved."""
web_form = frappe.form_dict.get("web_form")
if not web_form:
return
# `web_form` comes from the (client-controllable) POST body — only trust a CRM
# form that targets this exact doctype, else a submission could pull in another
# form's defaults (or a different doctype's).
form = frappe.db.get_value("Web Form", web_form, ["doc_type", "crm_hidden_defaults"], as_dict=True)
if not form or form.doc_type not in ALLOWED_DOCTYPES or form.doc_type != doc.doctype:
return
raw = form.crm_hidden_defaults
if not raw:
return
try:
hidden = json.loads(raw)
except Exception:
return
for h in hidden:
fieldname = h.get("fieldname")
default = h.get("default")
if (
fieldname
and default not in (None, "")
and doc.meta.has_field(fieldname)
and not doc.get(fieldname)
):
doc.set(fieldname, default)

34
crm/api/live_demo.py Normal file
View File

@@ -0,0 +1,34 @@
# Copyright (c) 2024, Frappe Technologies and contributors
# This file is used to handle live demo site (https://frappecrm-demo.frappe.cloud) related API calls and hooks
import frappe
from frappe import _
from frappe.auth import LoginManager
@frappe.whitelist(allow_guest=True)
def login():
if not frappe.conf.demo_username or not frappe.conf.demo_password:
return
frappe.local.response["redirect_to"] = "/crm"
login_manager = LoginManager()
login_manager.authenticate(frappe.conf.demo_username, frappe.conf.demo_password)
login_manager.post_login()
frappe.local.response["type"] = "redirect"
frappe.local.response["location"] = frappe.local.response["redirect_to"]
def validate_reset_password(doc, event):
if frappe.conf.demo_username and frappe.session.user == frappe.conf.demo_username:
frappe.throw(
_("Password cannot be reset by Demo User {}").format(frappe.bold(frappe.conf.demo_username)),
frappe.PermissionError,
)
def validate_user(doc, event):
if frappe.conf.demo_username and frappe.session.user == frappe.conf.demo_username and doc.new_password:
frappe.throw(
_("Password cannot be reset by Demo User {}").format(frappe.bold(frappe.conf.demo_username)),
frappe.PermissionError,
)

69
crm/api/notifications.py Normal file
View File

@@ -0,0 +1,69 @@
import frappe
from frappe.query_builder import Order
@frappe.whitelist()
def get_notifications():
Notification = frappe.qb.DocType("CRM Notification")
query = (
frappe.qb.from_(Notification)
.select("*")
.where(Notification.to_user == frappe.session.user)
.orderby("creation", order=Order.desc)
)
notifications = query.run(as_dict=True)
_notifications = []
for notification in notifications:
_notifications.append(
{
"creation": notification.creation,
"from_user": {
"name": notification.from_user,
"full_name": frappe.get_value("User", notification.from_user, "full_name"),
},
"type": notification.type,
"to_user": notification.to_user,
"read": notification.read,
"hash": get_hash(notification),
"notification_text": notification.notification_text,
"notification_type_doctype": notification.notification_type_doctype,
"notification_type_doc": notification.notification_type_doc,
"reference_doctype": ("deal" if notification.reference_doctype == "CRM Deal" else "lead"),
"reference_name": notification.reference_name,
"route_name": ("Deal" if notification.reference_doctype == "CRM Deal" else "Lead"),
}
)
return _notifications
@frappe.whitelist()
def mark_as_read(doc: str | None = None):
user = frappe.session.user
filters = {"to_user": user, "read": False}
or_filters = []
if doc:
or_filters = [
{"comment": doc},
{"notification_type_doc": doc},
]
for n in frappe.get_all("CRM Notification", filters=filters, or_filters=or_filters):
d = frappe.get_doc("CRM Notification", n.name)
d.read = True
d.save()
def get_hash(notification):
_hash = ""
if notification.type == "Mention" and notification.notification_type_doc:
_hash = "#" + notification.notification_type_doc
if notification.type == "WhatsApp":
_hash = "#whatsapp"
if notification.type == "Assignment" and notification.notification_type_doctype == "CRM Task":
_hash = "#tasks"
if "has been removed by" in notification.message:
_hash = ""
return _hash

24
crm/api/onboarding.py Normal file
View File

@@ -0,0 +1,24 @@
import frappe
@frappe.whitelist()
def get_first_lead():
lead = frappe.get_all(
"CRM Lead",
filters={"converted": 0},
fields=["name"],
order_by="creation",
limit=1,
)
return lead[0].name if lead else None
@frappe.whitelist()
def get_first_deal():
deal = frappe.get_all(
"CRM Deal",
fields=["name"],
order_by="creation",
limit=1,
)
return deal[0].name if deal else None

171
crm/api/session.py Normal file
View File

@@ -0,0 +1,171 @@
import frappe
from frappe import _
CRM_ALLOWED_ROLES = ["System Manager", "Sales Manager", "Sales User"]
def get_session_role_flags():
roles = set(frappe.get_roles())
if not roles.intersection(set(CRM_ALLOWED_ROLES)):
frappe.throw(_("You are not permitted to access CRM resources."), frappe.PermissionError)
return {
"is_system_manager": "System Manager" in roles,
"is_sales_manager": "Sales Manager" in roles and "System Manager" not in roles,
"is_sales_user": "Sales User" in roles
and "Sales Manager" not in roles
and "System Manager" not in roles,
}
USER_FIELDS = [
"name",
"email",
"enabled",
"user_image",
"first_name",
"last_name",
"full_name",
"user_type",
"language",
]
@frappe.whitelist()
def get_users(include_all: bool = False):
"""Return (users, crm_users) for the CRM frontend.
By default (`include_all=False`) the User query is filtered at SQL level
to just users with CRM roles — typically a handful of rows. This unblocks
the UI on initial load even on sites with hundreds of thousands of users.
When `include_all=True` and the session has System Manager, the full
enabled user list is returned. The frontend uses this path for a
non-blocking background fetch that populates name/avatar info for
non-CRM users referenced in CRM activity. Non-System-Manager sessions
cannot escalate by passing this param.
"""
session_roles = get_session_role_flags()
# Param arrives as a string from the HTTP layer; coerce.
if isinstance(include_all, str):
include_all = include_all.lower() in ("1", "true", "yes")
if not session_roles["is_system_manager"]:
include_all = False
# Always need the CRM user name set — used both as the filter for the
# fast path and as the membership check on the full path.
crm_user_names = set(
frappe.get_all(
"Has Role",
filters={"parenttype": "User", "role": ["in", CRM_ALLOWED_ROLES]},
pluck="parent",
distinct=True,
)
)
crm_user_names.add("Administrator")
user_filters = {"enabled": 1}
if not include_all:
user_filters["name"] = ["in", list(crm_user_names)]
users = frappe.qb.get_query(
"User",
fields=USER_FIELDS,
order_by="full_name asc",
filters=user_filters,
).run(as_dict=1)
if not users:
return [], []
system_language = frappe.db.get_single_value("System Settings", "language")
session_user = frappe.session.user
# Has Role lookup — restrict by parent on the fast path (tiny IN list);
# unfiltered scan on the full path because the IN list would otherwise
# carry every enabled user name (see commit dropping IN-list filters).
if include_all:
role_filters = {"parenttype": "User"}
else:
role_filters = {"parenttype": "User", "parent": ["in", list(crm_user_names)]}
role_rows = frappe.get_all("Has Role", filters=role_filters, fields=["parent", "role"])
roles_by_user = {}
for row in role_rows:
roles_by_user.setdefault(row.parent, []).append(row.role)
# Telephony agent table is tiny on any real site; full pluck is cheaper
# than serializing an IN list and gives identical results.
telephony_agents = set(frappe.get_all("CRM Telephony Agent", pluck="user"))
role_priority = ("System Manager", "Sales Manager", "Sales User", "Guest")
crm_users = []
for user in users:
if session_user == user.name:
user.session_user = True
# Administrator has every role implicitly via frappe.get_roles() but
# its Has Role child table is not guaranteed to contain System Manager
# on every install — special-case it to avoid locking the admin out.
if user.name == "Administrator":
user.roles = ["System Manager", "All"]
user.role = "System Manager"
else:
# Mirror frappe.get_roles() which appends implicit "All" and "Guest"
user.roles = [*roles_by_user.get(user.name, []), "All", "Guest"]
user.role = ""
for role in role_priority:
if role in user.roles:
user.role = role
break
user.is_telephony_agent = user.name in telephony_agents
user.language = user.language or system_language
if user.role in CRM_ALLOWED_ROLES:
crm_users.append(user)
if not include_all:
# Fast path — both positions are the CRM users.
return crm_users, crm_users
return users, crm_users
@frappe.whitelist()
def get_user_info(users: str | list):
"""Resolve display info for a batch of User names.
Used by the frontend to fill in name/avatar info for non-CRM users
referenced in CRM activity (comment authors, doc owners, etc.) when
the background full-list fetch has not yet landed. Gated behind a
CRM role; capped at 200 names per call to limit enumeration cost.
"""
get_session_role_flags()
if isinstance(users, str):
users = frappe.parse_json(users)
if not users:
return []
return frappe.get_all(
"User",
filters={"name": ["in", list(users)[:200]]},
fields=["name", "email", "full_name", "user_image", "user_type"],
)
@frappe.whitelist()
def get_organizations():
get_session_role_flags()
organizations = frappe.qb.get_query(
"CRM Organization",
fields=["*"],
order_by="name asc",
distinct=True,
).run(as_dict=1)
return organizations

99
crm/api/settings.py Normal file
View File

@@ -0,0 +1,99 @@
import frappe
@frappe.whitelist()
def create_email_account(data: dict):
service = data.get("service")
service_config = email_service_config.get(service)
if not service_config:
return "Service not supported"
try:
email_doc = frappe.get_doc(
{
"doctype": "Email Account",
"email_id": data.get("email_id"),
"email_account_name": data.get("email_account_name"),
"service": service,
"enable_incoming": data.get("enable_incoming"),
"enable_outgoing": data.get("enable_outgoing"),
"default_incoming": data.get("default_incoming"),
"default_outgoing": data.get("default_outgoing"),
"email_sync_option": "ALL",
"initial_sync_count": 100,
"create_contact": 1,
"track_email_status": 1,
"use_tls": 1,
"use_imap": 1,
"smtp_port": 587,
**service_config,
}
)
if service == "Frappe Mail":
email_doc.api_key = data.get("api_key")
email_doc.api_secret = data.get("api_secret")
email_doc.frappe_mail_site = data.get("frappe_mail_site")
email_doc.append_to = "CRM Lead"
else:
email_doc.append("imap_folder", {"append_to": "CRM Lead", "folder_name": "INBOX"})
email_doc.password = data.get("password")
# validate whether the credentials are correct
email_doc.get_incoming_server()
# if correct credentials, save the email account
email_doc.save()
except Exception as e:
frappe.throw(str(e))
email_service_config = {
"Frappe Mail": {
"domain": None,
"password": None,
"awaiting_password": 0,
"ascii_encode_password": 0,
"login_id_is_different": 0,
"login_id": None,
"use_imap": 0,
"use_ssl": 0,
"validate_ssl_certificate": 0,
"use_starttls": 0,
"email_server": None,
"incoming_port": 0,
"always_use_account_email_id_as_sender": 1,
"use_tls": 0,
"use_ssl_for_outgoing": 0,
"smtp_server": None,
"smtp_port": None,
"no_smtp_authentication": 0,
},
"GMail": {
"email_server": "imap.gmail.com",
"use_ssl": 1,
"smtp_server": "smtp.gmail.com",
},
"Outlook": {
"email_server": "imap-mail.outlook.com",
"use_ssl": 1,
"smtp_server": "smtp-mail.outlook.com",
},
"Sendgrid": {
"smtp_server": "smtp.sendgrid.net",
"smtp_port": 587,
},
"SparkPost": {
"smtp_server": "smtp.sparkpostmail.com",
},
"Yahoo": {
"email_server": "imap.mail.yahoo.com",
"use_ssl": 1,
"smtp_server": "smtp.mail.yahoo.com",
"smtp_port": 587,
},
"Yandex": {
"email_server": "imap.yandex.com",
"use_ssl": 1,
"smtp_server": "smtp.yandex.com",
"smtp_port": 587,
},
}

144
crm/api/todo.py Normal file
View File

@@ -0,0 +1,144 @@
import frappe
from frappe import _
from crm.fcrm.doctype.crm_notification.crm_notification import notify_user
def validate(doc, method):
"""Require write access when directly assigning a CRM lead or deal."""
# assign_to and assignment rules insert with ignore_permissions after their
# own permission check.
if (
doc.is_new()
and doc.reference_type in ["CRM Lead", "CRM Deal"]
and doc.reference_name
and not doc.flags.ignore_permissions
):
frappe.get_doc(doc.reference_type, doc.reference_name).check_permission("write")
def after_insert(doc, method):
if doc.reference_type in ["CRM Lead", "CRM Deal"] and doc.reference_name and doc.allocated_to:
fieldname = "lead_owner" if doc.reference_type == "CRM Lead" else "deal_owner"
# Mirror assign_to: the latest assignment owns the record, overriding any prior owner.
frappe.db.set_value(
doc.reference_type, doc.reference_name, fieldname, doc.allocated_to, update_modified=False
)
if doc.reference_type in ["CRM Lead", "CRM Deal", "CRM Task"] and doc.reference_name and doc.allocated_to:
notify_assigned_user(doc)
def on_update(doc, method):
if (
doc.has_value_changed("status")
and doc.status == "Cancelled"
and doc.reference_type in ["CRM Lead", "CRM Deal", "CRM Task"]
and doc.reference_name
and doc.allocated_to
):
notify_assigned_user(doc, is_cancelled=True)
clear_owner_on_unassign(doc)
def clear_owner_on_unassign(doc):
# Owner concept only exists for Lead/Deal, not Task.
if doc.reference_type not in ["CRM Lead", "CRM Deal"]:
return
fieldname = "lead_owner" if doc.reference_type == "CRM Lead" else "deal_owner"
# Mirror assign_to: cancelling an assignment clears the owner. Wrinkle (accepted):
# removing one of several manual co-assignees also clears, since owner is single-valued.
frappe.db.set_value(doc.reference_type, doc.reference_name, fieldname, None, update_modified=False)
def notify_assigned_user(doc, is_cancelled=False):
_doc = frappe.get_doc(doc.reference_type, doc.reference_name)
owner = frappe.get_cached_value("User", frappe.session.user, "full_name")
notification_text = get_notification_text(owner, doc, _doc, is_cancelled)
message = (
_("Your assignment on {0} {1} has been removed by {2}").format(
doc.reference_type, doc.reference_name, owner
)
if is_cancelled
else _("{0} assigned a {1} {2} to you").format(owner, doc.reference_type, doc.reference_name)
)
redirect_to_doctype, redirect_to_name = get_redirect_to_doc(doc)
notify_user(
{
"owner": frappe.session.user,
"assigned_to": doc.allocated_to,
"notification_type": "Assignment",
"message": message,
"notification_text": notification_text,
"reference_doctype": doc.reference_type,
"reference_docname": doc.reference_name,
"redirect_to_doctype": redirect_to_doctype,
"redirect_to_docname": redirect_to_name,
}
)
def get_notification_text(owner, doc, reference_doc, is_cancelled=False):
name = doc.reference_name
doctype = doc.reference_type
if doctype.startswith("CRM "):
doctype = doctype[4:].lower()
if doctype in ["lead", "deal"]:
name = (
reference_doc.lead_name or name
if doctype == "lead"
else reference_doc.organization or reference_doc.lead_name or name
)
if is_cancelled:
return f"""
<div class="mb-2 leading-5 text-ink-gray-5">
<span>{ _('Your assignment on {0} {1} has been removed by {2}').format(
doctype,
f'<span class="font-medium text-ink-gray-9">{ name }</span>',
f'<span class="font-medium text-ink-gray-9">{ owner }</span>'
) }</span>
</div>
"""
return f"""
<div class="mb-2 leading-5 text-ink-gray-5">
<span class="font-medium text-ink-gray-9">{ owner }</span>
<span>{ _('assigned a {0} {1} to you').format(
doctype,
f'<span class="font-medium text-ink-gray-9">{ name }</span>'
) }</span>
</div>
"""
if doctype == "task":
if is_cancelled:
return f"""
<div class="mb-2 leading-5 text-ink-gray-5">
<span>{ _('Your assignment on task {0} has been removed by {1}').format(
f'<span class="font-medium text-ink-gray-9">{ reference_doc.title }</span>',
f'<span class="font-medium text-ink-gray-9">{ owner }</span>'
) }</span>
</div>
"""
return f"""
<div class="mb-2 leading-5 text-ink-gray-5">
<span class="font-medium text-ink-gray-9">{ owner }</span>
<span>{ _('assigned a new task {0} to you').format(
f'<span class="font-medium text-ink-gray-9">{ reference_doc.title }</span>'
) }</span>
</div>
"""
def get_redirect_to_doc(doc):
if doc.reference_type == "CRM Task":
reference_doc = frappe.get_doc(doc.reference_type, doc.reference_name)
return reference_doc.reference_doctype, reference_doc.reference_docname
return doc.reference_type, doc.reference_name

238
crm/api/user.py Normal file
View File

@@ -0,0 +1,238 @@
import frappe
from frappe import _
from frappe.auth import LoginAttemptTracker
from frappe.rate_limiter import rate_limit
from frappe.utils.password import check_password, update_password
@frappe.whitelist()
@rate_limit(limit=5, seconds=300) # 5 attempts per 5 minutes per user/IP
def change_password(old_password: str, new_password: str):
"""
Change password for the current logged-in user.
Uses Frappe's LoginAttemptTracker for attempt counting/lockout, and rate_limit for API abuse protection.
"""
user = frappe.session.user
if user == "Guest":
frappe.throw(_("You must be logged in to change your password"), frappe.AuthenticationError)
tracker = LoginAttemptTracker(user)
if not tracker.is_user_allowed():
frappe.throw(_("Too many failed attempts. Please try again after some time."))
if old_password == new_password:
frappe.throw(
_("New password cannot be the same as current password. Please choose a different password.")
)
try:
check_password(user, old_password)
except frappe.AuthenticationError:
tracker.add_failure_attempt()
frappe.throw(_("Incorrect current password. Please try again."))
else:
tracker.add_success_attempt()
# Validate new password strength (server-side enforcement)
from frappe.core.doctype.user.user import test_password_strength
result = test_password_strength(new_password)
feedback = result.get("feedback", {})
if not feedback.get("password_policy_validation_passed", False):
suggestions = feedback.get("suggestions", [])
frappe.throw(_("Password is too weak. {0}").format(" ".join(suggestions) if suggestions else ""))
update_password(user=user, pwd=new_password, logout_all_sessions=False)
return _("Password Updated Successfully")
def needs_password_setup() -> bool:
"""Whether the session user has no password of their own and must set one.
True for users provisioned without a password — the Frappe Cloud site owner,
users created by a script — who are logged into a session they never
authenticated for and so have no way back in once it expires.
Users who sign in through SSO are excluded: having no password is the
correct state for them, not something to fix.
"""
user = frappe.session.user
# Checked first: it is the discriminating one, and it short-circuits the
# other queries for everyone who already has a password — which is almost
# everyone, on every CRM page load.
if has_password(user):
return False
if frappe.get_system_settings("disable_user_pass_login"):
return False
# Every user carries a `frappe` provider row (Frappe issues one on insert
# so the site can act as an identity provider), so only other providers
# mean the user actually signs in through SSO.
if frappe.db.exists(
"User Social Login",
{
"parenttype": "User",
"parent": user,
"provider": ["!=", "frappe"],
"userid": ["is", "set"],
},
):
return False
return True
def has_password(user: str) -> bool:
"""Whether a password is stored for the user.
User passwords are hashed rows in `__Auth` with `encrypted = 0`, so
`get_decrypted_password` (which only looks at encrypted rows) cannot answer
this — hence the direct query.
"""
Auth = frappe.qb.Table("__Auth")
return bool(
(
frappe.qb.from_(Auth)
.select(Auth.name)
.where(
(Auth.doctype == "User")
& (Auth.name == user)
& (Auth.fieldname == "password")
& (Auth.encrypted == 0)
)
.limit(1)
).run()
)
@frappe.whitelist()
def add_existing_users(users: str | list, role: str = "Sales User"):
"""
Add existing users to the CRM by assigning them a role (Sales User or Sales Manager).
:param users: List of user names to be added
"""
frappe.only_for(["System Manager", "Sales Manager"], True)
is_system_manager = "System Manager" in frappe.get_roles()
if role == "System Manager" and not is_system_manager:
frappe.throw(_("Only System Managers can assign the System Manager role"), frappe.PermissionError)
if role == "Sales Manager" and not is_system_manager:
frappe.throw(_("Only System Managers can assign the Sales Manager role"), frappe.PermissionError)
users = frappe.parse_json(users)
for user in users:
update_user_role(user, role)
@frappe.whitelist()
def update_user_role(user: str, new_role: str):
"""
Update the role of the user to Sales Manager, Sales User, or System Manager.
:param user: The name of the user
:param new_role: The new role to assign (Sales Manager or Sales User)
"""
frappe.only_for(["System Manager", "Sales Manager"], True)
is_system_manager = "System Manager" in frappe.get_roles()
if new_role not in ["System Manager", "Sales Manager", "Sales User"]:
frappe.throw(_("Cannot assign this role"))
user_doc = frappe.get_doc("User", user)
target_roles = [d.role for d in user_doc.roles]
target_is_system_manager = "System Manager" in target_roles
if new_role == "System Manager" and not is_system_manager:
frappe.throw(_("Only System Managers can assign the System Manager role"), frappe.PermissionError)
if target_is_system_manager and not is_system_manager:
frappe.throw(_("Only System Managers can modify other System Managers"), frappe.PermissionError)
if new_role == "Sales Manager" and not is_system_manager:
frappe.throw(_("Only System Managers can assign the Sales Manager role"), frappe.PermissionError)
if new_role == "System Manager":
user_doc.append_roles("System Manager", "Sales Manager", "Sales User")
user_doc.set("block_modules", [])
if new_role == "Sales Manager":
user_doc.append_roles("Sales Manager", "Sales User")
remove_roles(user_doc, "System Manager")
if new_role == "Sales User":
node = frappe.db.get_value(
"CRM Sales Hierarchy", {"user": user}, ["name", "reports_to"], as_dict=True
)
if node:
has_reports = frappe.db.exists("CRM Sales Hierarchy", {"reports_to": node.name})
if has_reports or not node.reports_to:
frappe.throw(
_("Remove this user from the sales hierarchy before changing their role to Sales User")
)
user_doc.append_roles("Sales User")
remove_roles(user_doc, "Sales Manager", "System Manager")
update_module_in_user(user_doc, "FCRM")
user_doc.save(ignore_permissions=True)
@frappe.whitelist()
def remove_crm_roles_from_user(user: str):
"""
Remove a user means removing Sales User & Sales Manager roles from the user.
:param user: The name of the user to be removed
"""
frappe.only_for(["System Manager", "Sales Manager"], True)
if user == frappe.session.user:
frappe.throw(_("You cannot remove yourself."), frappe.PermissionError)
user_doc = frappe.get_doc("User", user)
roles = [d.role for d in user_doc.roles]
current_user_is_system_manager = "System Manager" in frappe.get_roles()
if "System Manager" in roles and not current_user_is_system_manager:
frappe.throw(_("Only System Managers can modify other System Managers"), frappe.PermissionError)
if user_doc.get("role_profiles") or user_doc.get("role_profile_name"):
return frappe.throw(
_("User {0} cannot be removed as it has a Role Profile assigned to it.").format(user)
)
if "Sales User" in roles:
remove_roles(user_doc, "Sales User")
if "Sales Manager" in roles:
remove_roles(user_doc, "Sales Manager")
if "System Manager" in roles and current_user_is_system_manager:
remove_roles(user_doc, "System Manager")
update_module_in_user(user_doc, "FCRM")
user_doc.save(ignore_permissions=True)
node_name = frappe.db.get_value("CRM Sales Hierarchy", {"user": user}, "name")
if node_name:
frappe.delete_doc("CRM Sales Hierarchy", node_name, ignore_permissions=True)
frappe.msgprint(_("User {0} has been removed from CRM roles.").format(user))
def remove_roles(self, *roles):
existing_roles = {d.role: d for d in self.get("roles")}
for role in roles:
if role in existing_roles:
self.get("roles").remove(existing_roles[role])
def update_module_in_user(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)

16
crm/api/views.py Normal file
View File

@@ -0,0 +1,16 @@
import frappe
from pypika import Criterion
@frappe.whitelist()
def get_views(doctype: str):
View = frappe.qb.DocType("CRM View Settings")
query = (
frappe.qb.from_(View)
.select("*")
.where(Criterion.any([View.user == "", View.user == frappe.session.user]))
)
if doctype:
query = query.where(View.dt == doctype)
views = query.run(as_dict=True)
return views

387
crm/api/whatsapp.py Normal file
View File

@@ -0,0 +1,387 @@
import json
import frappe
from frappe import _
from frappe.permissions import add_permission, update_permission_property
from crm.api.doc import get_assigned_users
from crm.fcrm.doctype.crm_notification.crm_notification import notify_user
from crm.integrations.api import get_contact_lead_or_deal_from_number
ALLOWED_WHATSAPP_ROLES = ["System Manager", "Sales Manager", "Sales User"]
def validate_access(reference_doctype=None, reference_name=None, permtype="read"):
if not any(role in ALLOWED_WHATSAPP_ROLES for role in frappe.get_roles()):
frappe.throw(_("Only sales users can access WhatsApp features."), frappe.PermissionError)
if reference_doctype and reference_name:
if not frappe.db.exists(reference_doctype, reference_name):
frappe.throw(
_("Reference document {0} {1} does not exist.").format(reference_doctype, reference_name),
frappe.DoesNotExistError,
)
reference_doc = frappe.get_doc(reference_doctype, reference_name)
if not reference_doc.has_permission(permtype):
frappe.throw(
_("Not permitted to access reference document {0} {1}.").format(
reference_doctype, reference_name
),
frappe.PermissionError,
)
return reference_doc
return None
def validate(doc, method):
phone_number = doc.get("from") if doc.type == "Incoming" else doc.get("to")
if phone_number:
try:
name, doctype = get_contact_lead_or_deal_from_number(phone_number)
if doctype and name is not None:
doc.reference_doctype = doctype
doc.reference_name = name
except Exception:
frappe.log_error(frappe.get_traceback(), "CRM WhatsApp: failed to resolve contact from number")
def on_update(doc, method):
frappe.publish_realtime(
"whatsapp_message",
{
"reference_doctype": doc.reference_doctype,
"reference_name": doc.reference_name,
},
)
notify_agent(doc)
def notify_agent(doc):
if doc.type == "Incoming":
if not doc.reference_doctype or not doc.reference_name:
return
doctype = doc.reference_doctype
if doctype and doctype.startswith("CRM "):
doctype = doctype[4:].lower()
safe_reference_name = frappe.utils.escape_html(doc.reference_name)
notification_text = f"""
<div class="mb-2 leading-5 text-ink-gray-5">
<span class="font-medium text-ink-gray-9">{_("You")}</span>
<span>{_("received a whatsapp message in {0}").format(doctype)}</span>
<span class="font-medium text-ink-gray-9">{safe_reference_name}</span>
</div>
"""
assigned_users = get_assigned_users(doc.reference_doctype, doc.reference_name)
for user in assigned_users:
notify_user(
{
"owner": doc.owner,
"assigned_to": user,
"notification_type": "WhatsApp",
"message": doc.message,
"notification_text": notification_text,
"reference_doctype": "WhatsApp Message",
"reference_docname": doc.name,
"redirect_to_doctype": doc.reference_doctype,
"redirect_to_docname": doc.reference_name,
}
)
@frappe.whitelist()
def is_whatsapp_enabled():
if not frappe.db.exists("DocType", "WhatsApp Settings"):
return False
default_outgoing = frappe.get_cached_value(
"WhatsApp Settings", "WhatsApp Settings", "default_outgoing_account"
)
if not default_outgoing:
return False
status = frappe.get_cached_value("WhatsApp Account", default_outgoing, "status")
return status == "Active"
@frappe.whitelist()
def is_whatsapp_installed():
if not frappe.db.exists("DocType", "WhatsApp Settings"):
return False
return True
@frappe.whitelist()
def get_whatsapp_messages(reference_doctype: str, reference_name: str):
reference_doc = validate_access(reference_doctype, reference_name)
# twilio integration app is not compatible with crm app
# crm has its own twilio integration in built
if "twilio_integration" in frappe.get_installed_apps():
return []
if not frappe.db.exists("DocType", "WhatsApp Message"):
return []
messages = []
if reference_doctype == "CRM Deal":
lead = reference_doc.get("lead")
if lead:
validate_access("CRM Lead", lead)
messages = frappe.get_all(
"WhatsApp Message",
filters={
"reference_doctype": "CRM Lead",
"reference_name": lead,
},
fields=[
"name",
"type",
"to",
"from",
"content_type",
"message_type",
"attach",
"template",
"use_template",
"message_id",
"is_reply",
"reply_to_message_id",
"creation",
"message",
"status",
"reference_doctype",
"reference_name",
"template_parameters",
"template_header_parameters",
],
)
messages += frappe.get_all(
"WhatsApp Message",
filters={
"reference_doctype": reference_doctype,
"reference_name": reference_name,
},
fields=[
"name",
"type",
"to",
"from",
"content_type",
"message_type",
"attach",
"template",
"use_template",
"message_id",
"is_reply",
"reply_to_message_id",
"creation",
"message",
"status",
"reference_doctype",
"reference_name",
"template_parameters",
"template_header_parameters",
],
)
# Filter messages to get only Template messages
template_messages = [message for message in messages if message["message_type"] == "Template"]
# Iterate through template messages
for template_message in template_messages:
# Find the template that this message is using
if not frappe.db.exists("WhatsApp Templates", template_message["template"]):
continue
template = frappe.get_doc("WhatsApp Templates", template_message["template"])
if template:
template_message["template_name"] = template.template_name
if template_message["template_parameters"]:
parameters = json.loads(template_message["template_parameters"])
template.template = parse_template_parameters(template.template, parameters)
template_message["template"] = template.template
if template_message["template_header_parameters"]:
header_parameters = json.loads(template_message["template_header_parameters"])
template.header = parse_template_parameters(template.header, header_parameters)
template_message["header"] = template.header
template_message["footer"] = template.footer
# Filter messages to get only reaction messages
reaction_messages = [message for message in messages if message["content_type"] == "reaction"]
reaction_messages.reverse()
# Iterate through reaction messages
for reaction_message in reaction_messages:
# Find the message that this reaction is reacting to
reacted_message = next(
(m for m in messages if m["message_id"] == reaction_message["reply_to_message_id"]),
None,
)
# If the reacted message is found, add the reaction to it
if reacted_message:
reacted_message["reaction"] = reaction_message["message"]
for message in messages:
from_name = get_from_name(message) if message["from"] else _("You")
message["from_name"] = from_name
# Filter messages to get only replies
reply_messages = [message for message in messages if message["is_reply"]]
# Iterate through reply messages
for reply_message in reply_messages:
# Find the message that this message is replying to
replied_message = next(
(m for m in messages if m["message_id"] == reply_message["reply_to_message_id"]),
None,
)
# If the replied message is found, add the reply details to the reply message
if replied_message:
from_name = get_from_name(reply_message) if replied_message["from"] else _("You")
message = replied_message["message"]
if replied_message["message_type"] == "Template":
message = replied_message["template"]
reply_message["reply_message"] = message
reply_message["header"] = replied_message.get("header") or ""
reply_message["footer"] = replied_message.get("footer") or ""
reply_message["reply_to"] = replied_message["name"]
reply_message["reply_to_type"] = replied_message["type"]
reply_message["reply_to_from"] = from_name
return [message for message in messages if message["content_type"] != "reaction"]
@frappe.whitelist()
def create_whatsapp_message(
reference_doctype: str,
reference_name: str,
message: str,
to: str,
attach: str,
reply_to: str,
content_type: str = "text",
):
validate_access(reference_doctype, reference_name)
doc = frappe.new_doc("WhatsApp Message")
if reply_to:
if not frappe.db.exists("WhatsApp Message", reply_to):
frappe.throw(_("Referenced WhatsApp message does not exist."), frappe.DoesNotExistError)
reply_doc = frappe.get_doc("WhatsApp Message", reply_to)
if not reply_doc.has_permission("read"):
frappe.throw(
_("Not permitted to access the referenced WhatsApp message."), frappe.PermissionError
)
validate_access(reply_doc.reference_doctype, reply_doc.reference_name)
doc.update(
{
"is_reply": True,
"reply_to_message_id": reply_doc.message_id,
}
)
doc.update(
{
"reference_doctype": reference_doctype,
"reference_name": reference_name,
"message": message or attach,
"to": to,
"attach": attach,
"content_type": content_type,
}
)
doc.insert(ignore_permissions=True)
return doc.name
@frappe.whitelist()
def send_whatsapp_template(reference_doctype: str, reference_name: str, template: str, to: str):
validate_access(reference_doctype, reference_name)
doc = frappe.new_doc("WhatsApp Message")
doc.update(
{
"reference_doctype": reference_doctype,
"reference_name": reference_name,
"message_type": "Template",
"message": "Template message",
"content_type": "text",
"use_template": True,
"template": template,
"to": to,
}
)
doc.insert(ignore_permissions=True)
return doc.name
@frappe.whitelist()
def react_on_whatsapp_message(emoji: str, reply_to_name: str):
validate_access()
if not frappe.db.exists("WhatsApp Message", reply_to_name):
frappe.throw(_("Referenced WhatsApp message does not exist."), frappe.DoesNotExistError)
reply_to_doc = frappe.get_doc("WhatsApp Message", reply_to_name)
if not reply_to_doc.has_permission("read"):
frappe.throw(_("Not permitted to access the referenced WhatsApp message."), frappe.PermissionError)
validate_access(reply_to_doc.reference_doctype, reply_to_doc.reference_name)
to = (reply_to_doc.type == "Incoming" and reply_to_doc.get("from")) or reply_to_doc.to
doc = frappe.new_doc("WhatsApp Message")
doc.update(
{
"reference_doctype": reply_to_doc.reference_doctype,
"reference_name": reply_to_doc.reference_name,
"message": emoji,
"to": to,
"reply_to_message_id": reply_to_doc.message_id,
"content_type": "reaction",
}
)
doc.insert(ignore_permissions=True)
return doc.name
def parse_template_parameters(string, parameters):
for i, parameter in enumerate(parameters, start=1):
placeholder = "{{" + str(i) + "}}"
string = string.replace(placeholder, str(parameter))
return string
def get_from_name(message):
doc = frappe.get_doc(message["reference_doctype"], message["reference_name"])
from_name = ""
if message["reference_doctype"] == "CRM Deal":
if doc.get("contacts"):
for c in doc.get("contacts"):
if c.is_primary:
from_name = c.full_name or c.mobile_no
break
else:
from_name = doc.get("lead_name")
else:
from_name = " ".join(name for name in [doc.get("first_name"), doc.get("last_name")] if name)
return from_name
def add_roles():
if "frappe_whatsapp" not in frappe.get_installed_apps():
return
role_list = ["Sales Manager", "Sales User"]
doctypes = ["WhatsApp Message", "WhatsApp Templates", "WhatsApp Settings"]
for doctype in doctypes:
for role in role_list:
if frappe.db.exists("Custom DocPerm", {"parent": doctype, "role": role}):
continue
add_permission(doctype, role, 0, "write")
update_permission_property(doctype, role, 0, "create", 1)
update_permission_property(doctype, role, 0, "delete", 1)
update_permission_property(doctype, role, 0, "share", 1)
update_permission_property(doctype, role, 0, "email", 1)
update_permission_property(doctype, role, 0, "print", 1)
update_permission_property(doctype, role, 0, "report", 1)
update_permission_property(doctype, role, 0, "export", 1)