diff --git a/crm/api/activities.py b/crm/api/activities.py
index 8cc714eb..7f6fb689 100644
--- a/crm/api/activities.py
+++ b/crm/api/activities.py
@@ -1,10 +1,13 @@
import json
-from bs4 import BeautifulSoup
import frappe
+from bs4 import BeautifulSoup
from frappe import _
-from frappe.utils.caching import redis_cache
from frappe.desk.form.load import get_docinfo
+from frappe.query_builder import JoinType
+
+from crm.fcrm.doctype.crm_call_log.crm_call_log import parse_call_log
+
@frappe.whitelist()
def get_activities(name):
@@ -15,11 +18,14 @@ def get_activities(name):
else:
frappe.throw(_("Document not found"), frappe.DoesNotExistError)
+
def get_deal_activities(name):
- get_docinfo('', "CRM Deal", name)
+ get_docinfo("", "CRM Deal", name)
docinfo = frappe.response["docinfo"]
deal_meta = frappe.get_meta("CRM Deal")
- deal_fields = {field.fieldname: {"label": field.label, "options": field.options} for field in deal_meta.fields}
+ deal_fields = {
+ field.fieldname: {"label": field.label, "options": field.options} for field in deal_meta.fields
+ }
avoid_fields = [
"lead",
"response_by",
@@ -43,13 +49,15 @@ def get_deal_activities(name):
activities, calls, notes, tasks, attachments = get_lead_activities(lead)
creation_text = "converted the lead to this deal"
- activities.append({
- "activity_type": "creation",
- "creation": doc[0],
- "owner": doc[1],
- "data": creation_text,
- "is_lead": False,
- })
+ activities.append(
+ {
+ "activity_type": "creation",
+ "creation": doc[0],
+ "owner": doc[1],
+ "data": creation_text,
+ "is_lead": False,
+ }
+ )
docinfo.versions.reverse()
@@ -107,7 +115,7 @@ def get_deal_activities(name):
"creation": comment.creation,
"owner": comment.owner,
"content": comment.content,
- "attachments": get_attachments('Comment', comment.name),
+ "attachments": get_attachments("Comment", comment.name),
"is_lead": False,
}
activities.append(activity)
@@ -125,7 +133,7 @@ def get_deal_activities(name):
"recipients": communication.recipients,
"cc": communication.cc,
"bcc": communication.bcc,
- "attachments": get_attachments('Communication', communication.name),
+ "attachments": get_attachments("Communication", communication.name),
"read_by_recipient": communication.read_by_recipient,
"delivery_status": communication.delivery_status,
},
@@ -144,21 +152,24 @@ def get_deal_activities(name):
}
activities.append(activity)
- calls = calls + get_linked_calls(name)
- notes = notes + get_linked_notes(name)
- tasks = tasks + get_linked_tasks(name)
- attachments = attachments + get_attachments('CRM Deal', name)
+ 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):
- get_docinfo('', "CRM Lead", name)
+ get_docinfo("", "CRM Lead", name)
docinfo = frappe.response["docinfo"]
lead_meta = frappe.get_meta("CRM Lead")
- lead_fields = {field.fieldname: {"label": field.label, "options": field.options} for field in lead_meta.fields}
+ lead_fields = {
+ field.fieldname: {"label": field.label, "options": field.options} for field in lead_meta.fields
+ }
avoid_fields = [
"converted",
"response_by",
@@ -169,13 +180,15 @@ def get_lead_activities(name):
]
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,
- }]
+ activities = [
+ {
+ "activity_type": "creation",
+ "creation": doc[0],
+ "owner": doc[1],
+ "data": "created this lead",
+ "is_lead": True,
+ }
+ ]
docinfo.versions.reverse()
@@ -233,7 +246,7 @@ def get_lead_activities(name):
"creation": comment.creation,
"owner": comment.owner,
"content": comment.content,
- "attachments": get_attachments('Comment', comment.name),
+ "attachments": get_attachments("Comment", comment.name),
"is_lead": True,
}
activities.append(activity)
@@ -251,7 +264,7 @@ def get_lead_activities(name):
"recipients": communication.recipients,
"cc": communication.cc,
"bcc": communication.bcc,
- "attachments": get_attachments('Communication', communication.name),
+ "attachments": get_attachments("Communication", communication.name),
"read_by_recipient": communication.read_by_recipient,
"delivery_status": communication.delivery_status,
},
@@ -270,10 +283,10 @@ def get_lead_activities(name):
}
activities.append(activity)
- calls = get_linked_calls(name)
- notes = get_linked_notes(name)
- tasks = get_linked_tasks(name)
- attachments = get_attachments('CRM Lead', name)
+ 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)
@@ -282,11 +295,25 @@ def get_lead_activities(name):
def get_attachments(doctype, name):
- 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", "creation", "owner"],
- ) or []
+ 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):
activities = []
@@ -298,7 +325,8 @@ def handle_multiple_versions(versions):
activities.append(version)
if not old_version:
old_version = version
- if is_version: grouped_versions.append(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)
@@ -306,13 +334,15 @@ def handle_multiple_versions(versions):
if grouped_versions:
activities.append(parse_grouped_versions(grouped_versions))
grouped_versions = []
- if is_version: grouped_versions.append(version)
+ 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):
version = versions[0]
if len(versions) == 1:
@@ -321,6 +351,7 @@ def parse_grouped_versions(versions):
version["other_versions"] = other_versions
return version
+
def get_linked_calls(name):
calls = frappe.db.get_all(
"CRM Call Log",
@@ -341,16 +372,89 @@ def get_linked_calls(name):
"note",
],
)
- return calls or []
+
+ 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):
notes = frappe.db.get_all(
"FCRM Note",
filters={"reference_docname": name},
- fields=['name', 'title', 'content', 'owner', 'modified'],
+ fields=["name", "title", "content", "owner", "modified"],
)
return notes or []
+
def get_linked_tasks(name):
tasks = frappe.db.get_all(
"CRM Task",
@@ -360,7 +464,6 @@ def get_linked_tasks(name):
"title",
"description",
"assigned_to",
- "assigned_to",
"due_date",
"priority",
"status",
@@ -369,6 +472,7 @@ def get_linked_tasks(name):
)
return tasks or []
+
def parse_attachment_log(html, type):
soup = BeautifulSoup(html, "html.parser")
a_tag = soup.find("a")
@@ -390,4 +494,4 @@ def parse_attachment_log(html, type):
"file_name": a_tag.text,
"file_url": a_tag["href"],
"is_private": is_private,
- }
\ No newline at end of file
+ }
diff --git a/crm/api/doc.py b/crm/api/doc.py
index 4001fa7e..282ebe8d 100644
--- a/crm/api/doc.py
+++ b/crm/api/doc.py
@@ -185,12 +185,13 @@ def get_quick_filters(doctype: str):
if field.fieldtype == "Select" and options and isinstance(options, str):
options = options.split("\n")
options = [{"label": option, "value": option} for option in options]
- options.insert(0, {"label": "", "value": ""})
+ if not any([not option.get("value") for option in options]):
+ options.insert(0, {"label": "", "value": ""})
quick_filters.append(
{
"label": _(field.label),
- "name": field.fieldname,
- "type": field.fieldtype,
+ "fieldname": field.fieldname,
+ "fieldtype": field.fieldtype,
"options": options,
}
)
@@ -306,6 +307,7 @@ def get_data(
)
or []
)
+ data = parse_list_data(data, doctype)
if view_type == "kanban":
if not rows:
@@ -479,6 +481,13 @@ def get_data(
}
+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()
diff --git a/crm/fcrm/doctype/crm_call_log/crm_call_log.json b/crm/fcrm/doctype/crm_call_log/crm_call_log.json
index ebefc2ab..96416bf9 100644
--- a/crm/fcrm/doctype/crm_call_log/crm_call_log.json
+++ b/crm/fcrm/doctype/crm_call_log/crm_call_log.json
@@ -9,6 +9,8 @@
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
+ "telephony_medium",
+ "section_break_gyqe",
"id",
"from",
"status",
@@ -24,7 +26,9 @@
"caller",
"recording_url",
"end_time",
- "note"
+ "note",
+ "section_break_kebz",
+ "links"
],
"fields": [
{
@@ -75,6 +79,7 @@
"label": "To"
},
{
+ "description": "Call duration in seconds",
"fieldname": "duration",
"fieldtype": "Duration",
"in_list_view": 1,
@@ -123,11 +128,33 @@
"fieldtype": "Dynamic Link",
"label": "Reference Name",
"options": "reference_doctype"
+ },
+ {
+ "fieldname": "section_break_kebz",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "links",
+ "fieldtype": "Table",
+ "label": "Links",
+ "options": "Dynamic Link"
+ },
+ {
+ "fieldname": "telephony_medium",
+ "fieldtype": "Select",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Telephony Medium",
+ "options": "\nManual\nTwilio\nExotel"
+ },
+ {
+ "fieldname": "section_break_gyqe",
+ "fieldtype": "Section Break"
}
],
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2024-03-16 13:23:09.201843",
+ "modified": "2025-01-17 21:46:01.558377",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Call Log",
diff --git a/crm/fcrm/doctype/crm_call_log/crm_call_log.py b/crm/fcrm/doctype/crm_call_log/crm_call_log.py
index 42752c6a..880413e1 100644
--- a/crm/fcrm/doctype/crm_call_log/crm_call_log.py
+++ b/crm/fcrm/doctype/crm_call_log/crm_call_log.py
@@ -4,78 +4,190 @@
import frappe
from frappe.model.document import Document
+from crm.integrations.api import get_contact_by_phone_number
+from crm.utils import seconds_to_duration
+
class CRMCallLog(Document):
- @staticmethod
- def default_list_data():
- columns = [
- {
- 'label': 'From',
- 'type': 'Link',
- 'key': 'caller',
- 'options': 'User',
- 'width': '9rem',
- },
- {
- 'label': 'To',
- 'type': 'Link',
- 'key': 'receiver',
- 'options': 'User',
- 'width': '9rem',
- },
- {
- 'label': 'Type',
- 'type': 'Select',
- 'key': 'type',
- 'width': '9rem',
- },
- {
- 'label': 'Status',
- 'type': 'Select',
- 'key': 'status',
- 'width': '9rem',
- },
- {
- 'label': 'Duration',
- 'type': 'Duration',
- 'key': 'duration',
- 'width': '6rem',
- },
- {
- 'label': 'From (number)',
- 'type': 'Data',
- 'key': 'from',
- 'width': '9rem',
- },
- {
- 'label': 'To (number)',
- 'type': 'Data',
- 'key': 'to',
- 'width': '9rem',
- },
- {
- 'label': 'Created On',
- 'type': 'Datetime',
- 'key': 'creation',
- 'width': '8rem',
- },
- ]
- rows = [
- "name",
- "caller",
- "receiver",
- "type",
- "status",
- "duration",
- "from",
- "to",
- "note",
- "recording_url",
- "reference_doctype",
- "reference_docname",
- "creation",
- ]
- return {'columns': columns, 'rows': rows}
+ @staticmethod
+ def default_list_data():
+ columns = [
+ {
+ "label": "From",
+ "type": "Link",
+ "key": "caller",
+ "options": "User",
+ "width": "9rem",
+ },
+ {
+ "label": "To",
+ "type": "Link",
+ "key": "receiver",
+ "options": "User",
+ "width": "9rem",
+ },
+ {
+ "label": "Type",
+ "type": "Select",
+ "key": "type",
+ "width": "9rem",
+ },
+ {
+ "label": "Status",
+ "type": "Select",
+ "key": "status",
+ "width": "9rem",
+ },
+ {
+ "label": "Duration",
+ "type": "Duration",
+ "key": "duration",
+ "width": "6rem",
+ },
+ {
+ "label": "From (number)",
+ "type": "Data",
+ "key": "from",
+ "width": "9rem",
+ },
+ {
+ "label": "To (number)",
+ "type": "Data",
+ "key": "to",
+ "width": "9rem",
+ },
+ {
+ "label": "Created On",
+ "type": "Datetime",
+ "key": "creation",
+ "width": "8rem",
+ },
+ ]
+ rows = [
+ "name",
+ "caller",
+ "receiver",
+ "type",
+ "status",
+ "duration",
+ "from",
+ "to",
+ "note",
+ "recording_url",
+ "reference_doctype",
+ "reference_docname",
+ "creation",
+ ]
+ return {"columns": columns, "rows": rows}
+
+ def parse_list_data(calls):
+ return [parse_call_log(call) for call in calls] if calls else []
+
+ def has_link(self, doctype, name):
+ for link in self.links:
+ if link.link_doctype == doctype and link.link_name == name:
+ return True
+
+ def link_with_reference_doc(self, reference_doctype, reference_name):
+ if self.has_link(reference_doctype, reference_name):
+ return
+
+ self.append("links", {"link_doctype": reference_doctype, "link_name": reference_name})
+
+
+def parse_call_log(call):
+ call["show_recording"] = False
+ call["duration"] = seconds_to_duration(call.get("duration"))
+ if call.get("type") == "Incoming":
+ call["activity_type"] = "incoming_call"
+ contact = get_contact_by_phone_number(call.get("from"))
+ receiver = (
+ frappe.db.get_values("User", call.get("receiver"), ["full_name", "user_image"])[0]
+ if call.get("receiver")
+ else [None, None]
+ )
+ call["caller"] = {
+ "label": contact.get("full_name", "Unknown"),
+ "image": contact.get("image"),
+ }
+ call["receiver"] = {
+ "label": receiver[0],
+ "image": receiver[1],
+ }
+ elif call.get("type") == "Outgoing":
+ call["activity_type"] = "outgoing_call"
+ contact = get_contact_by_phone_number(call.get("to"))
+ caller = (
+ frappe.db.get_values("User", call.get("caller"), ["full_name", "user_image"])[0]
+ if call.get("caller")
+ else [None, None]
+ )
+ call["caller"] = {
+ "label": caller[0],
+ "image": caller[1],
+ }
+ call["receiver"] = {
+ "label": contact.get("full_name", "Unknown"),
+ "image": contact.get("image"),
+ }
+
+ return call
+
+
+@frappe.whitelist()
+def get_call_log(name):
+ call = frappe.get_cached_doc(
+ "CRM Call Log",
+ name,
+ fields=[
+ "name",
+ "caller",
+ "receiver",
+ "duration",
+ "type",
+ "status",
+ "from",
+ "to",
+ "note",
+ "recording_url",
+ "reference_doctype",
+ "reference_docname",
+ "creation",
+ ],
+ ).as_dict()
+
+ call = parse_call_log(call)
+
+ notes = []
+ tasks = []
+
+ if call.get("note"):
+ note = frappe.get_cached_doc("FCRM Note", call.get("note")).as_dict()
+ notes.append(note)
+
+ if call.get("reference_doctype") and call.get("reference_docname"):
+ if call.get("reference_doctype") == "CRM Lead":
+ call["_lead"] = call.get("reference_docname")
+ elif call.get("reference_doctype") == "CRM Deal":
+ call["_deal"] = call.get("reference_docname")
+
+ if call.get("links"):
+ for link in call.get("links"):
+ if link.get("link_doctype") == "CRM Task":
+ task = frappe.get_cached_doc("CRM Task", link.get("link_name")).as_dict()
+ tasks.append(task)
+ elif link.get("link_doctype") == "FCRM Note":
+ note = frappe.get_cached_doc("FCRM Note", link.get("link_name")).as_dict()
+ notes.append(note)
+ elif link.get("link_doctype") == "CRM Lead":
+ call["_lead"] = link.get("link_name")
+ elif link.get("link_doctype") == "CRM Deal":
+ call["_deal"] = link.get("link_name")
+
+ call["_tasks"] = tasks
+ call["_notes"] = notes
+ return call
+
@frappe.whitelist()
def create_lead_from_call_log(call_log):
@@ -85,15 +197,17 @@ def create_lead_from_call_log(call_log):
lead.lead_owner = frappe.session.user
lead.save(ignore_permissions=True)
- frappe.db.set_value("CRM Call Log", call_log.get("name"), {
- "reference_doctype": "CRM Lead",
- "reference_docname": lead.name
- })
+ frappe.db.set_value(
+ "CRM Call Log",
+ call_log.get("name"),
+ {"reference_doctype": "CRM Lead", "reference_docname": lead.name},
+ )
if call_log.get("note"):
- frappe.db.set_value("FCRM Note", call_log.get("note"), {
- "reference_doctype": "CRM Lead",
- "reference_docname": lead.name
- })
+ frappe.db.set_value(
+ "FCRM Note",
+ call_log.get("note"),
+ {"reference_doctype": "CRM Lead", "reference_docname": lead.name},
+ )
- return lead.name
\ No newline at end of file
+ return lead.name
diff --git a/crm/fcrm/doctype/crm_exotel_agent/__init__.py b/crm/fcrm/doctype/crm_exotel_agent/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.js b/crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.js
new file mode 100644
index 00000000..78f3c9bb
--- /dev/null
+++ b/crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+// frappe.ui.form.on("CRM Exotel Agent", {
+// refresh(frm) {
+
+// },
+// });
diff --git a/crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.json b/crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.json
new file mode 100644
index 00000000..c9baa785
--- /dev/null
+++ b/crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.json
@@ -0,0 +1,80 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "autoname": "field:mobile_no",
+ "creation": "2025-01-11 16:12:46.602782",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "user",
+ "user_name",
+ "column_break_hdec",
+ "mobile_no",
+ "exotel_number"
+ ],
+ "fields": [
+ {
+ "fieldname": "user",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "User",
+ "options": "User",
+ "reqd": 1
+ },
+ {
+ "fieldname": "column_break_hdec",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "mobile_no",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Mobile No.",
+ "reqd": 1,
+ "unique": 1
+ },
+ {
+ "fetch_from": "user.full_name",
+ "fieldname": "user_name",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "User Name"
+ },
+ {
+ "fieldname": "exotel_number",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Exotel Number"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2025-01-15 20:03:31.162162",
+ "modified_by": "Administrator",
+ "module": "FCRM",
+ "name": "CRM Exotel Agent",
+ "naming_rule": "By fieldname",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": [],
+ "title_field": "user_name"
+}
\ No newline at end of file
diff --git a/crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.py b/crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.py
new file mode 100644
index 00000000..05fa7ee7
--- /dev/null
+++ b/crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.py
@@ -0,0 +1,9 @@
+# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class CRMExotelAgent(Document):
+ pass
diff --git a/crm/fcrm/doctype/crm_exotel_agent/test_crm_exotel_agent.py b/crm/fcrm/doctype/crm_exotel_agent/test_crm_exotel_agent.py
new file mode 100644
index 00000000..9fe610ce
--- /dev/null
+++ b/crm/fcrm/doctype/crm_exotel_agent/test_crm_exotel_agent.py
@@ -0,0 +1,30 @@
+# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests import IntegrationTestCase, UnitTestCase
+
+
+# On IntegrationTestCase, the doctype test records and all
+# link-field test record dependencies are recursively loaded
+# Use these module variables to add/remove to/from that list
+EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+
+
+class UnitTestCRMExotelAgent(UnitTestCase):
+ """
+ Unit tests for CRMExotelAgent.
+ Use this class for testing individual functions and methods.
+ """
+
+ pass
+
+
+class IntegrationTestCRMExotelAgent(IntegrationTestCase):
+ """
+ Integration tests for CRMExotelAgent.
+ Use this class for testing interactions between multiple components.
+ """
+
+ pass
diff --git a/crm/fcrm/doctype/crm_exotel_settings/__init__.py b/crm/fcrm/doctype/crm_exotel_settings/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.js b/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.js
new file mode 100644
index 00000000..b7b3339d
--- /dev/null
+++ b/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+// frappe.ui.form.on("CRM Exotel Settings", {
+// refresh(frm) {
+
+// },
+// });
diff --git a/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.json b/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.json
new file mode 100644
index 00000000..98382626
--- /dev/null
+++ b/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.json
@@ -0,0 +1,115 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-01-08 15:55:50.710356",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "enabled",
+ "column_break_uxtz",
+ "record_call",
+ "section_break_kfez",
+ "account_sid",
+ "section_break_iuct",
+ "api_key",
+ "column_break_hyen",
+ "api_token"
+ ],
+ "fields": [
+ {
+ "default": "0",
+ "fieldname": "enabled",
+ "fieldtype": "Check",
+ "label": "Enabled"
+ },
+ {
+ "fieldname": "section_break_kfez",
+ "fieldtype": "Section Break",
+ "hide_border": 1
+ },
+ {
+ "depends_on": "enabled",
+ "fieldname": "account_sid",
+ "fieldtype": "Data",
+ "label": "Account SID",
+ "mandatory_depends_on": "enabled"
+ },
+ {
+ "depends_on": "enabled",
+ "fieldname": "section_break_iuct",
+ "fieldtype": "Section Break",
+ "hide_border": 1
+ },
+ {
+ "fieldname": "column_break_hyen",
+ "fieldtype": "Column Break"
+ },
+ {
+ "depends_on": "enabled",
+ "fieldname": "api_key",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "API Key",
+ "mandatory_depends_on": "enabled"
+ },
+ {
+ "fieldname": "column_break_uxtz",
+ "fieldtype": "Column Break"
+ },
+ {
+ "depends_on": "enabled",
+ "fieldname": "api_token",
+ "fieldtype": "Password",
+ "in_list_view": 1,
+ "label": "API Token",
+ "mandatory_depends_on": "enabled"
+ },
+ {
+ "default": "0",
+ "depends_on": "enabled",
+ "fieldname": "record_call",
+ "fieldtype": "Check",
+ "label": "Record Call"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "issingle": 1,
+ "links": [],
+ "modified": "2025-01-15 19:31:00.310049",
+ "modified_by": "Administrator",
+ "module": "FCRM",
+ "name": "CRM Exotel Settings",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "print": 1,
+ "read": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "print": 1,
+ "read": 1,
+ "role": "Sales Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "email": 1,
+ "print": 1,
+ "read": 1,
+ "role": "All",
+ "share": 1
+ }
+ ],
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
\ No newline at end of file
diff --git a/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.py b/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.py
new file mode 100644
index 00000000..c72fc5e9
--- /dev/null
+++ b/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.py
@@ -0,0 +1,24 @@
+# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import frappe
+import requests
+from frappe import _
+from frappe.model.document import Document
+
+
+class CRMExotelSettings(Document):
+ def validate(self):
+ self.verify_credentials()
+
+ def verify_credentials(self):
+ if self.enabled:
+ response = requests.get(
+ "https://api.exotel.com/v1/Accounts/{sid}".format(sid=self.account_sid),
+ auth=(self.api_key, self.get_password("api_token")),
+ )
+ if response.status_code != 200:
+ frappe.throw(
+ _(f"Please enter valid exotel Account SID, API key & API token: {response.reason}"),
+ title=_("Invalid credentials"),
+ )
diff --git a/crm/fcrm/doctype/crm_exotel_settings/test_crm_exotel_settings.py b/crm/fcrm/doctype/crm_exotel_settings/test_crm_exotel_settings.py
new file mode 100644
index 00000000..476525f4
--- /dev/null
+++ b/crm/fcrm/doctype/crm_exotel_settings/test_crm_exotel_settings.py
@@ -0,0 +1,29 @@
+# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests import IntegrationTestCase, UnitTestCase
+
+# On IntegrationTestCase, the doctype test records and all
+# link-field test record dependencies are recursively loaded
+# Use these module variables to add/remove to/from that list
+EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+
+
+class UnitTestCRMExotelSettings(UnitTestCase):
+ """
+ Unit tests for CRMExotelSettings.
+ Use this class for testing individual functions and methods.
+ """
+
+ pass
+
+
+class IntegrationTestCRMExotelSettings(IntegrationTestCase):
+ """
+ Integration tests for CRMExotelSettings.
+ Use this class for testing interactions between multiple components.
+ """
+
+ pass
diff --git a/crm/fcrm/doctype/fcrm_settings/fcrm_settings.json b/crm/fcrm/doctype/fcrm_settings/fcrm_settings.json
index 1cb48bf7..0a902c99 100644
--- a/crm/fcrm/doctype/fcrm_settings/fcrm_settings.json
+++ b/crm/fcrm/doctype/fcrm_settings/fcrm_settings.json
@@ -12,7 +12,9 @@
"brand_logo",
"favicon",
"dropdown_items_tab",
- "dropdown_items"
+ "dropdown_items",
+ "calling_tab",
+ "default_calling_medium"
],
"fields": [
{
@@ -56,12 +58,23 @@
"fieldname": "favicon",
"fieldtype": "Attach",
"label": "Favicon"
+ },
+ {
+ "fieldname": "calling_tab",
+ "fieldtype": "Tab Break",
+ "label": "Calling"
+ },
+ {
+ "fieldname": "default_calling_medium",
+ "fieldtype": "Select",
+ "label": "Default calling medium",
+ "options": "\nTwilio\nExotel"
}
],
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2024-12-30 19:21:30.847343",
+ "modified": "2025-01-15 17:40:32.784762",
"modified_by": "Administrator",
"module": "FCRM",
"name": "FCRM Settings",
diff --git a/crm/fcrm/doctype/twilio_settings/twilio_settings.json b/crm/fcrm/doctype/twilio_settings/twilio_settings.json
index cf48bb6a..17e92cfb 100644
--- a/crm/fcrm/doctype/twilio_settings/twilio_settings.json
+++ b/crm/fcrm/doctype/twilio_settings/twilio_settings.json
@@ -23,6 +23,7 @@
],
"fields": [
{
+ "depends_on": "enabled",
"fieldname": "account_sid",
"fieldtype": "Data",
"in_list_view": 1,
@@ -30,6 +31,7 @@
"mandatory_depends_on": "eval: doc.enabled"
},
{
+ "depends_on": "enabled",
"fieldname": "api_key",
"fieldtype": "Data",
"label": "API Key",
@@ -37,6 +39,7 @@
"read_only": 1
},
{
+ "depends_on": "enabled",
"fieldname": "api_secret",
"fieldtype": "Password",
"label": "API Secret",
@@ -48,6 +51,7 @@
"fieldtype": "Column Break"
},
{
+ "depends_on": "enabled",
"fieldname": "auth_token",
"fieldtype": "Password",
"in_list_view": 1,
@@ -55,6 +59,7 @@
"mandatory_depends_on": "eval: doc.enabled"
},
{
+ "depends_on": "enabled",
"fieldname": "twiml_sid",
"fieldtype": "Data",
"label": "TwiML SID",
@@ -67,6 +72,7 @@
},
{
"default": "0",
+ "depends_on": "enabled",
"fieldname": "record_calls",
"fieldtype": "Check",
"label": "Record Calls"
@@ -77,7 +83,8 @@
},
{
"fieldname": "section_break_malx",
- "fieldtype": "Section Break"
+ "fieldtype": "Section Break",
+ "hide_border": 1
},
{
"default": "0",
@@ -87,7 +94,8 @@
},
{
"fieldname": "section_break_eklq",
- "fieldtype": "Section Break"
+ "fieldtype": "Section Break",
+ "hide_border": 1
},
{
"fieldname": "column_break_yqvr",
@@ -97,7 +105,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2024-12-30 19:23:04.401439",
+ "modified": "2025-01-15 19:35:13.406254",
"modified_by": "Administrator",
"module": "FCRM",
"name": "Twilio Settings",
diff --git a/crm/integrations/__init__.py b/crm/integrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/crm/integrations/api.py b/crm/integrations/api.py
new file mode 100644
index 00000000..145bf03a
--- /dev/null
+++ b/crm/integrations/api.py
@@ -0,0 +1,144 @@
+import frappe
+from frappe.query_builder import Order
+from pypika.functions import Replace
+
+from crm.utils import are_same_phone_number, parse_phone_number
+
+
+@frappe.whitelist()
+def is_call_integration_enabled():
+ twilio_enabled = frappe.db.get_single_value("Twilio Settings", "enabled")
+ exotel_enabled = frappe.db.get_single_value("CRM Exotel Settings", "enabled")
+ default_calling_medium = frappe.db.get_single_value("FCRM Settings", "default_calling_medium")
+
+ return {
+ "twilio_enabled": twilio_enabled,
+ "exotel_enabled": exotel_enabled,
+ "default_calling_medium": default_calling_medium,
+ }
+
+
+@frappe.whitelist()
+def set_default_calling_medium(medium):
+ return frappe.db.set_value("FCRM Settings", "FCRM Settings", "default_calling_medium", medium)
+
+
+@frappe.whitelist()
+def add_note_to_call_log(call_sid, note):
+ """Add/Update note to call log based on call sid."""
+ _note = None
+ if not note.get("name"):
+ _note = frappe.get_doc(
+ {
+ "doctype": "FCRM Note",
+ "content": note.get("content"),
+ }
+ ).insert(ignore_permissions=True)
+ call_log = frappe.get_cached_doc("CRM Call Log", call_sid)
+ call_log.link_with_reference_doc("FCRM Note", _note.name)
+ call_log.save(ignore_permissions=True)
+ else:
+ _note = frappe.set_value("FCRM Note", note.get("name"), "content", note.get("content"))
+
+ return _note
+
+
+@frappe.whitelist()
+def add_task_to_call_log(call_sid, task):
+ """Add/Update task to call log based on call sid."""
+ _task = None
+ if not task.get("name"):
+ _task = frappe.get_doc(
+ {
+ "doctype": "CRM Task",
+ "title": task.get("title"),
+ "description": task.get("description"),
+ }
+ ).insert(ignore_permissions=True)
+ call_log = frappe.get_doc("CRM Call Log", call_sid)
+ call_log.link_with_reference_doc("CRM Task", _task.name)
+ call_log.save(ignore_permissions=True)
+ else:
+ _task = frappe.get_doc("CRM Task", task.get("name"))
+ _task.update(
+ {
+ "title": task.get("title"),
+ "description": task.get("description"),
+ }
+ )
+ _task.save(ignore_permissions=True)
+
+ return _task
+
+
+@frappe.whitelist()
+def get_contact_by_phone_number(phone_number):
+ """Get contact by phone number."""
+ number = parse_phone_number(phone_number)
+
+ if number.get("is_valid"):
+ return get_contact(number.get("national_number"))
+ else:
+ return get_contact(phone_number, exact_match=True)
+
+
+def get_contact(phone_number, exact_match=False):
+ cleaned_number = (
+ phone_number.strip()
+ .replace(" ", "")
+ .replace("-", "")
+ .replace("(", "")
+ .replace(")", "")
+ .replace("+", "")
+ )
+
+ # Check if the number is associated with a contact
+ Contact = frappe.qb.DocType("Contact")
+ normalized_phone = Replace(
+ Replace(Replace(Replace(Replace(Contact.mobile_no, " ", ""), "-", ""), "(", ""), ")", ""), "+", ""
+ )
+
+ query = (
+ frappe.qb.from_(Contact)
+ .select(Contact.name, Contact.full_name, Contact.image, Contact.mobile_no)
+ .where(normalized_phone.like(f"%{cleaned_number}%"))
+ .orderby("modified", order=Order.desc)
+ )
+ contacts = query.run(as_dict=True)
+
+ if len(contacts):
+ # Check if the contact is associated with a deal
+ for contact in contacts:
+ if frappe.db.exists("CRM Contacts", {"contact": contact.name, "is_primary": 1}):
+ deal = frappe.db.get_value(
+ "CRM Contacts", {"contact": contact.name, "is_primary": 1}, "parent"
+ )
+ if are_same_phone_number(contact.mobile_no, phone_number, validate=not exact_match):
+ contact["deal"] = deal
+ return contact
+ # Else, return the first contact
+ if are_same_phone_number(contacts[0].mobile_no, phone_number, validate=not exact_match):
+ return contacts[0]
+
+ # Else, Check if the number is associated with a lead
+ Lead = frappe.qb.DocType("CRM Lead")
+ normalized_phone = Replace(
+ Replace(Replace(Replace(Replace(Lead.mobile_no, " ", ""), "-", ""), "(", ""), ")", ""), "+", ""
+ )
+
+ query = (
+ frappe.qb.from_(Lead)
+ .select(Lead.name, Lead.lead_name, Lead.image, Lead.mobile_no)
+ .where(Lead.converted == 0)
+ .where(normalized_phone.like(f"%{cleaned_number}%"))
+ .orderby("modified", order=Order.desc)
+ )
+ leads = query.run(as_dict=True)
+
+ if len(leads):
+ for lead in leads:
+ if are_same_phone_number(lead.mobile_no, phone_number, validate=not exact_match):
+ lead["lead"] = lead.name
+ return lead
+
+ return {"mobile_no": phone_number}
diff --git a/crm/integrations/exotel/handler.py b/crm/integrations/exotel/handler.py
new file mode 100644
index 00000000..1f01837c
--- /dev/null
+++ b/crm/integrations/exotel/handler.py
@@ -0,0 +1,257 @@
+import json
+
+import bleach
+import frappe
+import requests
+from frappe import _
+from frappe.integrations.utils import create_request_log
+
+from crm.integrations.api import get_contact_by_phone_number
+
+
+# Incoming Call
+@frappe.whitelist(allow_guest=True)
+def handle_request(**kwargs):
+ if not is_integration_enabled():
+ return
+
+ request_log = create_request_log(
+ kwargs,
+ request_description="Exotel Call",
+ service_name="Exotel",
+ request_headers=frappe.request.headers,
+ is_remote_request=1,
+ )
+
+ try:
+ request_log.status = "Completed"
+ exotel_settings = get_exotel_settings()
+ if not exotel_settings.enabled:
+ return
+
+ call_payload = kwargs
+
+ frappe.publish_realtime("exotel_call", call_payload)
+ status = call_payload.get("Status")
+ if status == "free":
+ return
+
+ if call_log := get_call_log(call_payload):
+ update_call_log(call_payload, call_log=call_log)
+ else:
+ create_call_log(
+ call_id=call_payload.get("CallSid"),
+ from_number=call_payload.get("CallFrom"),
+ to_number=call_payload.get("DialWhomNumber"),
+ medium=call_payload.get("To"),
+ status=get_call_log_status(call_payload),
+ agent=call_payload.get("AgentEmail"),
+ )
+ except Exception:
+ request_log.status = "Failed"
+ request_log.error = frappe.get_traceback()
+ frappe.db.rollback()
+ frappe.log_error(title="Error while creating/updating call record")
+ frappe.db.commit()
+ finally:
+ request_log.save(ignore_permissions=True)
+ frappe.db.commit()
+
+
+# Outgoing Call
+@frappe.whitelist()
+def make_a_call(to_number, from_number=None, caller_id=None):
+ if not is_integration_enabled():
+ frappe.throw(_("Please setup Exotel intergration"), title=_("Integration Not Enabled"))
+
+ endpoint = get_exotel_endpoint("Calls/connect.json?details=true")
+
+ if not from_number:
+ from_number = frappe.get_value("CRM Exotel Agent", {"user": frappe.session.user}, "mobile_no")
+
+ if not caller_id:
+ caller_id = frappe.get_value("CRM Exotel Agent", {"user": frappe.session.user}, "exotel_number")
+
+ if caller_id and caller_id not in get_all_exophones():
+ frappe.throw(_("Exotel Number {0} is not valid").format(caller_id), title=_("Invalid Exotel Number"))
+
+ if not from_number:
+ frappe.throw(
+ _("You do not have mobile number set in your Exotel Agent"), title=_("Mobile Number Missing")
+ )
+
+ record_call = frappe.db.get_single_value("CRM Exotel Settings", "record_call")
+
+ try:
+ response = requests.post(
+ endpoint,
+ data={
+ "From": from_number,
+ "To": to_number,
+ "CallerId": caller_id,
+ "Record": "true" if record_call else "false",
+ "StatusCallback": get_status_updater_url(),
+ "StatusCallbackEvents[0]": "terminal",
+ "StatusCallbackEvents[1]": "answered",
+ },
+ )
+ response.raise_for_status()
+ except requests.exceptions.HTTPError:
+ if exc := response.json().get("RestException"):
+ frappe.throw(bleach.linkify(exc.get("Message")), title=_("Exotel Exception"))
+ else:
+ res = response.json()
+ call_payload = res.get("Call", {})
+
+ create_call_log(
+ call_id=call_payload.get("Sid"),
+ from_number=call_payload.get("From"),
+ to_number=call_payload.get("To"),
+ medium=call_payload.get("PhoneNumberSid"),
+ call_type="Outgoing",
+ agent=frappe.session.user,
+ )
+
+ return response.json()
+
+
+def get_exotel_endpoint(action=None):
+ settings = get_exotel_settings()
+ return "https://{api_key}:{api_token}@api.exotel.com/v1/Accounts/{sid}/{action}".format(
+ api_key=settings.api_key,
+ api_token=settings.get_password("api_token"),
+ sid=settings.account_sid,
+ action=action,
+ )
+
+
+def get_all_exophones():
+ endpoint = get_exotel_endpoint("IncomingPhoneNumbers.json")
+ response = requests.get(endpoint)
+ return [
+ phone.get("IncomingPhoneNumber", {}).get("PhoneNumber")
+ for phone in response.json().get("IncomingPhoneNumbers", [])
+ ]
+
+
+def get_status_updater_url():
+ from frappe.utils.data import get_url
+
+ return get_url("api/method/crm.integrations.exotel.handler.handle_request")
+
+
+def get_exotel_settings():
+ return frappe.get_single("CRM Exotel Settings")
+
+
+@frappe.whitelist()
+def is_integration_enabled():
+ return frappe.db.get_single_value("CRM Exotel Settings", "enabled", True)
+
+
+# Call Log Functions
+def create_call_log(
+ call_id,
+ from_number,
+ to_number,
+ medium,
+ agent,
+ status="Ringing",
+ call_type="Incoming",
+):
+ call_log = frappe.new_doc("CRM Call Log")
+ call_log.id = call_id
+ call_log.to = to_number
+ call_log.medium = medium
+ call_log.type = call_type
+ call_log.status = status
+ call_log.telephony_medium = "Exotel"
+ setattr(call_log, "from", from_number)
+
+ if call_type == "Incoming":
+ call_log.receiver = agent
+ else:
+ call_log.caller = agent
+
+ # link call log with lead/deal
+ contact_number = from_number if call_type == "Incoming" else to_number
+ link(contact_number, call_log)
+
+ call_log.save(ignore_permissions=True)
+ frappe.db.commit()
+ return call_log
+
+
+def link(contact_number, call_log):
+ contact = get_contact_by_phone_number(contact_number)
+ if contact.get("name"):
+ doctype = "Contact"
+ docname = contact.get("name")
+ if contact.get("lead"):
+ doctype = "CRM Lead"
+ docname = contact.get("lead")
+ elif contact.get("deal"):
+ doctype = "CRM Deal"
+ docname = contact.get("deal")
+ call_log.link_with_reference_doc(doctype, docname)
+
+
+def get_call_log(call_payload):
+ call_log_id = call_payload.get("CallSid")
+ if frappe.db.exists("CRM Call Log", call_log_id):
+ return frappe.get_doc("CRM Call Log", call_log_id)
+
+
+def get_call_log_status(call_payload, direction="inbound"):
+ if direction == "outbound-api" or direction == "outbound-dial":
+ status = call_payload.get("Status")
+ if status == "completed":
+ return "Completed"
+ elif status == "in-progress":
+ return "In Progress"
+ elif status == "busy":
+ return "Ringing"
+ elif status == "no-answer":
+ return "No Answer"
+ elif status == "failed":
+ return "Failed"
+
+ status = call_payload.get("DialCallStatus")
+ call_type = call_payload.get("CallType")
+ dial_call_status = call_payload.get("DialCallStatus")
+
+ if call_type == "incomplete" and dial_call_status == "no-answer":
+ status = "No Answer"
+ elif call_type == "client-hangup" and dial_call_status == "canceled":
+ status = "Canceled"
+ elif call_type == "incomplete" and dial_call_status == "failed":
+ status = "Failed"
+ elif call_type == "completed":
+ status = "Completed"
+ elif dial_call_status == "busy":
+ status = "Ringing"
+
+ return status
+
+
+def update_call_log(call_payload, status="Ringing", call_log=None):
+ direction = call_payload.get("Direction")
+ call_log = call_log or get_call_log(call_payload)
+ status = get_call_log_status(call_payload, direction)
+ try:
+ if call_log:
+ call_log.status = status
+ # resetting this because call might be redirected to other number
+ call_log.to = call_payload.get("DialWhomNumber") or call_payload.get("To")
+ call_log.duration = (
+ call_payload.get("DialCallDuration") or call_payload.get("ConversationDuration") or 0
+ )
+ call_log.recording_url = call_payload.get("RecordingUrl")
+ call_log.start_time = call_payload.get("StartTime")
+ call_log.end_time = call_payload.get("EndTime")
+ call_log.save(ignore_permissions=True)
+ frappe.db.commit()
+ return call_log
+ except Exception:
+ frappe.log_error(title="Error while updating call record")
+ frappe.db.commit()
diff --git a/crm/integrations/twilio/api.py b/crm/integrations/twilio/api.py
index 5ed4c28e..e78e0214 100644
--- a/crm/integrations/twilio/api.py
+++ b/crm/integrations/twilio/api.py
@@ -1,44 +1,46 @@
-from werkzeug.wrappers import Response
import json
import frappe
from frappe import _
-from .twilio_handler import Twilio, IncomingCall, TwilioCallDetails
-from .utils import parse_mobile_no
+from werkzeug.wrappers import Response
+
+from crm.integrations.api import get_contact_by_phone_number
+
+from .twilio_handler import IncomingCall, Twilio, TwilioCallDetails
+
@frappe.whitelist()
def is_enabled():
return frappe.db.get_single_value("Twilio Settings", "enabled")
+
@frappe.whitelist()
def generate_access_token():
- """Returns access token that is required to authenticate Twilio Client SDK.
- """
+ """Returns access token that is required to authenticate Twilio Client SDK."""
twilio = Twilio.connect()
if not twilio:
return {}
- from_number = frappe.db.get_value('Twilio Agents', frappe.session.user, 'twilio_number')
+ from_number = frappe.db.get_value("Twilio Agents", frappe.session.user, "twilio_number")
if not from_number:
return {
"ok": False,
"error": "caller_phone_identity_missing",
- "detail": "Phone number is not mapped to the caller"
+ "detail": "Phone number is not mapped to the caller",
}
- token=twilio.generate_voice_access_token(identity=frappe.session.user)
- return {
- 'token': frappe.safe_decode(token)
- }
+ token = twilio.generate_voice_access_token(identity=frappe.session.user)
+ return {"token": frappe.safe_decode(token)}
+
@frappe.whitelist(allow_guest=True)
def voice(**kwargs):
- """This is a webhook called by twilio to get instructions when the voice call request comes to twilio server.
- """
+ """This is a webhook called by twilio to get instructions when the voice call request comes to twilio server."""
+
def _get_caller_number(caller):
- identity = caller.replace('client:', '').strip()
+ identity = caller.replace("client:", "").strip()
user = Twilio.emailid_from_identity(identity)
- return frappe.db.get_value('Twilio Agents', user, 'twilio_number')
+ return frappe.db.get_value("Twilio Agents", user, "twilio_number")
args = frappe._dict(kwargs)
twilio = Twilio.connect()
@@ -54,7 +56,8 @@ def voice(**kwargs):
call_details = TwilioCallDetails(args, call_from=from_number)
create_call_log(call_details)
- return Response(resp.to_xml(), mimetype='text/xml')
+ return Response(resp.to_xml(), mimetype="text/xml")
+
@frappe.whitelist(allow_guest=True)
def twilio_incoming_call_handler(**kwargs):
@@ -63,36 +66,57 @@ def twilio_incoming_call_handler(**kwargs):
create_call_log(call_details)
resp = IncomingCall(args.From, args.To).process()
- return Response(resp.to_xml(), mimetype='text/xml')
+ return Response(resp.to_xml(), mimetype="text/xml")
+
def create_call_log(call_details: TwilioCallDetails):
- call_log = frappe.get_doc({**call_details.to_dict(),
- 'doctype': 'CRM Call Log',
- 'medium': 'Twilio'
- })
- call_log.reference_docname, call_log.reference_doctype = get_lead_or_deal_from_number(call_log)
- call_log.flags.ignore_permissions = True
- call_log.save()
+ details = call_details.to_dict()
+
+ call_log = frappe.get_doc({**details, "doctype": "CRM Call Log", "telephony_medium": "Twilio"})
+
+ # link call log with lead/deal
+ contact_number = details.get("from") if details.get("type") == "Incoming" else details.get("to")
+ link(contact_number, call_log)
+
+ call_log.save(ignore_permissions=True)
frappe.db.commit()
+ return call_log
+
+
+def link(contact_number, call_log):
+ contact = get_contact_by_phone_number(contact_number)
+ if contact.get("name"):
+ doctype = "Contact"
+ docname = contact.get("name")
+ if contact.get("lead"):
+ doctype = "CRM Lead"
+ docname = contact.get("lead")
+ elif contact.get("deal"):
+ doctype = "CRM Deal"
+ docname = contact.get("deal")
+ call_log.link_with_reference_doc(doctype, docname)
+
def update_call_log(call_sid, status=None):
- """Update call log status.
- """
+ """Update call log status."""
twilio = Twilio.connect()
- if not (twilio and frappe.db.exists("CRM Call Log", call_sid)): return
+ if not (twilio and frappe.db.exists("CRM Call Log", call_sid)):
+ return
+
+ try:
+ call_details = twilio.get_call_info(call_sid)
+ call_log = frappe.get_doc("CRM Call Log", call_sid)
+ call_log.status = TwilioCallDetails.get_call_status(status or call_details.status)
+ call_log.duration = call_details.duration
+ call_log.start_time = get_datetime_from_timestamp(call_details.start_time)
+ call_log.end_time = get_datetime_from_timestamp(call_details.end_time)
+ call_log.save(ignore_permissions=True)
+ frappe.db.commit()
+ return call_log
+ except Exception:
+ frappe.log_error(title="Error while updating call record")
+ frappe.db.commit()
- call_details = twilio.get_call_info(call_sid)
- call_log = frappe.get_doc("CRM Call Log", call_sid)
- call_log.status = TwilioCallDetails.get_call_status(status or call_details.status)
- call_log.duration = call_details.duration
- call_log.start_time = get_datetime_from_timestamp(call_details.start_time)
- call_log.end_time = get_datetime_from_timestamp(call_details.end_time)
- if call_log.note and call_log.reference_docname:
- frappe.db.set_value("FCRM Note", call_log.note, "reference_doctype", call_log.reference_doctype)
- frappe.db.set_value("FCRM Note", call_log.note, "reference_docname", call_log.reference_docname)
- call_log.flags.ignore_permissions = True
- call_log.save()
- frappe.db.commit()
@frappe.whitelist(allow_guest=True)
def update_recording_info(**kwargs):
@@ -102,9 +126,10 @@ def update_recording_info(**kwargs):
call_sid = args.CallSid
update_call_log(call_sid)
frappe.db.set_value("CRM Call Log", call_sid, "recording_url", recording_url)
- except:
+ except Exception:
frappe.log_error(title=_("Failed to capture Twilio recording"))
+
@frappe.whitelist(allow_guest=True)
def update_call_status_info(**kwargs):
try:
@@ -113,70 +138,29 @@ def update_call_status_info(**kwargs):
update_call_log(parent_call_sid, status=args.CallStatus)
call_info = {
- 'ParentCallSid': args.ParentCallSid,
- 'CallSid': args.CallSid,
- 'CallStatus': args.CallStatus,
- 'CallDuration': args.CallDuration,
- 'From': args.From,
- 'To': args.To,
+ "ParentCallSid": args.ParentCallSid,
+ "CallSid": args.CallSid,
+ "CallStatus": args.CallStatus,
+ "CallDuration": args.CallDuration,
+ "From": args.From,
+ "To": args.To,
}
client = Twilio.get_twilio_client()
- client.calls(args.ParentCallSid).user_defined_messages.create(
- content=json.dumps(call_info)
- )
- except:
+ client.calls(args.ParentCallSid).user_defined_messages.create(content=json.dumps(call_info))
+ except Exception:
frappe.log_error(title=_("Failed to update Twilio call status"))
+
def get_datetime_from_timestamp(timestamp):
from datetime import datetime
from zoneinfo import ZoneInfo
- if not timestamp: return None
+ if not timestamp:
+ return None
- datetime_utc_tz_str = timestamp.strftime('%Y-%m-%d %H:%M:%S%z')
- datetime_utc_tz = datetime.strptime(datetime_utc_tz_str, '%Y-%m-%d %H:%M:%S%z')
+ datetime_utc_tz_str = timestamp.strftime("%Y-%m-%d %H:%M:%S%z")
+ datetime_utc_tz = datetime.strptime(datetime_utc_tz_str, "%Y-%m-%d %H:%M:%S%z")
system_timezone = frappe.utils.get_system_timezone()
converted_datetime = datetime_utc_tz.astimezone(ZoneInfo(system_timezone))
- return frappe.utils.format_datetime(converted_datetime, 'yyyy-MM-dd HH:mm:ss')
-
-@frappe.whitelist()
-def add_note_to_call_log(call_sid, note):
- """Add note to call log. based on child call sid.
- """
- twilio = Twilio.connect()
- if not twilio: return
-
- call_details = twilio.get_call_info(call_sid)
- sid = call_sid if call_details.direction == 'inbound' else call_details.parent_call_sid
-
- frappe.db.set_value("CRM Call Log", sid, "note", note)
- frappe.db.commit()
-
-def get_lead_or_deal_from_number(call):
- """Get lead/deal from the given number.
- """
-
- def find_record(doctype, mobile_no, where=''):
- mobile_no = parse_mobile_no(mobile_no)
-
- query = f"""
- SELECT name, mobile_no
- FROM `tab{doctype}`
- WHERE CONCAT('+', REGEXP_REPLACE(mobile_no, '[^0-9]', '')) = {mobile_no}
- """
-
- data = frappe.db.sql(query + where, as_dict=True)
- return data[0].name if data else None
-
- doctype = "CRM Deal"
- number = call.get('to') if call.type == 'Outgoing' else call.get('from')
-
- doc = find_record(doctype, number) or None
- if not doc:
- doctype = "CRM Lead"
- doc = find_record(doctype, number, 'AND converted is not True')
- if not doc:
- doc = find_record(doctype, number)
-
- return doc, doctype
+ return frappe.utils.format_datetime(converted_datetime, "yyyy-MM-dd HH:mm:ss")
diff --git a/crm/integrations/twilio/twilio_handler.py b/crm/integrations/twilio/twilio_handler.py
index 1ece9296..b24e0e9d 100644
--- a/crm/integrations/twilio/twilio_handler.py
+++ b/crm/integrations/twilio/twilio_handler.py
@@ -1,16 +1,17 @@
-from twilio.rest import Client as TwilioClient
-from twilio.jwt.access_token import AccessToken
-from twilio.jwt.access_token.grants import VoiceGrant
-from twilio.twiml.voice_response import VoiceResponse, Dial
-from .utils import get_public_url, merge_dicts
-
import frappe
from frappe import _
from frappe.utils.password import get_decrypted_password
+from twilio.jwt.access_token import AccessToken
+from twilio.jwt.access_token.grants import VoiceGrant
+from twilio.rest import Client as TwilioClient
+from twilio.twiml.voice_response import Dial, VoiceResponse
+
+from .utils import get_public_url, merge_dicts
+
class Twilio:
- """Twilio connector over TwilioClient.
- """
+ """Twilio connector over TwilioClient."""
+
def __init__(self, settings):
"""
:param settings: `Twilio Settings` doctype
@@ -24,22 +25,19 @@ class Twilio:
@classmethod
def connect(self):
- """Make a twilio connection.
- """
+ """Make a twilio connection."""
settings = frappe.get_doc("Twilio Settings")
if not (settings and settings.enabled):
return
return Twilio(settings=settings)
def get_phone_numbers(self):
- """Get account's twilio phone numbers.
- """
+ """Get account's twilio phone numbers."""
numbers = self.twilio_client.incoming_phone_numbers.list()
return [n.phone_number for n in numbers]
- def generate_voice_access_token(self, identity: str, ttl=60*60):
- """Generates a token required to make voice calls from the browser.
- """
+ def generate_voice_access_token(self, identity: str, ttl=60 * 60):
+ """Generates a token required to make voice calls from the browser."""
# identity is used by twilio to identify the user uniqueness at browser(or any endpoints).
identity = self.safe_identity(identity)
@@ -49,7 +47,7 @@ class Twilio:
# Create a Voice grant and add to token
voice_grant = VoiceGrant(
outgoing_application_sid=self.application_sid,
- incoming_allow=True, # Allow incoming calls
+ incoming_allow=True, # Allow incoming calls
)
token.add_grant(voice_grant)
return token.to_jwt()
@@ -60,14 +58,13 @@ class Twilio:
Twilio Client JS fails to make a call connection if identity has special characters like @, [, / etc)
https://www.twilio.com/docs/voice/client/errors (#31105)
"""
- return identity.replace('@', '(at)')
+ return identity.replace("@", "(at)")
@classmethod
def emailid_from_identity(cls, identity: str):
- """Convert safe identity string into emailID.
- """
- return identity.replace('(at)', '@')
-
+ """Convert safe identity string into emailID."""
+ return identity.replace("(at)", "@")
+
def get_recording_status_callback_url(self):
url_path = "/api/method/crm.integrations.twilio.api.update_recording_info"
return get_public_url(url_path)
@@ -77,20 +74,19 @@ class Twilio:
return get_public_url(url_path)
def generate_twilio_dial_response(self, from_number: str, to_number: str):
- """Generates voice call instructions to forward the call to agents Phone.
- """
+ """Generates voice call instructions to forward the call to agents Phone."""
resp = VoiceResponse()
dial = Dial(
caller_id=from_number,
record=self.settings.record_calls,
recording_status_callback=self.get_recording_status_callback_url(),
- recording_status_callback_event='completed'
+ recording_status_callback_event="completed",
)
dial.number(
to_number,
- status_callback_event='initiated ringing answered completed',
+ status_callback_event="initiated ringing answered completed",
status_callback=self.get_update_call_status_callback_url(),
- status_callback_method='POST'
+ status_callback_method="POST",
)
resp.append(dial)
return resp
@@ -98,21 +94,20 @@ class Twilio:
def get_call_info(self, call_sid):
return self.twilio_client.calls(call_sid).fetch()
- def generate_twilio_client_response(self, client, ring_tone='at'):
- """Generates voice call instructions to forward the call to agents computer.
- """
+ def generate_twilio_client_response(self, client, ring_tone="at"):
+ """Generates voice call instructions to forward the call to agents computer."""
resp = VoiceResponse()
dial = Dial(
ring_tone=ring_tone,
record=self.settings.record_calls,
recording_status_callback=self.get_recording_status_callback_url(),
- recording_status_callback_event='completed'
+ recording_status_callback_event="completed",
)
dial.client(
client,
- status_callback_event='initiated ringing answered completed',
+ status_callback_event="initiated ringing answered completed",
status_callback=self.get_update_call_status_callback_url(),
- status_callback_method='POST'
+ status_callback_method="POST",
)
resp.append(dial)
return resp
@@ -122,12 +117,13 @@ class Twilio:
twilio_settings = frappe.get_doc("Twilio Settings")
if not twilio_settings.enabled:
frappe.throw(_("Please enable twilio settings before making a call."))
-
- auth_token = get_decrypted_password("Twilio Settings", "Twilio Settings", 'auth_token')
+
+ auth_token = get_decrypted_password("Twilio Settings", "Twilio Settings", "auth_token")
client = TwilioClient(twilio_settings.account_sid, auth_token)
return client
+
class IncomingCall:
def __init__(self, from_number, to_number, meta=None):
self.from_number = from_number
@@ -145,17 +141,18 @@ class IncomingCall:
if not attender:
resp = VoiceResponse()
- resp.say(_('Agent is unavailable to take the call, please call after some time.'))
+ resp.say(_("Agent is unavailable to take the call, please call after some time."))
return resp
- if attender['call_receiving_device'] == 'Phone':
- return twilio.generate_twilio_dial_response(self.from_number, attender['mobile_no'])
+ if attender["call_receiving_device"] == "Phone":
+ return twilio.generate_twilio_dial_response(self.from_number, attender["mobile_no"])
else:
- return twilio.generate_twilio_client_response(twilio.safe_identity(attender['name']))
+ return twilio.generate_twilio_client_response(twilio.safe_identity(attender["name"]))
+
def get_twilio_number_owners(phone_number):
"""Get list of users who is using the phone_number.
- >>> get_twilio_number_owners('+11234567890')
+ >>> get_twilio_number_owners("+11234567890")
{
'owner1': {'name': '..', 'mobile_no': '..', 'call_receiving_device': '...'},
'owner2': {....}
@@ -163,105 +160,106 @@ def get_twilio_number_owners(phone_number):
"""
# remove special characters from phone number and get only digits also remove white spaces
# keep + sign in the number at start of the number
- phone_number = ''.join([c for c in phone_number if c.isdigit() or c == '+'])
+ phone_number = "".join([c for c in phone_number if c.isdigit() or c == "+"])
user_voice_settings = frappe.get_all(
- 'Twilio Agents',
- filters={'twilio_number': phone_number},
- fields=["name", "call_receiving_device"]
+ "Twilio Agents", filters={"twilio_number": phone_number}, fields=["name", "call_receiving_device"]
)
- user_wise_voice_settings = {user['name']: user for user in user_voice_settings}
+ user_wise_voice_settings = {user["name"]: user for user in user_voice_settings}
user_general_settings = frappe.get_all(
- 'User',
- filters = [['name', 'IN', user_wise_voice_settings.keys()]],
- fields = ['name', 'mobile_no']
+ "User", filters=[["name", "IN", user_wise_voice_settings.keys()]], fields=["name", "mobile_no"]
)
- user_wise_general_settings = {user['name']: user for user in user_general_settings}
+ user_wise_general_settings = {user["name"]: user for user in user_general_settings}
return merge_dicts(user_wise_general_settings, user_wise_voice_settings)
+
def get_active_loggedin_users(users):
- """Filter the current loggedin users from the given users list
- """
- rows = frappe.db.sql("""
+ """Filter the current loggedin users from the given users list"""
+ rows = frappe.db.sql(
+ """
SELECT `user`
FROM `tabSessions`
WHERE `user` IN %(users)s
- """, {'users': users})
+ """,
+ {"users": users},
+ )
return [row[0] for row in set(rows)]
+
def get_the_call_attender(owners, caller=None):
- """Get attender details from list of owners
- """
- if not owners: return
+ """Get attender details from list of owners"""
+ if not owners:
+ return
current_loggedin_users = get_active_loggedin_users(list(owners.keys()))
if len(current_loggedin_users) > 1 and caller:
- deal_owner = frappe.db.get_value('CRM Deal', {'mobile_no': caller}, 'deal_owner')
+ deal_owner = frappe.db.get_value("CRM Deal", {"mobile_no": caller}, "deal_owner")
if not deal_owner:
- deal_owner = frappe.db.get_value('CRM Lead', {'mobile_no': caller, 'converted': False}, 'lead_owner')
+ deal_owner = frappe.db.get_value(
+ "CRM Lead", {"mobile_no": caller, "converted": False}, "lead_owner"
+ )
for user in current_loggedin_users:
if user == deal_owner:
current_loggedin_users = [user]
for name, details in owners.items():
- if ((details['call_receiving_device'] == 'Phone' and details['mobile_no']) or
- (details['call_receiving_device'] == 'Computer' and name in current_loggedin_users)):
+ if (details["call_receiving_device"] == "Phone" and details["mobile_no"]) or (
+ details["call_receiving_device"] == "Computer" and name in current_loggedin_users
+ ):
return details
class TwilioCallDetails:
- def __init__(self, call_info, call_from = None, call_to = None):
+ def __init__(self, call_info, call_from=None, call_to=None):
self.call_info = call_info
- self.account_sid = call_info.get('AccountSid')
- self.application_sid = call_info.get('ApplicationSid')
- self.call_sid = call_info.get('CallSid')
- self.call_status = self.get_call_status(call_info.get('CallStatus'))
- self._call_from = call_from or call_info.get('From')
- self._call_to = call_to or call_info.get('To')
+ self.account_sid = call_info.get("AccountSid")
+ self.application_sid = call_info.get("ApplicationSid")
+ self.call_sid = call_info.get("CallSid")
+ self.call_status = self.get_call_status(call_info.get("CallStatus"))
+ self._call_from = call_from or call_info.get("From")
+ self._call_to = call_to or call_info.get("To")
def get_direction(self):
- if self.call_info.get('Caller').lower().startswith('client'):
- return 'Outgoing'
- return 'Incoming'
+ if self.call_info.get("Caller").lower().startswith("client"):
+ return "Outgoing"
+ return "Incoming"
def get_from_number(self):
- return self._call_from or self.call_info.get('From')
+ return self._call_from or self.call_info.get("From")
def get_to_number(self):
- return self._call_to or self.call_info.get('To')
+ return self._call_to or self.call_info.get("To")
@classmethod
def get_call_status(cls, twilio_status):
- """Convert Twilio given status into system status.
- """
- twilio_status = twilio_status or ''
- return ' '.join(twilio_status.split('-')).title()
+ """Convert Twilio given status into system status."""
+ twilio_status = twilio_status or ""
+ return " ".join(twilio_status.split("-")).title()
def to_dict(self):
- """Convert call details into dict.
- """
+ """Convert call details into dict."""
direction = self.get_direction()
from_number = self.get_from_number()
to_number = self.get_to_number()
- caller = ''
- receiver = ''
+ caller = ""
+ receiver = ""
- if direction == 'Outgoing':
- caller = self.call_info.get('Caller')
- identity = caller.replace('client:', '').strip()
- caller = Twilio.emailid_from_identity(identity) if identity else ''
+ if direction == "Outgoing":
+ caller = self.call_info.get("Caller")
+ identity = caller.replace("client:", "").strip()
+ caller = Twilio.emailid_from_identity(identity) if identity else ""
else:
owners = get_twilio_number_owners(to_number)
attender = get_the_call_attender(owners, from_number)
- receiver = attender['name'] if attender else ''
+ receiver = attender["name"] if attender else ""
return {
- 'type': direction,
- 'status': self.call_status,
- 'id': self.call_sid,
- 'from': from_number,
- 'to': to_number,
- 'receiver': receiver,
- 'caller': caller,
- }
\ No newline at end of file
+ "type": direction,
+ "status": self.call_status,
+ "id": self.call_sid,
+ "from": from_number,
+ "to": to_number,
+ "receiver": receiver,
+ "caller": caller,
+ }
diff --git a/crm/integrations/twilio/utils.py b/crm/integrations/twilio/utils.py
index 2f037684..0430f311 100644
--- a/crm/integrations/twilio/utils.py
+++ b/crm/integrations/twilio/utils.py
@@ -1,7 +1,7 @@
from frappe.utils import get_url
-def get_public_url(path: str=None):
+def get_public_url(path: str | None = None):
return get_url().split(":8", 1)[0] + path
@@ -13,11 +13,5 @@ def merge_dicts(d1: dict, d2: dict):
)
... {'name1': {'age': 20, 'phone': '+xxx'}, 'name2': {'age': 30, 'phone': '+yyy'}}
"""
- return {k:{**v, **d2.get(k, {})} for k, v in d1.items()}
+ return {k: {**v, **d2.get(k, {})} for k, v in d1.items()}
-def parse_mobile_no(mobile_no: str):
- """Parse mobile number to remove spaces, brackets, etc.
- >>> parse_mobile_no('+91 (766) 667 6666')
- ... '+917666676666'
- """
- return ''.join([c for c in mobile_no if c.isdigit() or c == '+'])
\ No newline at end of file
diff --git a/crm/utils/__init__.py b/crm/utils/__init__.py
new file mode 100644
index 00000000..cd4a4ff8
--- /dev/null
+++ b/crm/utils/__init__.py
@@ -0,0 +1,95 @@
+import phonenumbers
+from frappe.utils import floor
+from phonenumbers import NumberParseException
+from phonenumbers import PhoneNumberFormat as PNF
+
+
+def parse_phone_number(phone_number, default_country="IN"):
+ try:
+ # Parse the number
+ number = phonenumbers.parse(phone_number, default_country)
+
+ # Get various information about the number
+ result = {
+ "is_valid": phonenumbers.is_valid_number(number),
+ "country_code": number.country_code,
+ "national_number": str(number.national_number),
+ "formats": {
+ "international": phonenumbers.format_number(number, PNF.INTERNATIONAL),
+ "national": phonenumbers.format_number(number, PNF.NATIONAL),
+ "E164": phonenumbers.format_number(number, PNF.E164),
+ "RFC3966": phonenumbers.format_number(number, PNF.RFC3966),
+ },
+ "type": phonenumbers.number_type(number),
+ "country": phonenumbers.region_code_for_number(number),
+ "is_possible": phonenumbers.is_possible_number(number),
+ }
+
+ return {"success": True, **result}
+ except NumberParseException as e:
+ return {"success": False, "error": str(e)}
+
+
+def are_same_phone_number(number1, number2, default_region="IN", validate=True):
+ """
+ Check if two phone numbers are the same, regardless of their format.
+
+ Args:
+ number1 (str): First phone number
+ number2 (str): Second phone number
+ default_region (str): Default region code for parsing ambiguous numbers
+
+ Returns:
+ bool: True if numbers are same, False otherwise
+ """
+ try:
+ # Parse both numbers
+ parsed1 = phonenumbers.parse(number1, default_region)
+ parsed2 = phonenumbers.parse(number2, default_region)
+
+ # Check if both numbers are valid
+ if validate and not (phonenumbers.is_valid_number(parsed1) and phonenumbers.is_valid_number(parsed2)):
+ return False
+
+ # Convert both to E164 format and compare
+ formatted1 = phonenumbers.format_number(parsed1, phonenumbers.PhoneNumberFormat.E164)
+ formatted2 = phonenumbers.format_number(parsed2, phonenumbers.PhoneNumberFormat.E164)
+
+ return formatted1 == formatted2
+
+ except phonenumbers.NumberParseException:
+ return False
+
+
+def seconds_to_duration(seconds):
+ if not seconds:
+ return "0s"
+
+ hours = floor(seconds // 3600)
+ minutes = floor((seconds % 3600) // 60)
+ seconds = floor((seconds % 3600) % 60)
+
+ # 1h 0m 0s -> 1h
+ # 0h 1m 0s -> 1m
+ # 0h 0m 1s -> 1s
+ # 1h 1m 0s -> 1h 1m
+ # 1h 0m 1s -> 1h 1s
+ # 0h 1m 1s -> 1m 1s
+ # 1h 1m 1s -> 1h 1m 1s
+
+ if hours and minutes and seconds:
+ return f"{hours}h {minutes}m {seconds}s"
+ elif hours and minutes:
+ return f"{hours}h {minutes}m"
+ elif hours and seconds:
+ return f"{hours}h {seconds}s"
+ elif minutes and seconds:
+ return f"{minutes}m {seconds}s"
+ elif hours:
+ return f"{hours}h"
+ elif minutes:
+ return f"{minutes}m"
+ elif seconds:
+ return f"{seconds}s"
+ else:
+ return "0s"
diff --git a/frappe-ui b/frappe-ui
index d82b3a12..863eaae9 160000
--- a/frappe-ui
+++ b/frappe-ui
@@ -1 +1 @@
-Subproject commit d82b3a12eeb6cb9e83375550508b462ce5cfdaf2
+Subproject commit 863eaae9ada2edb287fc09fb21d05212bb5eebe9
diff --git a/frontend/package.json b/frontend/package.json
index 377a8c34..1ac29f78 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -14,7 +14,7 @@
"@vueuse/core": "^10.3.0",
"@vueuse/integrations": "^10.3.0",
"feather-icons": "^4.28.0",
- "frappe-ui": "^0.1.104",
+ "frappe-ui": "^0.1.105",
"gemoji": "^8.1.0",
"lodash": "^4.17.21",
"mime": "^4.0.1",
@@ -22,7 +22,7 @@
"socket.io-client": "^4.7.2",
"sortablejs": "^1.15.0",
"tailwindcss": "^3.3.3",
- "vue": "^3.4.12",
+ "vue": "^3.5.13",
"vue-router": "^4.2.2",
"vuedraggable": "^4.1.0"
},
diff --git a/frontend/src/components/Activities/Activities.vue b/frontend/src/components/Activities/Activities.vue
index baf28de0..392bf4b5 100644
--- a/frontend/src/components/Activities/Activities.vue
+++ b/frontend/src/components/Activities/Activities.vue
@@ -484,7 +484,7 @@ import CommunicationArea from '@/components/CommunicationArea.vue'
import WhatsappTemplateSelectorModal from '@/components/Modals/WhatsappTemplateSelectorModal.vue'
import AllModals from '@/components/Activities/AllModals.vue'
import FilesUploader from '@/components/FilesUploader/FilesUploader.vue'
-import { timeAgo, formatDate, secondsToDuration, startCase } from '@/utils'
+import { timeAgo, formatDate, startCase } from '@/utils'
import { globalStore } from '@/stores/global'
import { usersStore } from '@/stores/users'
import { contactsStore } from '@/stores/contacts'
@@ -544,40 +544,6 @@ const all_activities = createResource({
cache: ['activity', doc.value.data.name],
auto: true,
transform: ([versions, calls, notes, tasks, attachments]) => {
- if (calls?.length) {
- calls.forEach((doc) => {
- doc.show_recording = false
- doc.activity_type =
- doc.type === 'Incoming' ? 'incoming_call' : 'outgoing_call'
- doc.duration = secondsToDuration(doc.duration)
- if (doc.type === 'Incoming') {
- doc.caller = {
- label:
- getContact(doc.from)?.full_name ||
- getLeadContact(doc.from)?.full_name ||
- 'Unknown',
- image:
- getContact(doc.from)?.image || getLeadContact(doc.from)?.image,
- }
- doc.receiver = {
- label: getUser(doc.receiver).full_name,
- image: getUser(doc.receiver).user_image,
- }
- } else {
- doc.caller = {
- label: getUser(doc.caller).full_name,
- image: getUser(doc.caller).user_image,
- }
- doc.receiver = {
- label:
- getContact(doc.to)?.full_name ||
- getLeadContact(doc.to)?.full_name ||
- 'Unknown',
- image: getContact(doc.to)?.image || getLeadContact(doc.to)?.image,
- }
- }
- })
- }
return { versions, calls, notes, tasks, attachments }
},
})
@@ -662,13 +628,13 @@ const activities = computed(() => {
return sortByCreation(all_activities.data.calls)
} else if (title.value == 'Tasks') {
if (!all_activities.data?.tasks) return []
- return sortByCreation(all_activities.data.tasks)
+ return sortByModified(all_activities.data.tasks)
} else if (title.value == 'Notes') {
if (!all_activities.data?.notes) return []
- return sortByCreation(all_activities.data.notes)
+ return sortByModified(all_activities.data.notes)
} else if (title.value == 'Attachments') {
if (!all_activities.data?.attachments) return []
- return sortByCreation(all_activities.data.attachments)
+ return sortByModified(all_activities.data.attachments)
}
_activities.forEach((activity) => {
@@ -696,6 +662,9 @@ const activities = computed(() => {
function sortByCreation(list) {
return list.sort((a, b) => new Date(a.creation) - new Date(b.creation))
}
+function sortByModified(list) {
+ return list.sort((b, a) => new Date(a.modified) - new Date(b.modified))
+}
function update_activities_details(activity) {
activity.owner_name = getUser(activity.owner).full_name
diff --git a/frontend/src/components/Activities/CallArea.vue b/frontend/src/components/Activities/CallArea.vue
index 48fe6f72..d2689580 100644
--- a/frontend/src/components/Activities/CallArea.vue
+++ b/frontend/src/components/Activities/CallArea.vue
@@ -1,5 +1,5 @@
-