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
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:
179
crm/api/__init__.py
Normal file
179
crm/api/__init__.py
Normal 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
536
crm/api/activities.py
Normal 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()
|
||||
32
crm/api/assignment_rule.py
Normal file
32
crm/api/assignment_rule.py
Normal 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
39
crm/api/auth.py
Normal 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
128
crm/api/comment.py
Normal 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
145
crm/api/contact.py
Normal 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
1323
crm/api/dashboard.py
Normal file
File diff suppressed because it is too large
Load Diff
857
crm/api/doc.py
Normal file
857
crm/api/doc.py
Normal 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
372
crm/api/event.py
Normal 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
158
crm/api/exchange_rate.py
Normal 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
638
crm/api/form.py
Normal 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
34
crm/api/live_demo.py
Normal 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
69
crm/api/notifications.py
Normal 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
24
crm/api/onboarding.py
Normal 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
171
crm/api/session.py
Normal 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
99
crm/api/settings.py
Normal 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
144
crm/api/todo.py
Normal 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
238
crm/api/user.py
Normal 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
16
crm/api/views.py
Normal 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
387
crm/api/whatsapp.py
Normal 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)
|
||||
Reference in New Issue
Block a user