From fa0a2a10ed407ad641bbf3e09f6e79037ad3cc7a Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Thu, 16 Jan 2025 21:14:05 +0530 Subject: [PATCH 01/61] fix: do not override field.options meta --- crm/api/doc.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crm/api/doc.py b/crm/api/doc.py index 4001fa7e..641a7f0f 100644 --- a/crm/api/doc.py +++ b/crm/api/doc.py @@ -182,6 +182,11 @@ def get_quick_filters(doctype: str): for field in fields: options = field.options + 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": ""}) + options = field.options if field.fieldtype == "Select" and options and isinstance(options, str): options = options.split("\n") options = [{"label": option, "value": option} for option in options] @@ -192,6 +197,7 @@ def get_quick_filters(doctype: str): "name": field.fieldname, "type": field.fieldtype, "options": options, + "options": options, } ) @@ -279,6 +285,7 @@ def get_data( 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")): elif not custom_view or (is_default and hasattr(_list, "default_list_data")): rows = default_rows columns = _list.default_list_data().get("columns") @@ -342,6 +349,7 @@ def get_data( for kc in kanban_columns: column_filters = {column_field: kc.get("name")} order = kc.get("order") + if (column_field in filters and filters.get(column_field) != kc.name) or kc.get("delete"): if (column_field in filters and filters.get(column_field) != kc.name) or kc.get("delete"): column_data = [] else: From 10bb3896a8f637cedcd3d3b0bb5f4b90833d7497 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Wed, 8 Jan 2025 16:06:35 +0530 Subject: [PATCH 02/61] fix: added exotel settings doctype --- .../doctype/crm_exotel_settings/__init__.py | 0 .../crm_exotel_settings.js | 8 ++ .../crm_exotel_settings.json | 111 ++++++++++++++++++ .../crm_exotel_settings.py | 9 ++ .../test_crm_exotel_settings.py | 29 +++++ 5 files changed, 157 insertions(+) create mode 100644 crm/fcrm/doctype/crm_exotel_settings/__init__.py create mode 100644 crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.js create mode 100644 crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.json create mode 100644 crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.py create mode 100644 crm/fcrm/doctype/crm_exotel_settings/test_crm_exotel_settings.py 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..a0cb41dc --- /dev/null +++ b/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.json @@ -0,0 +1,111 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-01-08 15:55:50.710356", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "enabled", + "column_break_uxtz", + "record_calls", + "section_break_kfez", + "account_sid", + "section_break_iuct", + "api_key", + "column_break_hyen", + "api_token" + ], + "fields": [ + { + "default": "0", + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enabled" + }, + { + "depends_on": "enabled", + "fieldname": "section_break_kfez", + "fieldtype": "Section Break" + }, + { + "fieldname": "account_sid", + "fieldtype": "Data", + "label": "Account SID", + "mandatory_depends_on": "enabled" + }, + { + "depends_on": "enabled", + "fieldname": "section_break_iuct", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_hyen", + "fieldtype": "Column Break" + }, + { + "fieldname": "api_key", + "fieldtype": "Data", + "in_list_view": 1, + "label": "API Key", + "mandatory_depends_on": "enabled" + }, + { + "fieldname": "column_break_uxtz", + "fieldtype": "Column Break" + }, + { + "default": "0", + "depends_on": "enabled", + "fieldname": "record_calls", + "fieldtype": "Check", + "label": "Record Calls" + }, + { + "fieldname": "api_token", + "fieldtype": "Password", + "in_list_view": 1, + "label": "API Token", + "mandatory_depends_on": "enabled" + } + ], + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2025-01-08 16:12:34.335490", + "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..dbaf1341 --- /dev/null +++ b/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.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 CRMExotelSettings(Document): + pass 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 From c816ed11dd22c092243bda8c64dbdc96176182b1 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Wed, 8 Jan 2025 16:15:37 +0530 Subject: [PATCH 03/61] fix: added exotel settings in portal --- frontend/src/components/Settings/ExotelSettings.vue | 6 ++++++ frontend/src/components/Settings/Settings.vue | 6 ++++++ 2 files changed, 12 insertions(+) create mode 100644 frontend/src/components/Settings/ExotelSettings.vue diff --git a/frontend/src/components/Settings/ExotelSettings.vue b/frontend/src/components/Settings/ExotelSettings.vue new file mode 100644 index 00000000..3ccd252a --- /dev/null +++ b/frontend/src/components/Settings/ExotelSettings.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/src/components/Settings/Settings.vue b/frontend/src/components/Settings/Settings.vue index 6018ad4e..84c9fbab 100644 --- a/frontend/src/components/Settings/Settings.vue +++ b/frontend/src/components/Settings/Settings.vue @@ -57,6 +57,7 @@ import ProfileSettings from '@/components/Settings/ProfileSettings.vue' import WhatsAppSettings from '@/components/Settings/WhatsAppSettings.vue' import ERPNextSettings from '@/components/Settings/ERPNextSettings.vue' import TwilioSettings from '@/components/Settings/TwilioSettings.vue' +import ExotelSettings from '@/components/Settings/ExotelSettings.vue' import SidebarLink from '@/components/SidebarLink.vue' import { usersStore } from '@/stores/users' import { @@ -109,6 +110,11 @@ const tabs = computed(() => { icon: PhoneIcon, component: markRaw(TwilioSettings), }, + { + label: __('Exotel'), + icon: PhoneIcon, + component: markRaw(ExotelSettings), + }, { label: __('WhatsApp'), icon: WhatsAppIcon, From 2a44ee9f0f8338829a71c9103809ca2912c5ff05 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Sat, 11 Jan 2025 13:11:50 +0530 Subject: [PATCH 04/61] fix: verify credentials --- .../crm_exotel_settings.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.py b/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.py index dbaf1341..c72fc5e9 100644 --- a/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.py +++ b/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.py @@ -1,9 +1,24 @@ # Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt -# import frappe +import frappe +import requests +from frappe import _ from frappe.model.document import Document class CRMExotelSettings(Document): - pass + 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"), + ) From 3991c819ba10ed9de374c903bb42f2b80f7b7d8d Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Sat, 11 Jan 2025 16:01:41 +0530 Subject: [PATCH 05/61] feat: receive call and log call --- crm/integrations/exotel/handler.py | 127 +++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 crm/integrations/exotel/handler.py diff --git a/crm/integrations/exotel/handler.py b/crm/integrations/exotel/handler.py new file mode 100644 index 00000000..df32020f --- /dev/null +++ b/crm/integrations/exotel/handler.py @@ -0,0 +1,127 @@ +import frappe +from frappe import _ +from frappe.integrations.utils import create_request_log + + +@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 + 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), + ) + except Exception: + request_log.status = "Failed" + request_log.error = frappe.get_traceback() + frappe.db.rollback() + frappe.log_error(title="Error while creating call record") + frappe.db.commit() + finally: + request_log.save(ignore_permissions=True) + frappe.db.commit() + + +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, + status="Ringing", + call_type="Incoming", + link_to_document=None, +): + 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 + setattr(call_log, "from", from_number) + if link_to_document: + call_log.append("links", link_to_document) + call_log.save(ignore_permissions=True) + frappe.db.commit() + return call_log + + +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): + 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): + call_log = call_log or get_call_log(call_payload) + status = get_call_log_status(call_payload) + 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") + 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() From 5c2aa522fcac746bece6ec9e1052ff9ed5ebbbe1 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Sat, 11 Jan 2025 16:32:18 +0530 Subject: [PATCH 06/61] fix: added telephony medium field to capture Exotel, Twilio or Manual call log --- .../doctype/crm_call_log/crm_call_log.json | 29 ++- crm/integrations/twilio/api.py | 100 +++++----- crm/integrations/twilio/twilio_handler.py | 182 +++++++++--------- crm/integrations/twilio/utils.py | 11 +- 4 files changed, 176 insertions(+), 146 deletions(-) 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..c13c6cc7 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,31 @@ "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", + "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-11 16:27:56.992950", "modified_by": "Administrator", "module": "FCRM", "name": "CRM Call Log", diff --git a/crm/integrations/twilio/api.py b/crm/integrations/twilio/api.py index 5ed4c28e..c88b6a58 100644 --- a/crm/integrations/twilio/api.py +++ b/crm/integrations/twilio/api.py @@ -1,44 +1,45 @@ -from werkzeug.wrappers import Response import json import frappe from frappe import _ -from .twilio_handler import Twilio, IncomingCall, TwilioCallDetails +from werkzeug.wrappers import Response + +from .twilio_handler import IncomingCall, Twilio, TwilioCallDetails from .utils import parse_mobile_no + @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 +55,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,23 +65,24 @@ 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 = frappe.get_doc( + {**call_details.to_dict(), "doctype": "CRM Call Log", "telephony_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() frappe.db.commit() + 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 call_details = twilio.get_call_info(call_sid) call_log = frappe.get_doc("CRM Call Log", call_sid) @@ -94,6 +97,7 @@ def update_call_log(call_sid, status=None): call_log.save() frappe.db.commit() + @frappe.whitelist(allow_guest=True) def update_recording_info(**kwargs): try: @@ -105,6 +109,7 @@ def update_recording_info(**kwargs): except: frappe.log_error(title=_("Failed to capture Twilio recording")) + @frappe.whitelist(allow_guest=True) def update_call_status_info(**kwargs): try: @@ -113,53 +118,54 @@ 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) - ) + client.calls(args.ParentCallSid).user_defined_messages.create(content=json.dumps(call_info)) except: 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') + 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. - """ + """Add note to call log. based on child call sid.""" twilio = Twilio.connect() - if not twilio: return + 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 + 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=''): +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}` @@ -170,12 +176,12 @@ def get_lead_or_deal_from_number(call): return data[0].name if data else None doctype = "CRM Deal" - number = call.get('to') if call.type == 'Outgoing' else call.get('from') + 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') + doc = find_record(doctype, number, "AND converted is not True") if not doc: doc = find_record(doctype, number) 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..a47e604d 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,12 @@ 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' + >>> 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 + return "".join([c for c in mobile_no if c.isdigit() or c == "+"]) From e085d4bc3094bf14e908ceb9beb2f9d71874da87 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Sat, 11 Jan 2025 16:33:40 +0530 Subject: [PATCH 07/61] fix: created exotel agent doctype to store user and mobile no --- crm/fcrm/doctype/crm_exotel_agent/__init__.py | 0 .../crm_exotel_agent/crm_exotel_agent.js | 8 +++ .../crm_exotel_agent/crm_exotel_agent.json | 72 +++++++++++++++++++ .../crm_exotel_agent/crm_exotel_agent.py | 9 +++ .../crm_exotel_agent/test_crm_exotel_agent.py | 30 ++++++++ 5 files changed, 119 insertions(+) create mode 100644 crm/fcrm/doctype/crm_exotel_agent/__init__.py create mode 100644 crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.js create mode 100644 crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.json create mode 100644 crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.py create mode 100644 crm/fcrm/doctype/crm_exotel_agent/test_crm_exotel_agent.py 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..826a49db --- /dev/null +++ b/crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.json @@ -0,0 +1,72 @@ +{ + "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" + ], + "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" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2025-01-11 16:20:21.877873", + "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 From b6b4ecc58d4909fb12e3ba08d96d129ef25e2893 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Sat, 11 Jan 2025 16:34:19 +0530 Subject: [PATCH 08/61] feat: added api to make a call --- .../crm_exotel_settings.json | 18 ++--- crm/integrations/exotel/handler.py | 72 +++++++++++++++++++ 2 files changed, 81 insertions(+), 9 deletions(-) diff --git a/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.json b/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.json index a0cb41dc..5495c846 100644 --- a/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.json +++ b/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.json @@ -7,7 +7,7 @@ "field_order": [ "enabled", "column_break_uxtz", - "record_calls", + "record_call", "section_break_kfez", "account_sid", "section_break_iuct", @@ -53,25 +53,25 @@ "fieldname": "column_break_uxtz", "fieldtype": "Column Break" }, - { - "default": "0", - "depends_on": "enabled", - "fieldname": "record_calls", - "fieldtype": "Check", - "label": "Record Calls" - }, { "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-08 16:12:34.335490", + "modified": "2025-01-11 16:08:48.251477", "modified_by": "Administrator", "module": "FCRM", "name": "CRM Exotel Settings", diff --git a/crm/integrations/exotel/handler.py b/crm/integrations/exotel/handler.py index df32020f..77d8f077 100644 --- a/crm/integrations/exotel/handler.py +++ b/crm/integrations/exotel/handler.py @@ -1,8 +1,13 @@ +import json + +import bleach import frappe +import requests from frappe import _ from frappe.integrations.utils import create_request_log +# Incoming Call @frappe.whitelist(allow_guest=True) def handle_request(**kwargs): if not is_integration_enabled(): @@ -47,6 +52,72 @@ def handle_request(**kwargs): frappe.db.commit() +# Outgoing Call +@frappe.whitelist() +def make_a_call(from_number, to_number, caller_id=None, link_to_document=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 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", + }, + ) + 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", {}) + if link_to_document: + link_to_document = json.loads(link_to_document) + 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", + link_to_document=link_to_document, + ) + + return response.json() + + +def get_exotel_endpoint(action): + 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_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") @@ -72,6 +143,7 @@ def create_call_log( 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 link_to_document: call_log.append("links", link_to_document) From b0f56fbeb49d04fabc58ee14b0c1329c609ec7bb Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Sat, 11 Jan 2025 18:07:56 +0530 Subject: [PATCH 09/61] fix: updated status based on direction --- crm/integrations/exotel/handler.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/crm/integrations/exotel/handler.py b/crm/integrations/exotel/handler.py index 77d8f077..741c2588 100644 --- a/crm/integrations/exotel/handler.py +++ b/crm/integrations/exotel/handler.py @@ -158,7 +158,18 @@ def get_call_log(call_payload): return frappe.get_doc("CRM Call Log", call_log_id) -def get_call_log_status(call_payload): +def get_call_log_status(call_payload, direction): + if direction == "outbound-api" or direction == "outbound-dial": + status = call_payload.get("Status") + if status == "completed": + return "Completed" + 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") @@ -178,13 +189,14 @@ def get_call_log_status(call_payload): 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) + 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") + 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 ) From 6b0a6ce6f396be88f0d3aaf57ec949bee1305a10 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Sat, 11 Jan 2025 18:23:22 +0530 Subject: [PATCH 10/61] fix: callback at in-progress and completed status --- crm/integrations/exotel/handler.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crm/integrations/exotel/handler.py b/crm/integrations/exotel/handler.py index 741c2588..6314a71f 100644 --- a/crm/integrations/exotel/handler.py +++ b/crm/integrations/exotel/handler.py @@ -79,6 +79,7 @@ def make_a_call(from_number, to_number, caller_id=None, link_to_document=None): "Record": "true" if record_call else "false", "StatusCallback": get_status_updater_url(), "StatusCallbackEvents[0]": "terminal", + "StatusCallbackEvents[1]": "answered" }, ) response.raise_for_status() @@ -163,6 +164,8 @@ def get_call_log_status(call_payload, direction): 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": From 37a420c40af012e8d366984c72e7fad174e164b9 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Sat, 11 Jan 2025 19:15:28 +0530 Subject: [PATCH 11/61] build(deps): bump vue to 3.5.13 --- frontend/package.json | 2 +- yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 377a8c34..82469d7c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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/yarn.lock b/yarn.lock index 71f426ce..56fc746a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4332,7 +4332,7 @@ vue-router@^4.2.2: dependencies: "@vue/devtools-api" "^6.6.4" -vue@^3.4.12: +vue@^3.5.13: version "3.5.13" resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.13.tgz#9f760a1a982b09c0c04a867903fc339c9f29ec0a" integrity sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ== From 4d868a788e273bccbcf03f3eca33ef01e6ec4868 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Mon, 13 Jan 2025 12:21:32 +0530 Subject: [PATCH 12/61] fix: added realtime event when receive a call or make a call --- crm/integrations/exotel/handler.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crm/integrations/exotel/handler.py b/crm/integrations/exotel/handler.py index 6314a71f..9abf24ab 100644 --- a/crm/integrations/exotel/handler.py +++ b/crm/integrations/exotel/handler.py @@ -20,6 +20,7 @@ def handle_request(**kwargs): request_headers=frappe.request.headers, is_remote_request=1, ) + try: request_log.status = "Completed" exotel_settings = get_exotel_settings() @@ -27,6 +28,7 @@ def handle_request(**kwargs): return call_payload = kwargs + frappe.publish_realtime("exotel_call", call_payload) status = call_payload.get("Status") if status == "free": return @@ -79,7 +81,7 @@ def make_a_call(from_number, to_number, caller_id=None, link_to_document=None): "Record": "true" if record_call else "false", "StatusCallback": get_status_updater_url(), "StatusCallbackEvents[0]": "terminal", - "StatusCallbackEvents[1]": "answered" + "StatusCallbackEvents[1]": "answered", }, ) response.raise_for_status() @@ -159,7 +161,7 @@ def get_call_log(call_payload): return frappe.get_doc("CRM Call Log", call_log_id) -def get_call_log_status(call_payload, direction): +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": From a716bdf0da35f36df652b2846a3b6a4c5bfad6e0 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Wed, 15 Jan 2025 14:59:10 +0530 Subject: [PATCH 13/61] fix: created exotel call ui --- crm/integrations/exotel/handler.py | 8 +- frappe-ui | 2 +- frontend/src/components/Icons/AvatarIcon.vue | 17 + frontend/src/components/Icons/NoteIcon.vue | 3 +- frontend/src/components/Layouts/AppHeader.vue | 4 +- .../src/components/Mobile/MobileAppHeader.vue | 4 +- .../src/components/{ => Telephony}/CallUI.vue | 0 .../src/components/Telephony/ExotelCallUI.vue | 306 ++++++++++++++++++ frontend/src/pages/Notes.vue | 2 +- 9 files changed, 339 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/Icons/AvatarIcon.vue rename frontend/src/components/{ => Telephony}/CallUI.vue (100%) create mode 100644 frontend/src/components/Telephony/ExotelCallUI.vue diff --git a/crm/integrations/exotel/handler.py b/crm/integrations/exotel/handler.py index 9abf24ab..4375f0a7 100644 --- a/crm/integrations/exotel/handler.py +++ b/crm/integrations/exotel/handler.py @@ -28,6 +28,7 @@ def handle_request(**kwargs): return call_payload = kwargs + frappe.publish_realtime("exotel_call", call_payload) status = call_payload.get("Status") if status == "free": @@ -56,11 +57,12 @@ def handle_request(**kwargs): # Outgoing Call @frappe.whitelist() -def make_a_call(from_number, to_number, caller_id=None, link_to_document=None): +def make_a_call(to_number, from_number=None, caller_id=None, link_to_document=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") @@ -91,8 +93,10 @@ def make_a_call(from_number, to_number, caller_id=None, link_to_document=None): else: res = response.json() call_payload = res.get("Call", {}) + if link_to_document: link_to_document = json.loads(link_to_document) + create_call_log( call_id=call_payload.get("Sid"), from_number=call_payload.get("From"), @@ -105,7 +109,7 @@ def make_a_call(from_number, to_number, caller_id=None, link_to_document=None): return response.json() -def get_exotel_endpoint(action): +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, diff --git a/frappe-ui b/frappe-ui index d82b3a12..99f0b86f 160000 --- a/frappe-ui +++ b/frappe-ui @@ -1 +1 @@ -Subproject commit d82b3a12eeb6cb9e83375550508b462ce5cfdaf2 +Subproject commit 99f0b86f15e094b95c32e87494e003c974b4f0df diff --git a/frontend/src/components/Icons/AvatarIcon.vue b/frontend/src/components/Icons/AvatarIcon.vue new file mode 100644 index 00000000..d1331ec6 --- /dev/null +++ b/frontend/src/components/Icons/AvatarIcon.vue @@ -0,0 +1,17 @@ + diff --git a/frontend/src/components/Icons/NoteIcon.vue b/frontend/src/components/Icons/NoteIcon.vue index 2bbca63f..38fb47ca 100644 --- a/frontend/src/components/Icons/NoteIcon.vue +++ b/frontend/src/components/Icons/NoteIcon.vue @@ -9,8 +9,9 @@ diff --git a/frontend/src/components/Layouts/AppHeader.vue b/frontend/src/components/Layouts/AppHeader.vue index 00a04369..b1b72c35 100644 --- a/frontend/src/components/Layouts/AppHeader.vue +++ b/frontend/src/components/Layouts/AppHeader.vue @@ -2,11 +2,13 @@
+
diff --git a/frontend/src/components/Mobile/MobileAppHeader.vue b/frontend/src/components/Mobile/MobileAppHeader.vue index de564def..b2c6e354 100644 --- a/frontend/src/components/Mobile/MobileAppHeader.vue +++ b/frontend/src/components/Mobile/MobileAppHeader.vue @@ -12,10 +12,12 @@
+ diff --git a/frontend/src/components/CallUI.vue b/frontend/src/components/Telephony/CallUI.vue similarity index 100% rename from frontend/src/components/CallUI.vue rename to frontend/src/components/Telephony/CallUI.vue diff --git a/frontend/src/components/Telephony/ExotelCallUI.vue b/frontend/src/components/Telephony/ExotelCallUI.vue new file mode 100644 index 00000000..b4363c36 --- /dev/null +++ b/frontend/src/components/Telephony/ExotelCallUI.vue @@ -0,0 +1,306 @@ + + diff --git a/frontend/src/pages/Notes.vue b/frontend/src/pages/Notes.vue index 58f39dec..e350296a 100644 --- a/frontend/src/pages/Notes.vue +++ b/frontend/src/pages/Notes.vue @@ -55,7 +55,7 @@ v-if="note.content" :content="note.content" :editable="false" - editor-class="!prose-sm max-w-none !text-sm text-ink-gray-5 focus:outline-none" + editor-class="prose-sm text-sm max-w-none text-ink-gray-5 focus:outline-none" class="flex-1 overflow-hidden" />
From 591e1269116073e457429be221e53beeb461f529 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Wed, 15 Jan 2025 16:08:04 +0530 Subject: [PATCH 14/61] fix: show different call status --- .../src/components/Telephony/ExotelCallUI.vue | 268 ++++++++++-------- 1 file changed, 150 insertions(+), 118 deletions(-) diff --git a/frontend/src/components/Telephony/ExotelCallUI.vue b/frontend/src/components/Telephony/ExotelCallUI.vue index b4363c36..bd447774 100644 --- a/frontend/src/components/Telephony/ExotelCallUI.vue +++ b/frontend/src/components/Telephony/ExotelCallUI.vue @@ -2,14 +2,14 @@
- {{ phoneNumber }} - · + + {{ callStatus }} + + {{ phoneNumber }} - 00:38 + · 00:38 + + + · + {{ callStatus }} + · 00:38 - {{ callStatus }}
-
-
00:38
-
{{ __(callStatus) }}
- + {{ __(callStatus) }} + · 00:38 +
+
{{ __(callStatus) }}
+
+ +
{{ note }}
+
{{ task }}
-
+
{{ contact.full_name }}
{{ phoneNumber }}
@@ -106,12 +135,6 @@
@@ -146,12 +162,12 @@ import AvatarIcon from '@/components/Icons/AvatarIcon.vue' import MinimizeIcon from '@/components/Icons/MinimizeIcon.vue' import NoteIcon from '@/components/Icons/NoteIcon.vue' -import PhoneIcon from '@/components/Icons/PhoneIcon.vue' +import TaskIcon from '@/components/Icons/TaskIcon.vue' import { Button, Dialog, FormControl, call, Avatar } from 'frappe-ui' import { globalStore } from '@/stores/global' import { contactsStore } from '@/stores/contacts' import { useDraggable, useWindowSize } from '@vueuse/core' -import { ref, onBeforeUnmount, onMounted } from 'vue' +import { ref, computed, onBeforeUnmount, onMounted } from 'vue' const { getContact, getLeadContact } = contactsStore() const { $socket, setMakeCall } = globalStore() @@ -178,20 +194,23 @@ let { style } = useDraggable(callPopup, { const showCallModal = ref(false) const callStatus = ref('') -const phoneNumber = ref('09821259504') +const phoneNumber = ref('') const callData = ref(null) -const contact = ref({ - full_name: '', - mobile_no: '', +const contact = computed(() => { + if (!phoneNumber.value) { + return { + full_name: '', + image: '', + } + } + let _contact = getContact(phoneNumber.value) + if (!_contact) { + _contact = getLeadContact(phoneNumber.value) + } + return _contact }) -const mute = ref(false) - -function toggleMute() { - mute.value = !mute.value -} - const note = ref( 'This is a note for the call. This is a note for the call. This is a note for the call. This is a note for the call.', ) @@ -200,11 +219,33 @@ const showNote = ref(false) function showNoteWindow() { showNote.value = !showNote.value + if (!showTask.value) { + updateWindowHeight(showNote.value) + } + if (showNote.value) { + showTask.value = false + } +} +const task = ref('This is a task for the call. This is a task for the call.') + +const showTask = ref(false) + +function showTaskWindow() { + showTask.value = !showTask.value + if (!showNote.value) { + updateWindowHeight(showTask.value) + } + if (showTask.value) { + showNote.value = false + } +} + +function updateWindowHeight(condition) { let top = parseInt(callPopup.value.style.top) let updatedTop = 0 - updatedTop = showNote.value ? top - 224 : top + 224 + updatedTop = condition ? top - 224 : top + 224 if (updatedTop < 0) { updatedTop = 10 @@ -219,29 +260,15 @@ function showMakeCallModal(number) { } function makeCall() { - contact.value = getContact(phoneNumber.value) - if (!contact.value) { - contact.value = getLeadContact(phoneNumber.value) - } - showCallModal.value = false callStatus.value = 'Calling...' showCallPopup.value = true call('crm.integrations.exotel.handler.make_a_call', { to_number: phoneNumber.value, - from_number: '07666980887', - caller_id: '08047091710', }) } -function endCall() { - callStatus.value = '' - showCallPopup.value = false - showSmallCallPopup.value = false - note.value = '' -} - onBeforeUnmount(() => { $socket.off('exotel_call') }) @@ -251,56 +278,61 @@ onMounted(() => { callData.value = data console.log(data) - if ( - data.EventType == 'answered' && - data.Direction == 'outbound-api' && - data.Status == 'in-progress' && - data['Legs[0][Status]'] == 'in-progress' && - data['Legs[1][Status]'] == '' - ) { - callStatus.value = 'Ringing...' - } else if ( - data.EventType == 'answered' && - data.Direction == 'outbound-api' && - data.Status == 'in-progress' && - data['Legs[1][Status]'] == 'in-progress' - ) { - callStatus.value = 'In Progress' - } else if ( - data.EventType == 'terminal' && - data.Direction == 'outbound-api' && - (data.Status == 'completed' || data.Status == 'no-answer') - ) { - callStatus.value = 'Call Ended' - } - - if ( - data.EventType == 'Dial' && - data.Direction == 'incoming' && - data.Status == 'busy' - ) { - callStatus.value = 'Incoming Call' - } else if ( - data.EventType == 'Terminal' && - data.Direction == 'incoming' && - data.Status == 'free' - ) { - callStatus.value = 'Call Ended' - } + callStatus.value = updateStatus(data) if (!showCallPopup.value && !showSmallCallPopup.value) { showCallPopup.value = true } - - if (callStatus.value == 'Call Ended') { - setTimeout(() => { - showCallPopup.value = false - showSmallCallPopup.value = false - note.value = '' - }, 2000) - } }) setMakeCall(showMakeCallModal) }) + +function closeCallPopup() { + showCallPopup.value = false + showSmallCallPopup.value = false + note.value = '' + task.value = '' +} + +function updateStatus(data) { + // outgoing call + if ( + data.EventType == 'answered' && + data.Direction == 'outbound-api' && + data.Status == 'in-progress' && + data['Legs[0][Status]'] == 'in-progress' && + data['Legs[1][Status]'] == '' + ) { + return 'Ringing...' + } else if ( + data.EventType == 'answered' && + data.Direction == 'outbound-api' && + data.Status == 'in-progress' && + data['Legs[1][Status]'] == 'in-progress' + ) { + return 'In Progress' + } else if ( + data.EventType == 'terminal' && + data.Direction == 'outbound-api' && + (data.Status == 'completed' || data.Status == 'no-answer') + ) { + return data.Status == 'no-answer' ? 'No Answer' : 'Call Ended' + } + + // incoming call + if ( + data.EventType == 'Dial' && + data.Direction == 'incoming' && + data.Status == 'busy' + ) { + return 'Incoming Call' + } else if ( + data.EventType == 'Terminal' && + data.Direction == 'incoming' && + data.Status == 'free' + ) { + return 'Call Ended' + } +} From 49de7c92a57866fd98472984b2873037cf0bd1eb Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Wed, 15 Jan 2025 18:05:24 +0530 Subject: [PATCH 15/61] fix: check exotel number --- .../crm_exotel_agent/crm_exotel_agent.json | 10 ++++++++-- crm/integrations/exotel/handler.py | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.json b/crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.json index 826a49db..f9730b94 100644 --- a/crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.json +++ b/crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.json @@ -9,7 +9,8 @@ "user", "user_name", "column_break_hdec", - "mobile_no" + "mobile_no", + "exotel_number" ], "fields": [ { @@ -41,11 +42,16 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "User Name" + }, + { + "fieldname": "exotel_number", + "fieldtype": "Data", + "label": "Exotel Number" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2025-01-11 16:20:21.877873", + "modified": "2025-01-15 16:19:21.965415", "modified_by": "Administrator", "module": "FCRM", "name": "CRM Exotel Agent", diff --git a/crm/integrations/exotel/handler.py b/crm/integrations/exotel/handler.py index 4375f0a7..3b6dd523 100644 --- a/crm/integrations/exotel/handler.py +++ b/crm/integrations/exotel/handler.py @@ -66,6 +66,12 @@ def make_a_call(to_number, from_number=None, caller_id=None, link_to_document=No 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") @@ -119,6 +125,15 @@ def get_exotel_endpoint(action=None): ) +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 From 8a912b21e070c67f4553829c7ae96c397b2ada95 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Wed, 15 Jan 2025 18:07:32 +0530 Subject: [PATCH 16/61] feat: select calling medium while calling and set defaulr medium --- .../doctype/fcrm_settings/fcrm_settings.json | 17 +- crm/integrations/__init__.py | 0 crm/integrations/api.py | 19 + frontend/src/components/Layouts/AppHeader.vue | 2 - .../src/components/Mobile/MobileAppHeader.vue | 2 - frontend/src/components/Telephony/CallUI.vue | 662 ++++-------------- .../src/components/Telephony/ExotelCallUI.vue | 67 +- .../src/components/Telephony/TwilioCallUI.vue | 537 ++++++++++++++ frontend/src/composables/settings.js | 12 +- frontend/src/stores/global.js | 7 - 10 files changed, 725 insertions(+), 600 deletions(-) create mode 100644 crm/integrations/__init__.py create mode 100644 crm/integrations/api.py create mode 100644 frontend/src/components/Telephony/TwilioCallUI.vue 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/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..73dae584 --- /dev/null +++ b/crm/integrations/api.py @@ -0,0 +1,19 @@ +import frappe + + +@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) diff --git a/frontend/src/components/Layouts/AppHeader.vue b/frontend/src/components/Layouts/AppHeader.vue index b1b72c35..6db3579a 100644 --- a/frontend/src/components/Layouts/AppHeader.vue +++ b/frontend/src/components/Layouts/AppHeader.vue @@ -2,13 +2,11 @@
-
diff --git a/frontend/src/components/Mobile/MobileAppHeader.vue b/frontend/src/components/Mobile/MobileAppHeader.vue index b2c6e354..2e777ba2 100644 --- a/frontend/src/components/Mobile/MobileAppHeader.vue +++ b/frontend/src/components/Mobile/MobileAppHeader.vue @@ -12,12 +12,10 @@
- diff --git a/frontend/src/components/Telephony/CallUI.vue b/frontend/src/components/Telephony/CallUI.vue index fb88f6ef..666027db 100644 --- a/frontend/src/components/Telephony/CallUI.vue +++ b/frontend/src/components/Telephony/CallUI.vue @@ -1,549 +1,135 @@ + - - - diff --git a/frontend/src/components/Telephony/ExotelCallUI.vue b/frontend/src/components/Telephony/ExotelCallUI.vue index bd447774..4db500f8 100644 --- a/frontend/src/components/Telephony/ExotelCallUI.vue +++ b/frontend/src/components/Telephony/ExotelCallUI.vue @@ -25,7 +25,7 @@ " class="font-normal text-ink-gray-4 mr-1" > - {{ callStatus }} + {{ __(callStatus) }} {{ phoneNumber }} · - {{ callStatus }} + {{ __(callStatus) }} · 00:38 - - - -
{ return _contact }) -const note = ref( - 'This is a note for the call. This is a note for the call. This is a note for the call. This is a note for the call.', -) +const note = ref('') const showNote = ref(false) @@ -227,7 +200,7 @@ function showNoteWindow() { } } -const task = ref('This is a task for the call. This is a task for the call.') +const task = ref('') const showTask = ref(false) @@ -254,26 +227,18 @@ function updateWindowHeight(condition) { callPopup.value.style.top = updatedTop + 'px' } -function showMakeCallModal(number) { - showCallModal.value = true +function makeOutgoingCall(number) { phoneNumber.value = number -} - -function makeCall() { - showCallModal.value = false callStatus.value = 'Calling...' showCallPopup.value = true + showSmallCallPopup.value = false call('crm.integrations.exotel.handler.make_a_call', { to_number: phoneNumber.value, }) } -onBeforeUnmount(() => { - $socket.off('exotel_call') -}) - -onMounted(() => { +function setup() { $socket.on('exotel_call', (data) => { callData.value = data console.log(data) @@ -284,8 +249,10 @@ onMounted(() => { showCallPopup.value = true } }) +} - setMakeCall(showMakeCallModal) +onBeforeUnmount(() => { + $socket.off('exotel_call') }) function closeCallPopup() { @@ -318,6 +285,12 @@ function updateStatus(data) { (data.Status == 'completed' || data.Status == 'no-answer') ) { return data.Status == 'no-answer' ? 'No Answer' : 'Call Ended' + } else if ( + data.EventType == 'terminal' && + data.Direction == 'outbound-api' && + data.Status == 'busy' + ) { + return 'No Answer' } // incoming call @@ -335,4 +308,6 @@ function updateStatus(data) { return 'Call Ended' } } + +defineExpose({ makeOutgoingCall, setup }) diff --git a/frontend/src/components/Telephony/TwilioCallUI.vue b/frontend/src/components/Telephony/TwilioCallUI.vue new file mode 100644 index 00000000..4a0600ec --- /dev/null +++ b/frontend/src/components/Telephony/TwilioCallUI.vue @@ -0,0 +1,537 @@ + + + + + diff --git a/frontend/src/composables/settings.js b/frontend/src/composables/settings.js index e4f27c47..b729bff9 100644 --- a/frontend/src/composables/settings.js +++ b/frontend/src/composables/settings.js @@ -21,12 +21,18 @@ createResource({ }) export const callEnabled = ref(false) +export const twilioEnabled = ref(false) +export const exotelEnabled = ref(false) +export const defaultCallingMedium = ref('') createResource({ - url: 'crm.integrations.twilio.api.is_enabled', - cache: 'Is Twilio Enabled', + url: 'crm.integrations.api.is_call_integration_enabled', + cache: 'Is Call Integration Enabled', auto: true, onSuccess: (data) => { - callEnabled.value = Boolean(data) + twilioEnabled.value = Boolean(data.twilio_enabled) + exotelEnabled.value = Boolean(data.exotel_enabled) + defaultCallingMedium.value = data.default_calling_medium + callEnabled.value = twilioEnabled.value || exotelEnabled.value }, }) diff --git a/frontend/src/stores/global.js b/frontend/src/stores/global.js index c9b8e8cb..9e0b1581 100644 --- a/frontend/src/stores/global.js +++ b/frontend/src/stores/global.js @@ -5,13 +5,8 @@ export const globalStore = defineStore('crm-global', () => { const app = getCurrentInstance() const { $dialog, $socket } = app.appContext.config.globalProperties - let twilioEnabled = ref(false) let callMethod = () => {} - function setTwilioEnabled(value) { - twilioEnabled.value = value - } - function setMakeCall(value) { callMethod = value } @@ -23,9 +18,7 @@ export const globalStore = defineStore('crm-global', () => { return { $dialog, $socket, - twilioEnabled, makeCall, - setTwilioEnabled, setMakeCall, } }) From b921ce9dff8da9b95d955014b376ebee4da95b80 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Wed, 15 Jan 2025 19:24:25 +0530 Subject: [PATCH 17/61] fix: only show fields if enabled --- .../doctype/crm_exotel_settings/crm_exotel_settings.json | 6 ++++-- crm/fcrm/doctype/twilio_settings/twilio_settings.json | 8 +++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.json b/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.json index 5495c846..3ddcc440 100644 --- a/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.json +++ b/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.json @@ -23,11 +23,11 @@ "label": "Enabled" }, { - "depends_on": "enabled", "fieldname": "section_break_kfez", "fieldtype": "Section Break" }, { + "depends_on": "enabled", "fieldname": "account_sid", "fieldtype": "Data", "label": "Account SID", @@ -43,6 +43,7 @@ "fieldtype": "Column Break" }, { + "depends_on": "enabled", "fieldname": "api_key", "fieldtype": "Data", "in_list_view": 1, @@ -54,6 +55,7 @@ "fieldtype": "Column Break" }, { + "depends_on": "enabled", "fieldname": "api_token", "fieldtype": "Password", "in_list_view": 1, @@ -71,7 +73,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-01-11 16:08:48.251477", + "modified": "2025-01-15 19:14:25.568485", "modified_by": "Administrator", "module": "FCRM", "name": "CRM Exotel Settings", diff --git a/crm/fcrm/doctype/twilio_settings/twilio_settings.json b/crm/fcrm/doctype/twilio_settings/twilio_settings.json index cf48bb6a..36ae7b04 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" @@ -97,7 +103,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2024-12-30 19:23:04.401439", + "modified": "2025-01-15 19:15:14.687909", "modified_by": "Administrator", "module": "FCRM", "name": "Twilio Settings", From 2dfe75931aa9e7fa366662fa8bf53eede13dec21 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Wed, 15 Jan 2025 19:29:19 +0530 Subject: [PATCH 18/61] fix: merged twilio & exotel settings in one calling settings --- .../components/Settings/ExotelSettings.vue | 6 - frontend/src/components/Settings/Settings.vue | 12 +- .../components/Settings/TelephonySettings.vue | 272 ++++++++++++++++++ .../components/Settings/TwilioSettings.vue | 6 - 4 files changed, 275 insertions(+), 21 deletions(-) delete mode 100644 frontend/src/components/Settings/ExotelSettings.vue create mode 100644 frontend/src/components/Settings/TelephonySettings.vue delete mode 100644 frontend/src/components/Settings/TwilioSettings.vue diff --git a/frontend/src/components/Settings/ExotelSettings.vue b/frontend/src/components/Settings/ExotelSettings.vue deleted file mode 100644 index 3ccd252a..00000000 --- a/frontend/src/components/Settings/ExotelSettings.vue +++ /dev/null @@ -1,6 +0,0 @@ - - diff --git a/frontend/src/components/Settings/Settings.vue b/frontend/src/components/Settings/Settings.vue index 84c9fbab..2f1c981d 100644 --- a/frontend/src/components/Settings/Settings.vue +++ b/frontend/src/components/Settings/Settings.vue @@ -56,8 +56,7 @@ import InviteMemberPage from '@/components/Settings/InviteMemberPage.vue' import ProfileSettings from '@/components/Settings/ProfileSettings.vue' import WhatsAppSettings from '@/components/Settings/WhatsAppSettings.vue' import ERPNextSettings from '@/components/Settings/ERPNextSettings.vue' -import TwilioSettings from '@/components/Settings/TwilioSettings.vue' -import ExotelSettings from '@/components/Settings/ExotelSettings.vue' +import TelephonySettings from '@/components/Settings/TelephonySettings.vue' import SidebarLink from '@/components/SidebarLink.vue' import { usersStore } from '@/stores/users' import { @@ -106,14 +105,9 @@ const tabs = computed(() => { label: __('Integrations'), items: [ { - label: __('Twilio'), + label: __('Telephony'), icon: PhoneIcon, - component: markRaw(TwilioSettings), - }, - { - label: __('Exotel'), - icon: PhoneIcon, - component: markRaw(ExotelSettings), + component: markRaw(TelephonySettings), }, { label: __('WhatsApp'), diff --git a/frontend/src/components/Settings/TelephonySettings.vue b/frontend/src/components/Settings/TelephonySettings.vue new file mode 100644 index 00000000..1a842c9b --- /dev/null +++ b/frontend/src/components/Settings/TelephonySettings.vue @@ -0,0 +1,272 @@ + + diff --git a/frontend/src/components/Settings/TwilioSettings.vue b/frontend/src/components/Settings/TwilioSettings.vue deleted file mode 100644 index 8d8479ba..00000000 --- a/frontend/src/components/Settings/TwilioSettings.vue +++ /dev/null @@ -1,6 +0,0 @@ - - From b757a0170aeae4c86f77e9c17018db6474cc503f Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Wed, 15 Jan 2025 19:36:19 +0530 Subject: [PATCH 19/61] fix: hide border if hide_border is set --- .../doctype/crm_exotel_settings/crm_exotel_settings.json | 8 +++++--- crm/fcrm/doctype/twilio_settings/twilio_settings.json | 8 +++++--- frontend/src/components/Settings/SettingsPage.vue | 1 + frontend/src/components/Settings/TelephonySettings.vue | 2 ++ 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.json b/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.json index 3ddcc440..98382626 100644 --- a/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.json +++ b/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.json @@ -24,7 +24,8 @@ }, { "fieldname": "section_break_kfez", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "hide_border": 1 }, { "depends_on": "enabled", @@ -36,7 +37,8 @@ { "depends_on": "enabled", "fieldname": "section_break_iuct", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "hide_border": 1 }, { "fieldname": "column_break_hyen", @@ -73,7 +75,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-01-15 19:14:25.568485", + "modified": "2025-01-15 19:31:00.310049", "modified_by": "Administrator", "module": "FCRM", "name": "CRM Exotel Settings", diff --git a/crm/fcrm/doctype/twilio_settings/twilio_settings.json b/crm/fcrm/doctype/twilio_settings/twilio_settings.json index 36ae7b04..17e92cfb 100644 --- a/crm/fcrm/doctype/twilio_settings/twilio_settings.json +++ b/crm/fcrm/doctype/twilio_settings/twilio_settings.json @@ -83,7 +83,8 @@ }, { "fieldname": "section_break_malx", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "hide_border": 1 }, { "default": "0", @@ -93,7 +94,8 @@ }, { "fieldname": "section_break_eklq", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "hide_border": 1 }, { "fieldname": "column_break_yqvr", @@ -103,7 +105,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-01-15 19:15:14.687909", + "modified": "2025-01-15 19:35:13.406254", "modified_by": "Administrator", "module": "FCRM", "name": "Twilio Settings", diff --git a/frontend/src/components/Settings/SettingsPage.vue b/frontend/src/components/Settings/SettingsPage.vue index b50f35da..39d6a95a 100644 --- a/frontend/src/components/Settings/SettingsPage.vue +++ b/frontend/src/components/Settings/SettingsPage.vue @@ -131,6 +131,7 @@ const tabs = computed(() => { _sections.push({ label: field.label, name: field.fieldname, + hideBorder: field.hide_border, columns: [{ name: 'column_' + getRandom(), fields: [] }], }) } else if (field.fieldtype === 'Column Break') { diff --git a/frontend/src/components/Settings/TelephonySettings.vue b/frontend/src/components/Settings/TelephonySettings.vue index 1a842c9b..2aeb4698 100644 --- a/frontend/src/components/Settings/TelephonySettings.vue +++ b/frontend/src/components/Settings/TelephonySettings.vue @@ -190,6 +190,7 @@ const twilioTabs = computed(() => { _sections.push({ label: field.label, name: field.fieldname, + hideBorder: field.hide_border, columns: [{ name: 'column_' + getRandom(), fields: [] }], }) } else if (field.fieldtype === 'Column Break') { @@ -241,6 +242,7 @@ const exotelTabs = computed(() => { _sections.push({ label: field.label, name: field.fieldname, + hideBorder: field.hide_border, columns: [{ name: 'column_' + getRandom(), fields: [] }], }) } else if (field.fieldtype === 'Column Break') { From dde6d3736f4cc51b7008c547d9251cd77c1b43b1 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Wed, 15 Jan 2025 19:53:33 +0530 Subject: [PATCH 20/61] fix: validate before saving --- .../crm_exotel_agent/crm_exotel_agent.json | 4 +- .../components/Settings/TelephonySettings.vue | 53 ++++++++++++++++--- 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.json b/crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.json index f9730b94..c9baa785 100644 --- a/crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.json +++ b/crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.json @@ -46,12 +46,14 @@ { "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 16:19:21.965415", + "modified": "2025-01-15 20:03:31.162162", "modified_by": "Administrator", "module": "FCRM", "name": "CRM Exotel Agent", diff --git a/frontend/src/components/Settings/TelephonySettings.vue b/frontend/src/components/Settings/TelephonySettings.vue index 2aeb4698..8134df82 100644 --- a/frontend/src/components/Settings/TelephonySettings.vue +++ b/frontend/src/components/Settings/TelephonySettings.vue @@ -5,7 +5,7 @@ >
{{ __('Telephony Settings') }}
- - +
@@ -55,7 +59,9 @@ :style="style" >
-
00:38
+
+ {{ counterUp?.updatedTime }} +
{{ __(callStatus) }} - · 00:38 + + · + {{ counterUp?.updatedTime }} +
{{ __(callStatus) }}
@@ -132,6 +141,7 @@
+ + From d6ab3b96e2d54feb277e8ca831d787063e08f7f5 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Thu, 16 Jan 2025 14:55:39 +0530 Subject: [PATCH 23/61] fix: layout & moved close button to footer --- .../src/components/Telephony/ExotelCallUI.vue | 188 ++++++++++-------- 1 file changed, 110 insertions(+), 78 deletions(-) diff --git a/frontend/src/components/Telephony/ExotelCallUI.vue b/frontend/src/components/Telephony/ExotelCallUI.vue index 8cd142c9..97690959 100644 --- a/frontend/src/components/Telephony/ExotelCallUI.vue +++ b/frontend/src/components/Telephony/ExotelCallUI.vue @@ -2,55 +2,40 @@
-
- - {{ __(callStatus) }} - - {{ phoneNumber }} - + {{ contact?.full_name ?? phoneNumber }} + · +
+ {{ counterUp?.updatedTime }} +
+ +
{{ __(callStatus) }}
-
-
- {{ counterUp?.updatedTime }} +
+
+
+ +
+ +
+
+
+ {{ contact.full_name ?? phoneNumber }} +
+
+
+ {{ phoneNumber }} + · + {{ counterUp?.updatedTime }} +
+ +
{{ __(callStatus) }}
+
+
+
+
+
+ {{ counterUp?.updatedTime }} +
+ +
{{ __(callStatus) }}
+
- -
{{ __(callStatus) }}
-
- -
+ +
{{ note }}
@@ -112,11 +138,11 @@ >
-
-
{{ contact.full_name }}
-
{{ phoneNumber }}
+
+
{{ contact.full_name }}
+
{{ phoneNumber }}
-
{{ phoneNumber }}
+
{{ phoneNumber }}
+
@@ -292,7 +324,7 @@ function updateStatus(data) { data['Legs[1][Status]'] == 'in-progress' ) { counterUp.value.start() - return 'In Progress' + return 'In progress' } else if ( data.EventType == 'terminal' && data.Direction == 'outbound-api' && @@ -300,14 +332,14 @@ function updateStatus(data) { data['Legs[1][Status]'] == 'no-answer' ) { counterUp.value.stop() - return 'No Answer' + return 'No answer' } else if ( data.EventType == 'terminal' && data.Direction == 'outbound-api' && data.Status == 'completed' ) { counterUp.value.stop() - return 'Call Ended' + return 'Call ended' } // incoming call @@ -316,13 +348,13 @@ function updateStatus(data) { data.Direction == 'incoming' && data.Status == 'busy' ) { - return 'Incoming Call' + return 'Incoming call' } else if ( data.EventType == 'Terminal' && data.Direction == 'incoming' && data.Status == 'free' ) { - return 'Call Ended' + return 'Call ended' } } From e6a5da4c1baae650eb1fccde285d250ac565bf82 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Thu, 16 Jan 2025 21:27:31 +0530 Subject: [PATCH 24/61] fix: rebase issue --- crm/api/doc.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/crm/api/doc.py b/crm/api/doc.py index 641a7f0f..4001fa7e 100644 --- a/crm/api/doc.py +++ b/crm/api/doc.py @@ -182,11 +182,6 @@ def get_quick_filters(doctype: str): for field in fields: options = field.options - 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": ""}) - options = field.options if field.fieldtype == "Select" and options and isinstance(options, str): options = options.split("\n") options = [{"label": option, "value": option} for option in options] @@ -197,7 +192,6 @@ def get_quick_filters(doctype: str): "name": field.fieldname, "type": field.fieldtype, "options": options, - "options": options, } ) @@ -285,7 +279,6 @@ def get_data( 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")): elif not custom_view or (is_default and hasattr(_list, "default_list_data")): rows = default_rows columns = _list.default_list_data().get("columns") @@ -349,7 +342,6 @@ def get_data( for kc in kanban_columns: column_filters = {column_field: kc.get("name")} order = kc.get("order") - if (column_field in filters and filters.get(column_field) != kc.name) or kc.get("delete"): if (column_field in filters and filters.get(column_field) != kc.name) or kc.get("delete"): column_data = [] else: From b212dac04354cb4fb6d7dda9e883939f6766a476 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Fri, 17 Jan 2025 16:35:52 +0530 Subject: [PATCH 25/61] feat: create note & task from call window --- crm/fcrm/doctype/crm_call_log/crm_call_log.py | 173 ++++++++++-------- crm/integrations/api.py | 29 +++ crm/integrations/exotel/handler.py | 2 +- frontend/src/components/CountUpTimer.vue | 57 ++++-- frontend/src/components/Telephony/CallUI.vue | 35 ++-- .../src/components/Telephony/ExotelCallUI.vue | 141 +++++++++++--- .../src/components/Telephony/TaskPanel.vue | 144 +++++++++++++++ 7 files changed, 443 insertions(+), 138 deletions(-) create mode 100644 frontend/src/components/Telephony/TaskPanel.vue 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..ba629a9c 100644 --- a/crm/fcrm/doctype/crm_call_log/crm_call_log.py +++ b/crm/fcrm/doctype/crm_call_log/crm_call_log.py @@ -6,76 +6,89 @@ from frappe.model.document import Document 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 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}) + self.save(ignore_permissions=True) + @frappe.whitelist() def create_lead_from_call_log(call_log): @@ -85,15 +98,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/integrations/api.py b/crm/integrations/api.py index 73dae584..d4f8ede1 100644 --- a/crm/integrations/api.py +++ b/crm/integrations/api.py @@ -17,3 +17,32 @@ def is_call_integration_enabled(): @frappe.whitelist() def set_default_calling_medium(medium): return frappe.db.set_value("FCRM Settings", "FCRM Settings", "default_calling_medium", medium) + + +@frappe.whitelist() +def create_and_add_note_to_call_log(call_sid, content): + """Add note to call log based on call sid.""" + note = frappe.get_doc( + { + "doctype": "FCRM Note", + "content": content, + } + ).insert(ignore_permissions=True) + + call_log = frappe.get_doc("CRM Call Log", call_sid) + call_log.link_with_reference_doc("FCRM Note", note.name) + + +@frappe.whitelist() +def create_and_add_task_to_call_log(call_sid, task): + """Add task to call log based on call sid.""" + _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) diff --git a/crm/integrations/exotel/handler.py b/crm/integrations/exotel/handler.py index 3b6dd523..8bb91db6 100644 --- a/crm/integrations/exotel/handler.py +++ b/crm/integrations/exotel/handler.py @@ -48,7 +48,7 @@ def handle_request(**kwargs): request_log.status = "Failed" request_log.error = frappe.get_traceback() frappe.db.rollback() - frappe.log_error(title="Error while creating call record") + frappe.log_error(title="Error while creating/updating call record") frappe.db.commit() finally: request_log.save(ignore_permissions=True) diff --git a/frontend/src/components/CountUpTimer.vue b/frontend/src/components/CountUpTimer.vue index 47b6b47d..a34a3924 100644 --- a/frontend/src/components/CountUpTimer.vue +++ b/frontend/src/components/CountUpTimer.vue @@ -12,6 +12,45 @@ const timer = ref(null) const updatedTime = ref('0:00') function startCounter() { + updatedTime.value = getTime() +} + +function start() { + timer.value = setInterval(() => startCounter(), 1000) +} + +function stop() { + clearInterval(timer.value) + let output = updatedTime.value + hours.value = 0 + minutes.value = 0 + seconds.value = 0 + updatedTime.value = '0:00' + return output +} + +function getTime(_seconds = 0) { + if (_seconds) { + if (typeof _seconds === 'string') { + _seconds = parseInt(_seconds) + } + seconds.value = _seconds + + if (seconds.value >= 60) { + minutes.value = Math.floor(seconds.value / 60) + seconds.value = seconds.value % 60 + } else { + minutes.value = 0 + } + + if (minutes.value >= 60) { + hours.value = Math.floor(minutes.value / 60) + minutes.value = minutes.value % 60 + } else { + hours.value = 0 + } + } + if (seconds.value === 59) { seconds.value = 0 minutes.value = minutes.value + 1 @@ -36,22 +75,8 @@ function startCounter() { } } - updatedTime.value = hoursCount + minutesCount + ':' + secondsCount + return hoursCount + minutesCount + ':' + secondsCount } -function start() { - timer.value = setInterval(() => startCounter(), 1000) -} - -function stop() { - clearInterval(timer.value) - let output = updatedTime.value - hours.value = 0 - minutes.value = 0 - seconds.value = 0 - updatedTime.value = '0:00' - return output -} - -defineExpose({ start, stop, updatedTime }) +defineExpose({ start, stop, getTime, updatedTime }) diff --git a/frontend/src/components/Telephony/CallUI.vue b/frontend/src/components/Telephony/CallUI.vue index 666027db..8c313fd8 100644 --- a/frontend/src/components/Telephony/CallUI.vue +++ b/frontend/src/components/Telephony/CallUI.vue @@ -53,7 +53,7 @@ import { import { globalStore } from '@/stores/global' import { createToast } from '@/utils' import { FormControl, call } from 'frappe-ui' -import { ref, watch } from 'vue' +import { nextTick, ref, watch } from 'vue' const { setMakeCall } = globalStore() @@ -116,20 +116,25 @@ async function setDefaultCallingMedium() { }) } -watch([twilioEnabled, exotelEnabled], ([twilioValue, exotelValue]) => { - if (twilioValue) { - twilio.value.setup() - callMedium.value = 'Twilio' - } +watch( + [twilioEnabled, exotelEnabled], + ([twilioValue, exotelValue]) => + nextTick(() => { + if (twilioValue) { + twilio.value.setup() + callMedium.value = 'Twilio' + } - if (exotelValue) { - exotel.value.setup() - callMedium.value = 'Exotel' - } + if (exotelValue) { + exotel.value.setup() + callMedium.value = 'Exotel' + } - if (twilioValue || exotelValue) { - callMedium.value = 'Twilio' - setMakeCall(makeCall) - } -}) + if (twilioValue || exotelValue) { + callMedium.value = 'Twilio' + setMakeCall(makeCall) + } + }), + { immediate: true }, +) diff --git a/frontend/src/components/Telephony/ExotelCallUI.vue b/frontend/src/components/Telephony/ExotelCallUI.vue index 97690959..3c9b7d18 100644 --- a/frontend/src/components/Telephony/ExotelCallUI.vue +++ b/frontend/src/components/Telephony/ExotelCallUI.vue @@ -32,18 +32,21 @@ {{ __(callStatus) }} · - {{ counterUp?.updatedTime }} + {{ callDuration }}
{{ __(callStatus) }}
-
+
- {{ contact.full_name ?? phoneNumber }} + {{ contact?.full_name ?? phoneNumber }}
@@ -81,7 +84,7 @@ {{ __(callStatus) }} · - {{ counterUp?.updatedTime }} + {{ callDuration }}
{{ __(callStatus) }}
@@ -105,7 +108,7 @@ {{ __(callStatus) }} · - {{ counterUp?.updatedTime }} + {{ callDuration }}
{{ __(callStatus) }}
@@ -123,8 +126,18 @@
-
{{ note }}
-
{{ task }}
+
+ +
+
-
{{ contact.full_name }}
-
{{ phoneNumber }}
+
+ {{ contact.full_name }} +
+
+ {{ phoneNumber }} +
+
+
+ {{ phoneNumber }}
-
{{ phoneNumber }}
-
@@ -182,8 +212,9 @@ import AvatarIcon from '@/components/Icons/AvatarIcon.vue' import MinimizeIcon from '@/components/Icons/MinimizeIcon.vue' import NoteIcon from '@/components/Icons/NoteIcon.vue' import TaskIcon from '@/components/Icons/TaskIcon.vue' +import TaskPanel from './TaskPanel.vue' import CountUpTimer from '@/components/CountUpTimer.vue' -import { Avatar, Button, call } from 'frappe-ui' +import { TextEditor, Avatar, Button, call } from 'frappe-ui' import { globalStore } from '@/stores/global' import { contactsStore } from '@/stores/contacts' import { useDraggable, useWindowSize } from '@vueuse/core' @@ -192,7 +223,7 @@ import { ref, computed, onBeforeUnmount } from 'vue' const { getContact, getLeadContact } = contactsStore() const { $socket } = globalStore() -const callPopup = ref(null) +const callPopupHeader = ref(null) const showCallPopup = ref(false) const showSmallCallPopup = ref(false) @@ -207,7 +238,7 @@ function toggleCallPopup() { const { width, height } = useWindowSize() -let { style } = useDraggable(callPopup, { +let { style } = useDraggable(callPopupHeader, { initialValue: { x: width.value - 350, y: height.value - 250 }, preventDefault: true, }) @@ -245,7 +276,22 @@ function showNoteWindow() { } } -const task = ref('') +function createNote() { + call('crm.integrations.api.create_and_add_note_to_call_log', { + call_sid: callData.value.CallSid, + content: note.value, + }) + note.value = '' +} + +const task = ref({ + title: '', + description: '', + assigned_to: '', + due_date: '', + status: 'Backlog', + priority: 'Low', +}) const showTask = ref(false) @@ -259,8 +305,24 @@ function showTaskWindow() { } } +function createTask() { + call('crm.integrations.api.create_and_add_task_to_call_log', { + call_sid: callData.value.CallSid, + task: task.value, + }) + task.value = { + title: '', + description: '', + assigned_to: '', + due_date: '', + status: 'Backlog', + priority: 'Low', + } +} + function updateWindowHeight(condition) { - let top = parseInt(callPopup.value.style.top) + let callPopup = callPopupHeader.value.parentElement + let top = parseInt(callPopup.style.top) let updatedTop = 0 updatedTop = condition ? top - 224 : top + 224 @@ -269,7 +331,7 @@ function updateWindowHeight(condition) { updatedTop = 10 } - callPopup.value.style.top = updatedTop + 'px' + callPopup.style.top = updatedTop + 'px' } function makeOutgoingCall(number) { @@ -304,9 +366,23 @@ function closeCallPopup() { showCallPopup.value = false showSmallCallPopup.value = false note.value = '' - task.value = '' + task.value = { + title: '', + description: '', + assigned_to: '', + due_date: '', + status: 'Backlog', + priority: 'Low', + } } +function save() { + if (note.value) createNote() + if (task.value.title) createTask() +} + +const callDuration = ref('00:00') + function updateStatus(data) { // outgoing call if ( @@ -339,6 +415,9 @@ function updateStatus(data) { data.Status == 'completed' ) { counterUp.value.stop() + callDuration.value = getTime( + data['Legs[0][OnCallDuration]'] || data.DialCallDuration, + ) return 'Call ended' } @@ -348,12 +427,16 @@ function updateStatus(data) { data.Direction == 'incoming' && data.Status == 'busy' ) { + phoneNumber.value = data.From || data.CallFrom return 'Incoming call' } else if ( - data.EventType == 'Terminal' && data.Direction == 'incoming' && - data.Status == 'free' + (data.EventType == 'Terminal' || data.CallType == 'completed') && + (data.Status == 'free' || data.DialCallStatus == 'completed') ) { + callDuration.value = counterUp.value.getTime( + data['Legs[0][OnCallDuration]'] || data.DialCallDuration, + ) return 'Call ended' } } @@ -376,4 +459,8 @@ defineExpose({ makeOutgoingCall, setup }) .blink { animation: blink 1s ease-in-out 6; } + +:deep(.ProseMirror) { + caret-color: var(--ink-white); +} diff --git a/frontend/src/components/Telephony/TaskPanel.vue b/frontend/src/components/Telephony/TaskPanel.vue new file mode 100644 index 00000000..bf9641a5 --- /dev/null +++ b/frontend/src/components/Telephony/TaskPanel.vue @@ -0,0 +1,144 @@ + + + From ccdada9c71854da105159bb8041c9e463b1c1f4f Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Fri, 17 Jan 2025 20:14:32 +0530 Subject: [PATCH 26/61] fix: get phone number details using phonenumbers --- crm/utils/__init__.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 crm/utils/__init__.py diff --git a/crm/utils/__init__.py b/crm/utils/__init__.py new file mode 100644 index 00000000..7c7dabcf --- /dev/null +++ b/crm/utils/__init__.py @@ -0,0 +1,29 @@ +import phonenumbers +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)} From 7301e8d62ca2d21d9109300f9b16eb7ddcbbf90b Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Fri, 17 Jan 2025 20:55:40 +0530 Subject: [PATCH 27/61] fix: check if two numbers are same --- crm/utils/__init__.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crm/utils/__init__.py b/crm/utils/__init__.py index 7c7dabcf..e4ef8ccd 100644 --- a/crm/utils/__init__.py +++ b/crm/utils/__init__.py @@ -27,3 +27,34 @@ def parse_phone_number(phone_number, default_country="IN"): 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 From f9c97ce51b88fffca934590874a1ae9719638005 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Fri, 17 Jan 2025 20:57:58 +0530 Subject: [PATCH 28/61] fix: get contact detail and if is lead or deal and show on call popup window --- crm/integrations/api.py | 77 ++++++++++ .../components/ListViews/CallLogsListView.vue | 1 + .../src/components/Telephony/ExotelCallUI.vue | 138 ++++++++++++------ 3 files changed, 169 insertions(+), 47 deletions(-) diff --git a/crm/integrations/api.py b/crm/integrations/api.py index d4f8ede1..f5a456ab 100644 --- a/crm/integrations/api.py +++ b/crm/integrations/api.py @@ -1,4 +1,8 @@ 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() @@ -46,3 +50,76 @@ def create_and_add_task_to_call_log(call_sid, task): call_log = frappe.get_doc("CRM Call Log", call_sid) call_log.link_with_reference_doc("CRM Task", _task.name) + + +@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/frontend/src/components/ListViews/CallLogsListView.vue b/frontend/src/components/ListViews/CallLogsListView.vue index 1522e0a5..bf04ccf7 100644 --- a/frontend/src/components/ListViews/CallLogsListView.vue +++ b/frontend/src/components/ListViews/CallLogsListView.vue @@ -198,6 +198,7 @@ const props = defineProps({ }) const emit = defineEmits([ + 'showCallLog', 'loadMore', 'updatePageCount', 'columnWidthUpdated', diff --git a/frontend/src/components/Telephony/ExotelCallUI.vue b/frontend/src/components/Telephony/ExotelCallUI.vue index 3c9b7d18..4f0092ed 100644 --- a/frontend/src/components/Telephony/ExotelCallUI.vue +++ b/frontend/src/components/Telephony/ExotelCallUI.vue @@ -48,21 +48,26 @@ class="header flex items-center justify-between gap-1 text-base cursor-move select-none" >
-
+
-
-
+
+
{{ contact?.full_name ?? phoneNumber }}
@@ -115,15 +120,24 @@
- +
+ +
@@ -184,43 +198,45 @@ -
-
+
diff --git a/frontend/src/components/Modals/CallLogModal.vue b/frontend/src/components/Modals/CallLogModal.vue index 9136c154..f2c46ed4 100644 --- a/frontend/src/components/Modals/CallLogModal.vue +++ b/frontend/src/components/Modals/CallLogModal.vue @@ -146,7 +146,7 @@ const detailFields = computed(() => { { icon: callLog.value.data._lead ? LeadsIcon : Dealsicon, name: 'reference_doc', - value: callLog.value.data._lead == 'CRM Lead' ? 'Lead' : 'Deal', + value: callLog.value.data._lead ? 'Lead' : 'Deal', link: () => { if (callLog.value.data._lead) { router.push({ From cfae3101c53f3cf5fa81ddbaf95ad56f121038dd Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Sat, 18 Jan 2025 20:34:04 +0530 Subject: [PATCH 37/61] fix: fix twilio contact viewing, note creation & linking code --- crm/integrations/api.py | 63 +++++++---- crm/integrations/twilio/api.py | 104 +++++++----------- crm/integrations/twilio/utils.py | 7 -- .../src/components/Telephony/ExotelCallUI.vue | 93 +++++++++++----- .../src/components/Telephony/TwilioCallUI.vue | 81 +++++++------- 5 files changed, 190 insertions(+), 158 deletions(-) diff --git a/crm/integrations/api.py b/crm/integrations/api.py index 7b8a1b43..145bf03a 100644 --- a/crm/integrations/api.py +++ b/crm/integrations/api.py @@ -24,34 +24,51 @@ def set_default_calling_medium(medium): @frappe.whitelist() -def create_and_add_note_to_call_log(call_sid, content): - """Add note to call log based on call sid.""" - note = frappe.get_doc( - { - "doctype": "FCRM Note", - "content": content, - } - ).insert(ignore_permissions=True) +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")) - call_log = frappe.get_doc("CRM Call Log", call_sid) - call_log.link_with_reference_doc("FCRM Note", note.name) - call_log.save(ignore_permissions=True) + return _note @frappe.whitelist() -def create_and_add_task_to_call_log(call_sid, task): - """Add task to call log based on call sid.""" - _task = frappe.get_doc( - { - "doctype": "CRM Task", - "title": task.get("title"), - "description": task.get("description"), - } - ).insert(ignore_permissions=True) +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) - 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) + return _task @frappe.whitelist() diff --git a/crm/integrations/twilio/api.py b/crm/integrations/twilio/api.py index c88b6a58..e78e0214 100644 --- a/crm/integrations/twilio/api.py +++ b/crm/integrations/twilio/api.py @@ -4,8 +4,9 @@ import frappe from frappe import _ from werkzeug.wrappers import Response +from crm.integrations.api import get_contact_by_phone_number + from .twilio_handler import IncomingCall, Twilio, TwilioCallDetails -from .utils import parse_mobile_no @frappe.whitelist() @@ -69,13 +70,31 @@ def twilio_incoming_call_handler(**kwargs): def create_call_log(call_details: TwilioCallDetails): - call_log = frappe.get_doc( - {**call_details.to_dict(), "doctype": "CRM Call Log", "telephony_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): @@ -84,18 +103,19 @@ def update_call_log(call_sid, status=None): if not (twilio and frappe.db.exists("CRM Call Log", call_sid)): return - 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() + 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() @frappe.whitelist(allow_guest=True) @@ -106,7 +126,7 @@ 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")) @@ -128,7 +148,7 @@ def update_call_status_info(**kwargs): client = Twilio.get_twilio_client() client.calls(args.ParentCallSid).user_defined_messages.create(content=json.dumps(call_info)) - except: + except Exception: frappe.log_error(title=_("Failed to update Twilio call status")) @@ -144,45 +164,3 @@ def get_datetime_from_timestamp(timestamp): 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 diff --git a/crm/integrations/twilio/utils.py b/crm/integrations/twilio/utils.py index a47e604d..0430f311 100644 --- a/crm/integrations/twilio/utils.py +++ b/crm/integrations/twilio/utils.py @@ -15,10 +15,3 @@ def merge_dicts(d1: dict, d2: dict): """ 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 == "+"]) diff --git a/frontend/src/components/Telephony/ExotelCallUI.vue b/frontend/src/components/Telephony/ExotelCallUI.vue index 6ff354c6..1e52af5d 100644 --- a/frontend/src/components/Telephony/ExotelCallUI.vue +++ b/frontend/src/components/Telephony/ExotelCallUI.vue @@ -146,8 +146,8 @@ ref="content" editor-class="prose-sm h-[290px] text-ink-white overflow-auto mt-1" :bubbleMenu="true" - :content="note" - @change="(val) => (note = val)" + :content="note.content" + @change="(val) => (note.content = val)" :placeholder="__('Take a note...')" />
@@ -210,8 +210,21 @@
+
- {{ contact.full_name }} + {{ contact?.full_name ?? __('Unknown') }}
-
{{ contact.mobile_no }}
+
{{ contact?.mobile_no }}
@@ -120,12 +121,13 @@ >
- {{ contact.full_name }} + {{ contact?.full_name ?? __('Unknown') }}
@@ -195,20 +197,13 @@ import CountUpTimer from '@/components/CountUpTimer.vue' import NoteModal from '@/components/Modals/NoteModal.vue' import { Device } from '@twilio/voice-sdk' import { useDraggable, useWindowSize } from '@vueuse/core' -import { contactsStore } from '@/stores/contacts' import { capture } from '@/telemetry' -import { Avatar, call } from 'frappe-ui' +import { Avatar, call, createResource } from 'frappe-ui' import { ref, watch } from 'vue' -const { getContact, getLeadContact } = contactsStore() - let device = '' let log = ref('Connecting...') let _call = null -const contact = ref({ - full_name: '', - mobile_no: '', -}) let showCallPopup = ref(false) let showSmallCallWindow = ref(false) @@ -218,8 +213,36 @@ let muted = ref(false) let callPopup = ref(null) let counterUp = ref(null) let callStatus = ref('') + +const phoneNumber = ref('') + +const contact = ref({ + full_name: '', + image: '', + mobile_no: '', +}) + +watch(phoneNumber, (value) => { + if (!value) return + getContact.fetch() +}) + +const getContact = createResource({ + url: 'crm.integrations.api.get_contact_by_phone_number', + makeParams() { + return { + phone_number: phoneNumber.value, + } + }, + cache: ['contact', phoneNumber.value], + onSuccess(data) { + contact.value = data + }, +}) + const showNoteModal = ref(false) const note = ref({ + name: '', title: '', content: '', }) @@ -227,9 +250,9 @@ const note = ref({ async function updateNote(_note, insert_mode = false) { note.value = _note if (insert_mode && _note.name) { - await call('crm.integrations.twilio.api.add_note_to_call_log', { + await call('crm.integrations.api.add_note_to_call_log', { call_sid: _call.parameters.CallSid, - note: _note.name, + note: _note, }) } } @@ -298,19 +321,7 @@ function toggleMute() { function handleIncomingCall(call) { log.value = `Incoming call from ${call.parameters.From}` - - // get name of the caller from the phone number - contact.value = getContact(call.parameters.From) - if (!contact.value) { - contact.value = getLeadContact(call.parameters.From) - } - - if (!contact.value) { - contact.value = { - full_name: __('Unknown'), - mobile_no: call.parameters.From, - } - } + phoneNumber.value = call.parameters.From showCallPopup.value = true _call = call @@ -352,6 +363,7 @@ function hangUpCall() { callStatus.value = '' muted.value = false note.value = { + name: '', title: '', content: '', } @@ -373,19 +385,7 @@ function handleDisconnectedIncomingCall() { } async function makeOutgoingCall(number) { - // check if number has a country code - // if (number?.replace(/[^0-9+]/g, '').length == 10) { - // $dialog({ - // title: 'Invalid Mobile Number', - // message: `${number} is not a valid mobile number. Either add a country code or check the number again.`, - // }) - // return - // } - - contact.value = getContact(number) - if (!contact.value) { - contact.value = getLeadContact(number) - } + phoneNumber.value = number if (device) { log.value = `Attempting to call ${number} ...` @@ -431,6 +431,7 @@ async function makeOutgoingCall(number) { muted.value = false counterUp.value.stop() note.value = { + name: '', title: '', content: '', } @@ -445,6 +446,7 @@ async function makeOutgoingCall(number) { callStatus.value = '' muted.value = false note.value = { + name: '', title: '', content: '', } @@ -471,6 +473,7 @@ function cancelCall() { callStatus.value = '' muted.value = false note.value = { + name: '', title: '', content: '', } From 3daa04c3a05b3fedbc11ad978b5bf0e4f6989729 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Sat, 18 Jan 2025 20:47:48 +0530 Subject: [PATCH 38/61] build(deps): bump frappeui to 0.1.105 --- frappe-ui | 2 +- frontend/package.json | 2 +- yarn.lock | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frappe-ui b/frappe-ui index 99f0b86f..863eaae9 160000 --- a/frappe-ui +++ b/frappe-ui @@ -1 +1 @@ -Subproject commit 99f0b86f15e094b95c32e87494e003c974b4f0df +Subproject commit 863eaae9ada2edb287fc09fb21d05212bb5eebe9 diff --git a/frontend/package.json b/frontend/package.json index 82469d7c..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", diff --git a/yarn.lock b/yarn.lock index 56fc746a..7b7baaa4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2388,10 +2388,10 @@ fraction.js@^4.3.7: resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7" integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew== -frappe-ui@^0.1.104: - version "0.1.104" - resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.104.tgz#1ca8b303817d45cdccee9af9ef3597524e4eb0f2" - integrity sha512-rLgYwGKPChJHYBH6AIgsdN3ZPpT+N1K2UthRvKrPva0xLX9YAQS7xpiw5xVxwZXnpc1//EiXqOQcB7bb575wAg== +frappe-ui@^0.1.105: + version "0.1.105" + resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.105.tgz#3bdf3c458ba27f27ff2f2a28cf7eb6f9ed872367" + integrity sha512-9bZ/hj/HhQ9vp7DxE8aOKS8HqwETZrKT3IhSzjpYOk21efK8QwdbQ9sp0t4m3UII+HaUTSOTHnFzF7y9EhRZxg== dependencies: "@headlessui/vue" "^1.7.14" "@popperjs/core" "^2.11.2" From 1af60281ade079b9c4dff671baa28243a0376611 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Sat, 18 Jan 2025 21:37:30 +0530 Subject: [PATCH 39/61] fix: minor fixe --- frontend/src/components/Telephony/ExotelCallUI.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/Telephony/ExotelCallUI.vue b/frontend/src/components/Telephony/ExotelCallUI.vue index 1e52af5d..3d65e02e 100644 --- a/frontend/src/components/Telephony/ExotelCallUI.vue +++ b/frontend/src/components/Telephony/ExotelCallUI.vue @@ -514,7 +514,7 @@ function updateStatus(data) { data.Status == 'completed' ) { counterUp.value.stop() - callDuration.value = getTime( + callDuration.value = counterUp.value.getTime( parseInt(data['Legs[0][OnCallDuration]']) || parseInt(data.DialCallDuration), ) From bc5c53e6529446c00df3e0e8f6571f2ba287082a Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Sun, 19 Jan 2025 14:16:24 +0530 Subject: [PATCH 40/61] refactor: renamed exotel agent to telephony agent will maintain agents details for twilio and exotel --- .../__init__.py | 0 .../crm_telephony_agent.js} | 2 +- .../crm_telephony_agent.json} | 64 ++++++++++++++++--- .../crm_telephony_agent.py | 34 ++++++++++ .../test_crm_telephony_agent.py} | 9 ++- .../doctype/crm_telephony_phone/__init__.py | 0 .../crm_telephony_phone.json | 40 ++++++++++++ .../crm_telephony_phone.py} | 2 +- 8 files changed, 134 insertions(+), 17 deletions(-) rename crm/fcrm/doctype/{crm_exotel_agent => crm_telephony_agent}/__init__.py (100%) rename crm/fcrm/doctype/{crm_exotel_agent/crm_exotel_agent.js => crm_telephony_agent/crm_telephony_agent.js} (77%) rename crm/fcrm/doctype/{crm_exotel_agent/crm_exotel_agent.json => crm_telephony_agent/crm_telephony_agent.json} (54%) create mode 100644 crm/fcrm/doctype/crm_telephony_agent/crm_telephony_agent.py rename crm/fcrm/doctype/{crm_exotel_agent/test_crm_exotel_agent.py => crm_telephony_agent/test_crm_telephony_agent.py} (77%) create mode 100644 crm/fcrm/doctype/crm_telephony_phone/__init__.py create mode 100644 crm/fcrm/doctype/crm_telephony_phone/crm_telephony_phone.json rename crm/fcrm/doctype/{crm_exotel_agent/crm_exotel_agent.py => crm_telephony_phone/crm_telephony_phone.py} (84%) diff --git a/crm/fcrm/doctype/crm_exotel_agent/__init__.py b/crm/fcrm/doctype/crm_telephony_agent/__init__.py similarity index 100% rename from crm/fcrm/doctype/crm_exotel_agent/__init__.py rename to crm/fcrm/doctype/crm_telephony_agent/__init__.py diff --git a/crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.js b/crm/fcrm/doctype/crm_telephony_agent/crm_telephony_agent.js similarity index 77% rename from crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.js rename to crm/fcrm/doctype/crm_telephony_agent/crm_telephony_agent.js index 78f3c9bb..b1bea173 100644 --- a/crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.js +++ b/crm/fcrm/doctype/crm_telephony_agent/crm_telephony_agent.js @@ -1,7 +1,7 @@ // Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors // For license information, please see license.txt -// frappe.ui.form.on("CRM Exotel Agent", { +// frappe.ui.form.on("CRM Telephony Agent", { // refresh(frm) { // }, diff --git a/crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.json b/crm/fcrm/doctype/crm_telephony_agent/crm_telephony_agent.json similarity index 54% rename from crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.json rename to crm/fcrm/doctype/crm_telephony_agent/crm_telephony_agent.json index c9baa785..083e4d91 100644 --- a/crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.json +++ b/crm/fcrm/doctype/crm_telephony_agent/crm_telephony_agent.json @@ -1,7 +1,7 @@ { "actions": [], "allow_rename": 1, - "autoname": "field:mobile_no", + "autoname": "field:user", "creation": "2025-01-11 16:12:46.602782", "doctype": "DocType", "engine": "InnoDB", @@ -10,7 +10,14 @@ "user_name", "column_break_hdec", "mobile_no", - "exotel_number" + "section_break_ozjn", + "twilio", + "twilio_number", + "column_break_aydj", + "exotel", + "exotel_number", + "section_break_phlq", + "phone_nos" ], "fields": [ { @@ -20,7 +27,8 @@ "in_standard_filter": 1, "label": "User", "options": "User", - "reqd": 1 + "reqd": 1, + "unique": 1 }, { "fieldname": "column_break_hdec", @@ -32,8 +40,7 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Mobile No.", - "reqd": 1, - "unique": 1 + "read_only": 1 }, { "fetch_from": "user.full_name", @@ -44,19 +51,56 @@ "label": "User Name" }, { + "depends_on": "exotel", "fieldname": "exotel_number", "fieldtype": "Data", - "in_list_view": 1, - "in_standard_filter": 1, - "label": "Exotel Number" + "label": "Exotel Number", + "mandatory_depends_on": "exotel" + }, + { + "fieldname": "section_break_phlq", + "fieldtype": "Section Break" + }, + { + "fieldname": "section_break_ozjn", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_aydj", + "fieldtype": "Column Break" + }, + { + "depends_on": "twilio", + "fieldname": "twilio_number", + "fieldtype": "Data", + "label": "Twilio Number", + "mandatory_depends_on": "twilio" + }, + { + "fieldname": "phone_nos", + "fieldtype": "Table", + "label": "Phone Numbers", + "options": "CRM Telephony Phone" + }, + { + "default": "0", + "fieldname": "twilio", + "fieldtype": "Check", + "label": "Twilio" + }, + { + "default": "0", + "fieldname": "exotel", + "fieldtype": "Check", + "label": "Exotel" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2025-01-15 20:03:31.162162", + "modified": "2025-01-19 14:12:51.596987", "modified_by": "Administrator", "module": "FCRM", - "name": "CRM Exotel Agent", + "name": "CRM Telephony Agent", "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ diff --git a/crm/fcrm/doctype/crm_telephony_agent/crm_telephony_agent.py b/crm/fcrm/doctype/crm_telephony_agent/crm_telephony_agent.py new file mode 100644 index 00000000..ff1f85e9 --- /dev/null +++ b/crm/fcrm/doctype/crm_telephony_agent/crm_telephony_agent.py @@ -0,0 +1,34 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document + + +class CRMTelephonyAgent(Document): + def validate(self): + self.set_primary() + + def set_primary(self): + # Used to set primary mobile no. + if len(self.phone_nos) == 0: + self.mobile_no = "" + return + + is_primary = [phone.number for phone in self.phone_nos if phone.get("is_primary")] + + if len(is_primary) > 1: + frappe.throw( + _("Only one {0} can be set as primary.").format(frappe.bold(frappe.unscrub("mobile_no"))) + ) + + primary_number_exists = False + for d in self.phone_nos: + if d.get("is_primary") == 1: + primary_number_exists = True + self.mobile_no = d.number + break + + if not primary_number_exists: + self.mobile_no = "" diff --git a/crm/fcrm/doctype/crm_exotel_agent/test_crm_exotel_agent.py b/crm/fcrm/doctype/crm_telephony_agent/test_crm_telephony_agent.py similarity index 77% rename from crm/fcrm/doctype/crm_exotel_agent/test_crm_exotel_agent.py rename to crm/fcrm/doctype/crm_telephony_agent/test_crm_telephony_agent.py index 9fe610ce..63e0f2fb 100644 --- a/crm/fcrm/doctype/crm_exotel_agent/test_crm_exotel_agent.py +++ b/crm/fcrm/doctype/crm_telephony_agent/test_crm_telephony_agent.py @@ -4,7 +4,6 @@ # 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 @@ -12,18 +11,18 @@ EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] -class UnitTestCRMExotelAgent(UnitTestCase): +class UnitTestCRMTelephonyAgent(UnitTestCase): """ - Unit tests for CRMExotelAgent. + Unit tests for CRMTelephonyAgent. Use this class for testing individual functions and methods. """ pass -class IntegrationTestCRMExotelAgent(IntegrationTestCase): +class IntegrationTestCRMTelephonyAgent(IntegrationTestCase): """ - Integration tests for CRMExotelAgent. + Integration tests for CRMTelephonyAgent. Use this class for testing interactions between multiple components. """ diff --git a/crm/fcrm/doctype/crm_telephony_phone/__init__.py b/crm/fcrm/doctype/crm_telephony_phone/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/crm/fcrm/doctype/crm_telephony_phone/crm_telephony_phone.json b/crm/fcrm/doctype/crm_telephony_phone/crm_telephony_phone.json new file mode 100644 index 00000000..6450a96a --- /dev/null +++ b/crm/fcrm/doctype/crm_telephony_phone/crm_telephony_phone.json @@ -0,0 +1,40 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-01-19 13:57:01.702519", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "number", + "is_primary" + ], + "fields": [ + { + "fieldname": "number", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Number", + "reqd": 1 + }, + { + "default": "0", + "fieldname": "is_primary", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Is Primary" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2025-01-19 13:58:59.063775", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Telephony Phone", + "owner": "Administrator", + "permissions": [], + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.py b/crm/fcrm/doctype/crm_telephony_phone/crm_telephony_phone.py similarity index 84% rename from crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.py rename to crm/fcrm/doctype/crm_telephony_phone/crm_telephony_phone.py index 05fa7ee7..5522b84d 100644 --- a/crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.py +++ b/crm/fcrm/doctype/crm_telephony_phone/crm_telephony_phone.py @@ -5,5 +5,5 @@ from frappe.model.document import Document -class CRMExotelAgent(Document): +class CRMTelephonyPhone(Document): pass From 0f6876de2752b7101e1cc47e8844fe5d17a7273f Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Sun, 19 Jan 2025 14:17:52 +0530 Subject: [PATCH 41/61] fix: also maintain agent level default medium --- .../doctype/crm_telephony_agent/crm_telephony_agent.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crm/fcrm/doctype/crm_telephony_agent/crm_telephony_agent.json b/crm/fcrm/doctype/crm_telephony_agent/crm_telephony_agent.json index 083e4d91..042632f6 100644 --- a/crm/fcrm/doctype/crm_telephony_agent/crm_telephony_agent.json +++ b/crm/fcrm/doctype/crm_telephony_agent/crm_telephony_agent.json @@ -10,6 +10,7 @@ "user_name", "column_break_hdec", "mobile_no", + "default_medium", "section_break_ozjn", "twilio", "twilio_number", @@ -93,11 +94,17 @@ "fieldname": "exotel", "fieldtype": "Check", "label": "Exotel" + }, + { + "fieldname": "default_medium", + "fieldtype": "Select", + "label": "Default Medium", + "options": "\nTwilio\nExotel" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2025-01-19 14:12:51.596987", + "modified": "2025-01-19 14:17:12.880185", "modified_by": "Administrator", "module": "FCRM", "name": "CRM Telephony Agent", From 03b1bab00dab724f6f7c80a108120b26eb58c110 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Sun, 19 Jan 2025 14:22:14 +0530 Subject: [PATCH 42/61] fix: use Telephony Agent to get exotel_number & phone_number --- crm/integrations/exotel/handler.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crm/integrations/exotel/handler.py b/crm/integrations/exotel/handler.py index 1f01837c..7d3301a8 100644 --- a/crm/integrations/exotel/handler.py +++ b/crm/integrations/exotel/handler.py @@ -67,17 +67,22 @@ def make_a_call(to_number, from_number=None, caller_id=None): 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") + from_number = frappe.get_value("CRM Telephony 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") + caller_id = frappe.get_value("CRM Telephony Agent", {"user": frappe.session.user}, "exotel_number") + + if not caller_id: + frappe.throw( + _("You do not have Exotel Number set in your Telephony Agent"), title=_("Exotel Number Missing") + ) 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") + _("You do not have mobile number set in your Telephony Agent"), title=_("Mobile Number Missing") ) record_call = frappe.db.get_single_value("CRM Exotel Settings", "record_call") From 5cd2f2bd39b3d87917bcbd497d43448c84cb996e Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Sun, 19 Jan 2025 14:28:36 +0530 Subject: [PATCH 43/61] fix: get and set default medium via telephony agent --- .../doctype/fcrm_settings/fcrm_settings.json | 17 ++--------- crm/integrations/api.py | 28 +++++++++++++++++-- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/crm/fcrm/doctype/fcrm_settings/fcrm_settings.json b/crm/fcrm/doctype/fcrm_settings/fcrm_settings.json index 0a902c99..f445541d 100644 --- a/crm/fcrm/doctype/fcrm_settings/fcrm_settings.json +++ b/crm/fcrm/doctype/fcrm_settings/fcrm_settings.json @@ -12,9 +12,7 @@ "brand_logo", "favicon", "dropdown_items_tab", - "dropdown_items", - "calling_tab", - "default_calling_medium" + "dropdown_items" ], "fields": [ { @@ -58,23 +56,12 @@ "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": "2025-01-15 17:40:32.784762", + "modified": "2025-01-19 14:23:05.981355", "modified_by": "Administrator", "module": "FCRM", "name": "FCRM Settings", diff --git a/crm/integrations/api.py b/crm/integrations/api.py index 145bf03a..f2f55443 100644 --- a/crm/integrations/api.py +++ b/crm/integrations/api.py @@ -9,18 +9,40 @@ from crm.utils import are_same_phone_number, parse_phone_number 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, + "default_calling_medium": get_user_default_calling_medium(), } +def get_user_default_calling_medium(): + if not frappe.db.exists("CRM Telephony Agent", frappe.session.user): + return None + + default_medium = frappe.db.get_value("CRM Telephony Agent", frappe.session.user, "default_medium") + + if not default_medium: + return None + + return default_medium + + @frappe.whitelist() def set_default_calling_medium(medium): - return frappe.db.set_value("FCRM Settings", "FCRM Settings", "default_calling_medium", medium) + if not frappe.db.exists("CRM Telephony Agent", frappe.session.user): + frappe.get_doc( + { + "doctype": "CRM Telephony Agent", + "agent": frappe.session.user, + "default_medium": medium, + } + ).insert(ignore_permissions=True) + else: + frappe.db.set_value("CRM Telephony Agent", frappe.session.user, "default_medium", medium) + + return get_user_default_calling_medium() @frappe.whitelist() From 346efbbe785164a5ee5f0ea8c7b33dea00044482 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Sun, 19 Jan 2025 14:41:17 +0530 Subject: [PATCH 44/61] fix: allow agent to set default medium from settings --- crm/api/session.py | 34 +++++++++++-------- frontend/src/components/Settings/Settings.vue | 8 +++-- .../components/Settings/TelephonySettings.vue | 15 ++++++-- frontend/src/stores/users.js | 5 +++ 4 files changed, 42 insertions(+), 20 deletions(-) diff --git a/crm/api/session.py b/crm/api/session.py index 2e2b9375..746de854 100644 --- a/crm/api/session.py +++ b/crm/api/session.py @@ -5,7 +5,16 @@ import frappe def get_users(): users = frappe.qb.get_query( "User", - fields=["name", "email", "enabled", "user_image", "first_name", "last_name", "full_name", "user_type"], + fields=[ + "name", + "email", + "enabled", + "user_image", + "first_name", + "last_name", + "full_name", + "user_type", + ], order_by="full_name asc", distinct=True, ).run(as_dict=1) @@ -14,11 +23,13 @@ def get_users(): if frappe.session.user == user.name: user.session_user = True - user.is_manager = ( - "Sales Manager" in frappe.get_roles(user.name) or user.name == "Administrator" - ) + user.is_manager = "Sales Manager" in frappe.get_roles(user.name) or user.name == "Administrator" + + user.is_agent = frappe.db.exists("CRM Telephony Agent", {"user": user.name}) + return users + @frappe.whitelist() def get_contacts(): contacts = frappe.get_all( @@ -37,7 +48,7 @@ def get_contacts(): "mobile_no", "phone", "company_name", - "modified" + "modified", ], order_by="first_name asc", distinct=True, @@ -58,18 +69,12 @@ def get_contacts(): return contacts + @frappe.whitelist() def get_lead_contacts(): lead_contacts = frappe.get_all( "CRM Lead", - fields=[ - "name", - "lead_name", - "mobile_no", - "phone", - "image", - "modified" - ], + fields=["name", "lead_name", "mobile_no", "phone", "image", "modified"], filters={"converted": 0}, order_by="lead_name asc", distinct=True, @@ -77,11 +82,12 @@ def get_lead_contacts(): return lead_contacts + @frappe.whitelist() def get_organizations(): organizations = frappe.qb.get_query( "CRM Organization", - fields=['*'], + fields=["*"], order_by="name asc", distinct=True, ).run(as_dict=1) diff --git a/frontend/src/components/Settings/Settings.vue b/frontend/src/components/Settings/Settings.vue index 2f1c981d..fde93938 100644 --- a/frontend/src/components/Settings/Settings.vue +++ b/frontend/src/components/Settings/Settings.vue @@ -67,7 +67,7 @@ import { import { Dialog, Button, Avatar } from 'frappe-ui' import { ref, markRaw, computed, watch, h } from 'vue' -const { isManager, getUser } = usersStore() +const { isManager, isAgent, getUser } = usersStore() const user = computed(() => getUser() || {}) @@ -108,20 +108,22 @@ const tabs = computed(() => { label: __('Telephony'), icon: PhoneIcon, component: markRaw(TelephonySettings), + condition: () => isManager() || isAgent(), }, { label: __('WhatsApp'), icon: WhatsAppIcon, component: markRaw(WhatsAppSettings), - condition: () => isWhatsappInstalled.value, + condition: () => isWhatsappInstalled.value && isManager(), }, { label: __('ERPNext'), icon: ERPNextIcon, component: markRaw(ERPNextSettings), + condition: () => isManager(), }, ], - condition: () => isManager(), + condition: () => isManager() || isAgent(), }, ] diff --git a/frontend/src/components/Settings/TelephonySettings.vue b/frontend/src/components/Settings/TelephonySettings.vue index 8134df82..a4fb0555 100644 --- a/frontend/src/components/Settings/TelephonySettings.vue +++ b/frontend/src/components/Settings/TelephonySettings.vue @@ -19,17 +19,18 @@ -
+
{{ __('Twilio') }} @@ -42,7 +43,7 @@
-
+
{{ __('Exotel') }} @@ -85,9 +86,12 @@ import { call, } from 'frappe-ui' import { defaultCallingMedium } from '@/composables/settings' +import { usersStore } from '@/stores/users' import { createToast, getRandom } from '@/utils' import { ref, computed, watch } from 'vue' +const { isManager, isAgent } = usersStore() + const twilioFields = createResource({ url: 'crm.api.doc.get_fields', cache: ['fields', 'Twilio Settings'], @@ -273,6 +277,9 @@ function update() { if (mediumChanged.value) { updateMedium() } + + if (!isManager()) return + if (twilio.isDirty) { twilio.save.submit() } @@ -298,6 +305,8 @@ async function updateMedium() { const error = ref('') function validateIfDefaultMediumIsEnabled() { + if (isAgent() && !isManager()) return true + if (defaultCallingMedium.value === 'Twilio' && !twilio.doc.enabled) { error.value = __('Twilio is not enabled') return false diff --git a/frontend/src/stores/users.js b/frontend/src/stores/users.js index bee56d56..5965c537 100644 --- a/frontend/src/stores/users.js +++ b/frontend/src/stores/users.js @@ -53,9 +53,14 @@ export const usersStore = defineStore('crm-users', () => { return getUser(email).is_manager } + function isAgent(email) { + return getUser(email).is_agent + } + return { users, getUser, isManager, + isAgent, } }) From 89dd09325fb26d93d349c51f5b8620a3369e55e5 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Sun, 19 Jan 2025 16:12:01 +0530 Subject: [PATCH 45/61] fix: renamed Twilio Settings to CRM Twilio Settings --- .../__init__.py | 0 .../crm_twilio_settings.js} | 2 +- .../crm_twilio_settings.json} | 2 +- .../crm_twilio_settings.py} | 44 +++++------ .../test_crm_twilio_settings.py} | 2 +- .../doctype/twilio_agents/twilio_agents.json | 78 ------------------- .../doctype/twilio_agents/twilio_agents.py | 9 --- crm/fcrm/doctype/twilio_settings/__init__.py | 0 .../twilio_settings/test_twilio_settings.py | 9 --- .../twilio_settings/twilio_settings.js | 8 -- 10 files changed, 22 insertions(+), 132 deletions(-) rename crm/fcrm/doctype/{twilio_agents => crm_twilio_settings}/__init__.py (100%) rename crm/fcrm/doctype/{twilio_agents/twilio_agents.js => crm_twilio_settings/crm_twilio_settings.js} (77%) rename crm/fcrm/doctype/{twilio_settings/twilio_settings.json => crm_twilio_settings/crm_twilio_settings.json} (98%) rename crm/fcrm/doctype/{twilio_settings/twilio_settings.py => crm_twilio_settings/crm_twilio_settings.py} (76%) rename crm/fcrm/doctype/{twilio_agents/test_twilio_agents.py => crm_twilio_settings/test_crm_twilio_settings.py} (77%) delete mode 100644 crm/fcrm/doctype/twilio_agents/twilio_agents.json delete mode 100644 crm/fcrm/doctype/twilio_agents/twilio_agents.py delete mode 100644 crm/fcrm/doctype/twilio_settings/__init__.py delete mode 100644 crm/fcrm/doctype/twilio_settings/test_twilio_settings.py delete mode 100644 crm/fcrm/doctype/twilio_settings/twilio_settings.js diff --git a/crm/fcrm/doctype/twilio_agents/__init__.py b/crm/fcrm/doctype/crm_twilio_settings/__init__.py similarity index 100% rename from crm/fcrm/doctype/twilio_agents/__init__.py rename to crm/fcrm/doctype/crm_twilio_settings/__init__.py diff --git a/crm/fcrm/doctype/twilio_agents/twilio_agents.js b/crm/fcrm/doctype/crm_twilio_settings/crm_twilio_settings.js similarity index 77% rename from crm/fcrm/doctype/twilio_agents/twilio_agents.js rename to crm/fcrm/doctype/crm_twilio_settings/crm_twilio_settings.js index 8d8bf294..1bfa5e44 100644 --- a/crm/fcrm/doctype/twilio_agents/twilio_agents.js +++ b/crm/fcrm/doctype/crm_twilio_settings/crm_twilio_settings.js @@ -1,7 +1,7 @@ // Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors // For license information, please see license.txt -// frappe.ui.form.on("Twilio Agents", { +// frappe.ui.form.on("CRM Twilio Settings", { // refresh(frm) { // }, diff --git a/crm/fcrm/doctype/twilio_settings/twilio_settings.json b/crm/fcrm/doctype/crm_twilio_settings/crm_twilio_settings.json similarity index 98% rename from crm/fcrm/doctype/twilio_settings/twilio_settings.json rename to crm/fcrm/doctype/crm_twilio_settings/crm_twilio_settings.json index 17e92cfb..d898b631 100644 --- a/crm/fcrm/doctype/twilio_settings/twilio_settings.json +++ b/crm/fcrm/doctype/crm_twilio_settings/crm_twilio_settings.json @@ -108,7 +108,7 @@ "modified": "2025-01-15 19:35:13.406254", "modified_by": "Administrator", "module": "FCRM", - "name": "Twilio Settings", + "name": "CRM Twilio Settings", "owner": "Administrator", "permissions": [ { diff --git a/crm/fcrm/doctype/twilio_settings/twilio_settings.py b/crm/fcrm/doctype/crm_twilio_settings/crm_twilio_settings.py similarity index 76% rename from crm/fcrm/doctype/twilio_settings/twilio_settings.py rename to crm/fcrm/doctype/crm_twilio_settings/crm_twilio_settings.py index 1d3a20b6..811e50ea 100644 --- a/crm/fcrm/doctype/twilio_settings/twilio_settings.py +++ b/crm/fcrm/doctype/crm_twilio_settings/crm_twilio_settings.py @@ -2,13 +2,13 @@ # For license information, please see license.txt import frappe -from frappe.model.document import Document from frappe import _ - +from frappe.model.document import Document from twilio.rest import Client -class TwilioSettings(Document): - friendly_resource_name = "Frappe CRM" # System creates TwiML app & API keys with this name. + +class CRMTwilioSettings(Document): + friendly_resource_name = "Frappe CRM" # System creates TwiML app & API keys with this name. def validate(self): self.validate_twilio_account() @@ -33,28 +33,24 @@ class TwilioSettings(Document): frappe.throw(_("Invalid Account SID or Auth Token.")) def set_api_credentials(self, twilio): - """Generate Twilio API credentials if not exist and update them. - """ + """Generate Twilio API credentials if not exist and update them.""" if self.api_key and self.api_secret: return new_key = self.create_api_key(twilio) self.api_key = new_key.sid self.api_secret = new_key.secret - frappe.db.set_value('Twilio Settings', 'Twilio Settings', { - 'api_key': self.api_key, - 'api_secret': self.api_secret - }) + frappe.db.set_value( + "Twilio Settings", "Twilio Settings", {"api_key": self.api_key, "api_secret": self.api_secret} + ) def set_application_credentials(self, twilio): - """Generate TwiML app credentials if not exist and update them. - """ + """Generate TwiML app credentials if not exist and update them.""" credentials = self.get_application(twilio) or self.create_application(twilio) self.twiml_sid = credentials.sid - frappe.db.set_value('Twilio Settings', 'Twilio Settings', 'twiml_sid', self.twiml_sid) + frappe.db.set_value("Twilio Settings", "Twilio Settings", "twiml_sid", self.twiml_sid) def create_api_key(self, twilio): - """Create API keys in twilio account. - """ + """Create API keys in twilio account.""" try: return twilio.new_keys.create(friendly_name=self.friendly_resource_name) except Exception: @@ -66,23 +62,21 @@ class TwilioSettings(Document): return get_public_url(url_path) def get_application(self, twilio, friendly_name=None): - """Get TwiML App from twilio account if exists. - """ + """Get TwiML App from twilio account if exists.""" friendly_name = friendly_name or self.friendly_resource_name applications = twilio.applications.list(friendly_name) return applications and applications[0] def create_application(self, twilio, friendly_name=None): - """Create TwilML App in twilio account. - """ + """Create TwilML App in twilio account.""" friendly_name = friendly_name or self.friendly_resource_name application = twilio.applications.create( - voice_method='POST', - voice_url=self.get_twilio_voice_url(), - friendly_name=friendly_name - ) + voice_method="POST", voice_url=self.get_twilio_voice_url(), friendly_name=friendly_name + ) return application -def get_public_url(path: str=None): + +def get_public_url(path: str | None = None): from frappe.utils import get_url - return get_url().split(":8", 1)[0] + path \ No newline at end of file + + return get_url().split(":8", 1)[0] + path diff --git a/crm/fcrm/doctype/twilio_agents/test_twilio_agents.py b/crm/fcrm/doctype/crm_twilio_settings/test_crm_twilio_settings.py similarity index 77% rename from crm/fcrm/doctype/twilio_agents/test_twilio_agents.py rename to crm/fcrm/doctype/crm_twilio_settings/test_crm_twilio_settings.py index 29ecc305..531076af 100644 --- a/crm/fcrm/doctype/twilio_agents/test_twilio_agents.py +++ b/crm/fcrm/doctype/crm_twilio_settings/test_crm_twilio_settings.py @@ -5,5 +5,5 @@ from frappe.tests import UnitTestCase -class TestTwilioAgents(UnitTestCase): +class TestCRMTwilioSettings(UnitTestCase): pass diff --git a/crm/fcrm/doctype/twilio_agents/twilio_agents.json b/crm/fcrm/doctype/twilio_agents/twilio_agents.json deleted file mode 100644 index 65251130..00000000 --- a/crm/fcrm/doctype/twilio_agents/twilio_agents.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "actions": [], - "allow_rename": 1, - "autoname": "field:user", - "creation": "2023-08-17 19:59:56.239729", - "default_view": "List", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "user", - "user_name", - "call_receiving_device", - "column_break_ljne", - "twilio_number" - ], - "fields": [ - { - "fieldname": "user", - "fieldtype": "Link", - "in_list_view": 1, - "label": "User", - "options": "User", - "unique": 1 - }, - { - "fieldname": "column_break_ljne", - "fieldtype": "Column Break" - }, - { - "fieldname": "twilio_number", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Twilio Number", - "options": "Phone" - }, - { - "fetch_from": "user.full_name", - "fieldname": "user_name", - "fieldtype": "Data", - "label": "User Name", - "read_only": 1 - }, - { - "default": "Computer", - "fieldname": "call_receiving_device", - "fieldtype": "Select", - "label": "Device", - "options": "Computer\nPhone" - } - ], - "index_web_pages_for_search": 1, - "links": [], - "modified": "2024-01-19 21:57:18.626669", - "modified_by": "Administrator", - "module": "FCRM", - "name": "Twilio Agents", - "naming_rule": "By fieldname", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "Sales Manager", - "share": 1, - "write": 1 - } - ], - "sort_field": "modified", - "sort_order": "DESC", - "states": [], - "track_changes": 1 -} \ No newline at end of file diff --git a/crm/fcrm/doctype/twilio_agents/twilio_agents.py b/crm/fcrm/doctype/twilio_agents/twilio_agents.py deleted file mode 100644 index fb660dd8..00000000 --- a/crm/fcrm/doctype/twilio_agents/twilio_agents.py +++ /dev/null @@ -1,9 +0,0 @@ -# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt - -# import frappe -from frappe.model.document import Document - - -class TwilioAgents(Document): - pass diff --git a/crm/fcrm/doctype/twilio_settings/__init__.py b/crm/fcrm/doctype/twilio_settings/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/crm/fcrm/doctype/twilio_settings/test_twilio_settings.py b/crm/fcrm/doctype/twilio_settings/test_twilio_settings.py deleted file mode 100644 index 21b03841..00000000 --- a/crm/fcrm/doctype/twilio_settings/test_twilio_settings.py +++ /dev/null @@ -1,9 +0,0 @@ -# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt - -# import frappe -from frappe.tests import UnitTestCase - - -class TestTwilioSettings(UnitTestCase): - pass diff --git a/crm/fcrm/doctype/twilio_settings/twilio_settings.js b/crm/fcrm/doctype/twilio_settings/twilio_settings.js deleted file mode 100644 index 9837c18d..00000000 --- a/crm/fcrm/doctype/twilio_settings/twilio_settings.js +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors -// For license information, please see license.txt - -// frappe.ui.form.on("Twilio Settings", { -// refresh(frm) { - -// }, -// }); From 97b67cfed5df2f40c119cd766fdb497f79cf6733 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Sun, 19 Jan 2025 16:13:18 +0530 Subject: [PATCH 46/61] patch: Rename Twilio Settings to CRM Twilio Settings Move Twilio Agent to Telephony Agent --- crm/patches.txt | 4 ++- .../move_twilio_agent_to_telephony_agent.py | 27 +++++++++++++++++++ ..._twilio_settings_to_crm_twilio_settings.py | 11 ++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 crm/patches/v1_0/move_twilio_agent_to_telephony_agent.py create mode 100644 crm/patches/v1_0/rename_twilio_settings_to_crm_twilio_settings.py diff --git a/crm/patches.txt b/crm/patches.txt index f964d13f..f8fbb4c6 100644 --- a/crm/patches.txt +++ b/crm/patches.txt @@ -2,6 +2,7 @@ # Patches added in this section will be executed before doctypes are migrated # Read docs to understand patches: https://frappeframework.com/docs/v14/user/en/database-migrations crm.patches.v1_0.move_crm_note_data_to_fcrm_note +crm.patches.v1_0.rename_twilio_settings_to_crm_twilio_settings [post_model_sync] # Patches added in this section will be executed after doctypes are migrated @@ -9,4 +10,5 @@ crm.patches.v1_0.create_email_template_custom_fields crm.patches.v1_0.create_default_fields_layout #10/12/2024 crm.patches.v1_0.create_default_sidebar_fields_layout crm.patches.v1_0.update_deal_quick_entry_layout -crm.patches.v1_0.update_layouts_to_new_format \ No newline at end of file +crm.patches.v1_0.update_layouts_to_new_format +crm.patches.v1_0.move_twilio_agent_to_telephony_agent \ No newline at end of file diff --git a/crm/patches/v1_0/move_twilio_agent_to_telephony_agent.py b/crm/patches/v1_0/move_twilio_agent_to_telephony_agent.py new file mode 100644 index 00000000..7049ab62 --- /dev/null +++ b/crm/patches/v1_0/move_twilio_agent_to_telephony_agent.py @@ -0,0 +1,27 @@ +import frappe + + +def execute(): + if not frappe.db.exists("DocType", "CRM Telephony Agent"): + frappe.reload_doctype("CRM Telephony Agent", force=True) + + if frappe.db.exists("DocType", "Twilio Agents") and frappe.db.count("Twilio Agents") == 0: + return + + agents = frappe.db.sql("SELECT * FROM `tabTwilio Agents`", as_dict=True) + if agents: + for agent in agents: + doc = frappe.get_doc( + { + "doctype": "CRM Telephony Agent", + "creation": agent.get("creation"), + "modified": agent.get("modified"), + "modified_by": agent.get("modified_by"), + "owner": agent.get("owner"), + "user": agent.get("user"), + "twilio_number": agent.get("twilio_number"), + "user_name": agent.get("user_name"), + "twilio": True, + } + ) + doc.db_insert() diff --git a/crm/patches/v1_0/rename_twilio_settings_to_crm_twilio_settings.py b/crm/patches/v1_0/rename_twilio_settings_to_crm_twilio_settings.py new file mode 100644 index 00000000..0a12973d --- /dev/null +++ b/crm/patches/v1_0/rename_twilio_settings_to_crm_twilio_settings.py @@ -0,0 +1,11 @@ +import frappe +from frappe.model.rename_doc import rename_doc + + +def execute(): + if frappe.db.table_exists("Twilio Settings"): + frappe.flags.ignore_route_conflict_validation = True + rename_doc("DocType", "Twilio Settings", "CRM Twilio Settings") + frappe.flags.ignore_route_conflict_validation = False + + frappe.reload_doctype("CRM Twilio Settings", force=True) From e7c89fdc2a85da9d67d028d79c988b1ae0147abf Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Sun, 19 Jan 2025 16:33:02 +0530 Subject: [PATCH 47/61] fix: rename Twilio Settings to CRM Twilio Settings in code --- .../doctype/crm_twilio_settings/crm_twilio_settings.py | 6 ++++-- crm/integrations/api.py | 2 +- crm/integrations/twilio/api.py | 2 +- crm/integrations/twilio/twilio_handler.py | 8 ++++---- frontend/src/components/Settings/TelephonySettings.vue | 10 +++++----- 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/crm/fcrm/doctype/crm_twilio_settings/crm_twilio_settings.py b/crm/fcrm/doctype/crm_twilio_settings/crm_twilio_settings.py index 811e50ea..f2737c6c 100644 --- a/crm/fcrm/doctype/crm_twilio_settings/crm_twilio_settings.py +++ b/crm/fcrm/doctype/crm_twilio_settings/crm_twilio_settings.py @@ -40,14 +40,16 @@ class CRMTwilioSettings(Document): self.api_key = new_key.sid self.api_secret = new_key.secret frappe.db.set_value( - "Twilio Settings", "Twilio Settings", {"api_key": self.api_key, "api_secret": self.api_secret} + "CRM Twilio Settings", + "CRM Twilio Settings", + {"api_key": self.api_key, "api_secret": self.api_secret}, ) def set_application_credentials(self, twilio): """Generate TwiML app credentials if not exist and update them.""" credentials = self.get_application(twilio) or self.create_application(twilio) self.twiml_sid = credentials.sid - frappe.db.set_value("Twilio Settings", "Twilio Settings", "twiml_sid", self.twiml_sid) + frappe.db.set_value("CRM Twilio Settings", "CRM Twilio Settings", "twiml_sid", self.twiml_sid) def create_api_key(self, twilio): """Create API keys in twilio account.""" diff --git a/crm/integrations/api.py b/crm/integrations/api.py index f2f55443..7bb15f89 100644 --- a/crm/integrations/api.py +++ b/crm/integrations/api.py @@ -7,7 +7,7 @@ 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") + twilio_enabled = frappe.db.get_single_value("CRM Twilio Settings", "enabled") exotel_enabled = frappe.db.get_single_value("CRM Exotel Settings", "enabled") return { diff --git a/crm/integrations/twilio/api.py b/crm/integrations/twilio/api.py index e78e0214..0df049ae 100644 --- a/crm/integrations/twilio/api.py +++ b/crm/integrations/twilio/api.py @@ -11,7 +11,7 @@ from .twilio_handler import IncomingCall, Twilio, TwilioCallDetails @frappe.whitelist() def is_enabled(): - return frappe.db.get_single_value("Twilio Settings", "enabled") + return frappe.db.get_single_value("CRM Twilio Settings", "enabled") @frappe.whitelist() diff --git a/crm/integrations/twilio/twilio_handler.py b/crm/integrations/twilio/twilio_handler.py index b24e0e9d..2d6b7df1 100644 --- a/crm/integrations/twilio/twilio_handler.py +++ b/crm/integrations/twilio/twilio_handler.py @@ -14,7 +14,7 @@ class Twilio: def __init__(self, settings): """ - :param settings: `Twilio Settings` doctype + :param settings: `CRM Twilio Settings` doctype """ self.settings = settings self.account_sid = settings.account_sid @@ -26,7 +26,7 @@ class Twilio: @classmethod def connect(self): """Make a twilio connection.""" - settings = frappe.get_doc("Twilio Settings") + settings = frappe.get_doc("CRM Twilio Settings") if not (settings and settings.enabled): return return Twilio(settings=settings) @@ -114,11 +114,11 @@ class Twilio: @classmethod def get_twilio_client(self): - twilio_settings = frappe.get_doc("Twilio Settings") + twilio_settings = frappe.get_doc("CRM 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("CRM Twilio Settings", "CRM Twilio Settings", "auth_token") client = TwilioClient(twilio_settings.account_sid, auth_token) return client diff --git a/frontend/src/components/Settings/TelephonySettings.vue b/frontend/src/components/Settings/TelephonySettings.vue index a4fb0555..b7df8c35 100644 --- a/frontend/src/components/Settings/TelephonySettings.vue +++ b/frontend/src/components/Settings/TelephonySettings.vue @@ -38,7 +38,7 @@ v-if="twilio?.doc && twilioTabs" :tabs="twilioTabs" :data="twilio.doc" - doctype="Twilio Settings" + doctype="CRM Twilio Settings" />
@@ -94,9 +94,9 @@ const { isManager, isAgent } = usersStore() const twilioFields = createResource({ url: 'crm.api.doc.get_fields', - cache: ['fields', 'Twilio Settings'], + cache: ['fields', 'CRM Twilio Settings'], params: { - doctype: 'Twilio Settings', + doctype: 'CRM Twilio Settings', allow_all_fieldtypes: true, }, auto: true, @@ -113,8 +113,8 @@ const exotelFields = createResource({ }) const twilio = createDocumentResource({ - doctype: 'Twilio Settings', - name: 'Twilio Settings', + doctype: 'CRM Twilio Settings', + name: 'CRM Twilio Settings', fields: ['*'], auto: true, setValue: { From dbaaed7ba3e4a7ff59ae4d4bc4f602e0ecf7eae9 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Sun, 19 Jan 2025 16:58:30 +0530 Subject: [PATCH 48/61] fix: use db.exists instead of db.table_exists --- .../v1_0/rename_twilio_settings_to_crm_twilio_settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crm/patches/v1_0/rename_twilio_settings_to_crm_twilio_settings.py b/crm/patches/v1_0/rename_twilio_settings_to_crm_twilio_settings.py index 0a12973d..8b38d770 100644 --- a/crm/patches/v1_0/rename_twilio_settings_to_crm_twilio_settings.py +++ b/crm/patches/v1_0/rename_twilio_settings_to_crm_twilio_settings.py @@ -3,7 +3,7 @@ from frappe.model.rename_doc import rename_doc def execute(): - if frappe.db.table_exists("Twilio Settings"): + if frappe.db.exists("DocType", "Twilio Settings"): frappe.flags.ignore_route_conflict_validation = True rename_doc("DocType", "Twilio Settings", "CRM Twilio Settings") frappe.flags.ignore_route_conflict_validation = False From 126d59cfee8696143d1527361355e1d139911343 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Sun, 19 Jan 2025 17:53:09 +0530 Subject: [PATCH 49/61] fix: update password encryption keys in Auth table --- .../rename_twilio_settings_to_crm_twilio_settings.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crm/patches/v1_0/rename_twilio_settings_to_crm_twilio_settings.py b/crm/patches/v1_0/rename_twilio_settings_to_crm_twilio_settings.py index 8b38d770..34976484 100644 --- a/crm/patches/v1_0/rename_twilio_settings_to_crm_twilio_settings.py +++ b/crm/patches/v1_0/rename_twilio_settings_to_crm_twilio_settings.py @@ -9,3 +9,12 @@ def execute(): frappe.flags.ignore_route_conflict_validation = False frappe.reload_doctype("CRM Twilio Settings", force=True) + + if frappe.db.exists("__Auth", {"doctype": "Twilio Settings"}): + Auth = frappe.qb.DocType("__Auth") + result = frappe.qb.from_(Auth).select("*").where(Auth.doctype == "Twilio Settings").run(as_dict=True) + + for row in result: + frappe.qb.into(Auth).insert( + "CRM Twilio Settings", "CRM Twilio Settings", row.fieldname, row.password, row.encrypted + ).run() From c6c0ef852fb9088e3b6ee3feb48b97f544ac0080 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Sun, 19 Jan 2025 22:12:24 +0530 Subject: [PATCH 50/61] fix: link call log to lead if lead is created from call log --- crm/fcrm/doctype/crm_call_log/crm_call_log.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) 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 880413e1..6560aa8e 100644 --- a/crm/fcrm/doctype/crm_call_log/crm_call_log.py +++ b/crm/fcrm/doctype/crm_call_log/crm_call_log.py @@ -197,17 +197,9 @@ 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}, - ) - - if call_log.get("note"): - frappe.db.set_value( - "FCRM Note", - call_log.get("note"), - {"reference_doctype": "CRM Lead", "reference_docname": lead.name}, - ) + # link call log with lead + call_log = frappe.get_doc("CRM Call Log", call_log.get("name")) + call_log.link_with_reference_doc("CRM Lead", lead.name) + call_log.save(ignore_permissions=True) return lead.name From 9c800417bb969f6ac5df0fe39faed39cd120fee0 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Sun, 19 Jan 2025 22:16:51 +0530 Subject: [PATCH 51/61] fix: return full_name for lead --- crm/integrations/api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/crm/integrations/api.py b/crm/integrations/api.py index 7bb15f89..b440e629 100644 --- a/crm/integrations/api.py +++ b/crm/integrations/api.py @@ -161,6 +161,7 @@ def get_contact(phone_number, exact_match=False): for lead in leads: if are_same_phone_number(lead.mobile_no, phone_number, validate=not exact_match): lead["lead"] = lead.name + lead["full_name"] = lead.lead_name return lead return {"mobile_no": phone_number} From 8e45656c8603531fcd61312a710261b99bba8dd1 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Sun, 19 Jan 2025 22:25:23 +0530 Subject: [PATCH 52/61] fix: added webhook verify token for security --- .../crm_exotel_settings.json | 15 +++++++++++- crm/integrations/exotel/handler.py | 23 +++++++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.json b/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.json index 98382626..a4fc1bf6 100644 --- a/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.json +++ b/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.json @@ -10,6 +10,8 @@ "record_call", "section_break_kfez", "account_sid", + "column_break_qwfn", + "webhook_verify_token", "section_break_iuct", "api_key", "column_break_hyen", @@ -70,12 +72,23 @@ "fieldname": "record_call", "fieldtype": "Check", "label": "Record Call" + }, + { + "fieldname": "column_break_qwfn", + "fieldtype": "Column Break" + }, + { + "depends_on": "enabled", + "fieldname": "webhook_verify_token", + "fieldtype": "Data", + "label": "Webhook Verify Token", + "mandatory_depends_on": "enabled" } ], "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-01-15 19:31:00.310049", + "modified": "2025-01-19 22:19:20.713970", "modified_by": "Administrator", "module": "FCRM", "name": "CRM Exotel Settings", diff --git a/crm/integrations/exotel/handler.py b/crm/integrations/exotel/handler.py index 7d3301a8..4ffc00b4 100644 --- a/crm/integrations/exotel/handler.py +++ b/crm/integrations/exotel/handler.py @@ -1,5 +1,3 @@ -import json - import bleach import frappe import requests @@ -8,10 +6,20 @@ from frappe.integrations.utils import create_request_log from crm.integrations.api import get_contact_by_phone_number +# Endpoints for webhook + +# Incoming Call: +# /api/method/crm.integrations.exotel.handler.handle_request?key= + +# Exotel Reference: +# https://developer.exotel.com/api/ +# https://support.exotel.com/support/solutions/articles/48283-working-with-passthru-applet + # Incoming Call @frappe.whitelist(allow_guest=True) def handle_request(**kwargs): + validate_request() if not is_integration_enabled(): return @@ -149,6 +157,17 @@ def get_exotel_settings(): return frappe.get_single("CRM Exotel Settings") +def validate_request(): + # workaround security since exotel does not support request signature + # /api/method/?key= + webhook_verify_token = frappe.db.get_single_value("CRM Exotel Settings", "webhook_verify_token") + key = frappe.request.args.get('key') + is_valid = key and key == webhook_verify_token + + if not is_valid: + frappe.throw(_("Unauthorized request"), exc=frappe.PermissionError) + + @frappe.whitelist() def is_integration_enabled(): return frappe.db.get_single_value("CRM Exotel Settings", "enabled", True) From 8a31769ec6cb836549f1c37163f51a3601551d09 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Sun, 19 Jan 2025 22:41:27 +0530 Subject: [PATCH 53/61] fix: do not cache get contact from phone number api --- frontend/src/components/Telephony/ExotelCallUI.vue | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/Telephony/ExotelCallUI.vue b/frontend/src/components/Telephony/ExotelCallUI.vue index 3d65e02e..1a5888fb 100644 --- a/frontend/src/components/Telephony/ExotelCallUI.vue +++ b/frontend/src/components/Telephony/ExotelCallUI.vue @@ -16,7 +16,7 @@ />
- {{ contact?.full_name ?? phoneNumber }} + {{ contact?.full_name ?? contact?.mobile_no }} ·
{{ counterUp?.updatedTime }} @@ -68,11 +68,11 @@ class="flex flex-col gap-1 text-base leading-4 overflow-hidden" >
- {{ contact?.full_name ?? phoneNumber }} + {{ contact?.full_name ?? contact?.mobile_no }}
- {{ phoneNumber }} + {{ contact?.mobile_no }} · {{ counterUp?.updatedTime }}
@@ -170,11 +170,11 @@ {{ contact.full_name }}
- {{ phoneNumber }} + {{ contact.mobile_no }}
- {{ phoneNumber }} + {{ contact.mobile_no }}
@@ -296,7 +296,6 @@ const getContact = createResource({ phone_number: phoneNumber.value, } }, - cache: ['contact', phoneNumber.value], onSuccess(data) { contact.value = data }, From 840bb6b76d2a0a1f846b1a9d4778d2f514f022ed Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Sun, 19 Jan 2025 22:50:21 +0530 Subject: [PATCH 54/61] fix: get call details while making call --- crm/integrations/exotel/handler.py | 6 ++++-- frontend/src/components/Telephony/ExotelCallUI.vue | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/crm/integrations/exotel/handler.py b/crm/integrations/exotel/handler.py index 4ffc00b4..555b903b 100644 --- a/crm/integrations/exotel/handler.py +++ b/crm/integrations/exotel/handler.py @@ -125,7 +125,9 @@ def make_a_call(to_number, from_number=None, caller_id=None): agent=frappe.session.user, ) - return response.json() + call_details = response.json().get("Call", {}) + call_details["CallSid"] = call_details.get("Sid", "") + return call_details def get_exotel_endpoint(action=None): @@ -161,7 +163,7 @@ def validate_request(): # workaround security since exotel does not support request signature # /api/method/?key= webhook_verify_token = frappe.db.get_single_value("CRM Exotel Settings", "webhook_verify_token") - key = frappe.request.args.get('key') + key = frappe.request.args.get("key") is_valid = key and key == webhook_verify_token if not is_valid: diff --git a/frontend/src/components/Telephony/ExotelCallUI.vue b/frontend/src/components/Telephony/ExotelCallUI.vue index 1a5888fb..c1e28fa9 100644 --- a/frontend/src/components/Telephony/ExotelCallUI.vue +++ b/frontend/src/components/Telephony/ExotelCallUI.vue @@ -399,7 +399,10 @@ function makeOutgoingCall(number) { url: 'crm.integrations.exotel.handler.make_a_call', params: { to_number: phoneNumber.value }, auto: true, - onSuccess() { + onSuccess(callDetails) { + callData.value = callDetails + console.log(callDetails) + callStatus.value = 'Calling...' showCallPopup.value = true showSmallCallPopup.value = false From 2388dffbe0b61b8907bd57569f95178c29bbecd0 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Sun, 19 Jan 2025 23:10:16 +0530 Subject: [PATCH 55/61] fix: show call note directly --- frontend/src/components/Modals/CallLogModal.vue | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/Modals/CallLogModal.vue b/frontend/src/components/Modals/CallLogModal.vue index f2c46ed4..fb8ec499 100644 --- a/frontend/src/components/Modals/CallLogModal.vue +++ b/frontend/src/components/Modals/CallLogModal.vue @@ -92,7 +92,7 @@ /> - +