diff --git a/erpnext/communication/doctype/communication_medium/communication_medium.json b/erpnext/communication/doctype/communication_medium/communication_medium.json index 1e1fe3bf49..b6b9c7e434 100644 --- a/erpnext/communication/doctype/communication_medium/communication_medium.json +++ b/erpnext/communication/doctype/communication_medium/communication_medium.json @@ -61,7 +61,7 @@ "fieldname": "communication_channel", "fieldtype": "Select", "label": "Communication Channel", - "options": "\nExotel" + "options": "" } ], "links": [], diff --git a/erpnext/erpnext_integrations/doctype/exotel_settings/__init__.py b/erpnext/erpnext_integrations/doctype/exotel_settings/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/erpnext/erpnext_integrations/doctype/exotel_settings/exotel_settings.json b/erpnext/erpnext_integrations/doctype/exotel_settings/exotel_settings.json deleted file mode 100644 index 0d42ca8c85..0000000000 --- a/erpnext/erpnext_integrations/doctype/exotel_settings/exotel_settings.json +++ /dev/null @@ -1,89 +0,0 @@ -{ - "actions": [], - "creation": "2019-05-21 07:41:53.536536", - "doctype": "DocType", - "engine": "InnoDB", - "field_order": [ - "enabled", - "section_break_2", - "account_sid", - "api_key", - "api_token", - "section_break_6", - "map_custom_field_to_doctype", - "target_doctype" - ], - "fields": [ - { - "default": "0", - "fieldname": "enabled", - "fieldtype": "Check", - "label": "Enabled" - }, - { - "depends_on": "enabled", - "fieldname": "section_break_2", - "fieldtype": "Section Break", - "label": "Credentials" - }, - { - "fieldname": "account_sid", - "fieldtype": "Data", - "label": "Account SID" - }, - { - "fieldname": "api_token", - "fieldtype": "Data", - "label": "API Token" - }, - { - "fieldname": "api_key", - "fieldtype": "Data", - "label": "API Key" - }, - { - "depends_on": "enabled", - "fieldname": "section_break_6", - "fieldtype": "Section Break", - "label": "Custom Field" - }, - { - "default": "0", - "fieldname": "map_custom_field_to_doctype", - "fieldtype": "Check", - "label": "Map Custom Field to DocType" - }, - { - "depends_on": "map_custom_field_to_doctype", - "fieldname": "target_doctype", - "fieldtype": "Link", - "label": "Target DocType", - "mandatory_depends_on": "map_custom_field_to_doctype", - "options": "DocType" - } - ], - "issingle": 1, - "links": [], - "modified": "2022-12-14 17:24:50.176107", - "modified_by": "Administrator", - "module": "ERPNext Integrations", - "name": "Exotel Settings", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "quick_entry": 1, - "sort_field": "modified", - "sort_order": "ASC", - "states": [], - "track_changes": 1 -} \ No newline at end of file diff --git a/erpnext/erpnext_integrations/doctype/exotel_settings/exotel_settings.py b/erpnext/erpnext_integrations/doctype/exotel_settings/exotel_settings.py deleted file mode 100644 index 4879cb5623..0000000000 --- a/erpnext/erpnext_integrations/doctype/exotel_settings/exotel_settings.py +++ /dev/null @@ -1,22 +0,0 @@ -# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt - - -import frappe -import requests -from frappe import _ -from frappe.model.document import Document - - -class ExotelSettings(Document): - def validate(self): - self.verify_credentials() - - def verify_credentials(self): - if self.enabled: - response = requests.get( - "https://api.exotel.com/v1/Accounts/{sid}".format(sid=self.account_sid), - auth=(self.api_key, self.api_token), - ) - if response.status_code != 200: - frappe.throw(_("Invalid credentials")) diff --git a/erpnext/erpnext_integrations/exotel_integration.py b/erpnext/erpnext_integrations/exotel_integration.py deleted file mode 100644 index 0d40667e32..0000000000 --- a/erpnext/erpnext_integrations/exotel_integration.py +++ /dev/null @@ -1,151 +0,0 @@ -import frappe -import requests - -# api/method/erpnext.erpnext_integrations.exotel_integration.handle_incoming_call -# api/method/erpnext.erpnext_integrations.exotel_integration.handle_end_call -# api/method/erpnext.erpnext_integrations.exotel_integration.handle_missed_call - - -@frappe.whitelist(allow_guest=True) -def handle_incoming_call(**kwargs): - try: - exotel_settings = get_exotel_settings() - if not exotel_settings.enabled: - return - - call_payload = kwargs - status = call_payload.get("Status") - if status == "free": - return - - call_log = get_call_log(call_payload) - if not call_log: - create_call_log(call_payload) - else: - update_call_log(call_payload, call_log=call_log) - except Exception as e: - frappe.db.rollback() - exotel_settings.log_error("Error in Exotel incoming call") - frappe.db.commit() - - -@frappe.whitelist(allow_guest=True) -def handle_end_call(**kwargs): - update_call_log(kwargs, "Completed") - - -@frappe.whitelist(allow_guest=True) -def handle_missed_call(**kwargs): - status = "" - call_type = kwargs.get("CallType") - dial_call_status = kwargs.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" - - update_call_log(kwargs, status) - - -def update_call_log(call_payload, status="Ringing", call_log=None): - call_log = call_log or get_call_log(call_payload) - - # for a new sid, call_log and get_call_log will be empty so create a new log - if not call_log: - call_log = create_call_log(call_payload) - if call_log: - call_log.status = status - call_log.to = call_payload.get("DialWhomNumber") - call_log.duration = call_payload.get("DialCallDuration") or 0 - call_log.recording_url = call_payload.get("RecordingUrl") - 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("Call Log", call_log_id): - return frappe.get_doc("Call Log", call_log_id) - - -def map_custom_field(call_payload, call_log): - field_value = call_payload.get("CustomField") - - if not field_value: - return call_log - - settings = get_exotel_settings() - target_doctype = settings.target_doctype - mapping_enabled = settings.map_custom_field_to_doctype - - if not mapping_enabled or not target_doctype: - return call_log - - call_log.append("links", {"link_doctype": target_doctype, "link_name": field_value}) - - return call_log - - -def create_call_log(call_payload): - call_log = frappe.new_doc("Call Log") - call_log.id = call_payload.get("CallSid") - call_log.to = call_payload.get("DialWhomNumber") - call_log.medium = call_payload.get("To") - call_log.status = "Ringing" - setattr(call_log, "from", call_payload.get("CallFrom")) - map_custom_field(call_payload, call_log) - call_log.save(ignore_permissions=True) - frappe.db.commit() - return call_log - - -@frappe.whitelist() -def get_call_status(call_id): - endpoint = get_exotel_endpoint("Calls/{call_id}.json".format(call_id=call_id)) - response = requests.get(endpoint) - status = response.json().get("Call", {}).get("Status") - return status - - -@frappe.whitelist() -def make_a_call(from_number, to_number, caller_id, **kwargs): - endpoint = get_exotel_endpoint("Calls/connect.json?details=true") - response = requests.post( - endpoint, data={"From": from_number, "To": to_number, "CallerId": caller_id, **kwargs} - ) - - return response.json() - - -def get_exotel_settings(): - return frappe.get_single("Exotel Settings") - - -def whitelist_numbers(numbers, caller_id): - endpoint = get_exotel_endpoint("CustomerWhitelist") - response = requests.post( - endpoint, - data={ - "VirtualNumber": caller_id, - "Number": numbers, - }, - ) - - return response - - -def get_all_exophones(): - endpoint = get_exotel_endpoint("IncomingPhoneNumbers") - response = requests.post(endpoint) - return response - - -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.api_token, sid=settings.account_sid, action=action - ) diff --git a/erpnext/erpnext_integrations/workspace/erpnext_integrations/erpnext_integrations.json b/erpnext/erpnext_integrations/workspace/erpnext_integrations/erpnext_integrations.json index ccc46b7a22..5c4be6ffaa 100644 --- a/erpnext/erpnext_integrations/workspace/erpnext_integrations/erpnext_integrations.json +++ b/erpnext/erpnext_integrations/workspace/erpnext_integrations/erpnext_integrations.json @@ -230,17 +230,6 @@ "onboard": 0, "type": "Card Break" }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "Exotel Settings", - "link_count": 0, - "link_to": "Exotel Settings", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, { "hidden": 0, "is_query_report": 0, @@ -252,7 +241,7 @@ "type": "Link" } ], - "modified": "2023-05-24 14:47:25.984717", + "modified": "2023-05-24 14:47:26.984717", "modified_by": "Administrator", "module": "ERPNext Integrations", "name": "ERPNext Integrations", diff --git a/erpnext/patches.txt b/erpnext/patches.txt index f9d9ebbdb3..6fa4b5a85a 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -333,4 +333,5 @@ execute:frappe.delete_doc('DocType', 'Cash Flow Mapping Accounts', ignore_missin erpnext.patches.v14_0.cleanup_workspaces erpnext.patches.v15_0.remove_loan_management_module #2023-07-03 erpnext.patches.v14_0.set_report_in_process_SOA -erpnext.buying.doctype.supplier.patches.migrate_supplier_portal_users \ No newline at end of file +erpnext.buying.doctype.supplier.patches.migrate_supplier_portal_users +erpnext.patches.v15_0.remove_exotel_integration diff --git a/erpnext/patches/v15_0/remove_exotel_integration.py b/erpnext/patches/v15_0/remove_exotel_integration.py new file mode 100644 index 0000000000..a37773f337 --- /dev/null +++ b/erpnext/patches/v15_0/remove_exotel_integration.py @@ -0,0 +1,37 @@ +from contextlib import suppress + +import click +import frappe +from frappe import _ +from frappe.desk.doctype.notification_log.notification_log import make_notification_logs +from frappe.utils.user import get_system_managers + +SETTINGS_DOCTYPE = "Exotel Settings" + + +def execute(): + if "exotel_integration" in frappe.get_installed_apps(): + return + + with suppress(Exception): + exotel = frappe.get_doc(SETTINGS_DOCTYPE) + if exotel.enabled: + notify_existing_users() + + frappe.delete_doc("DocType", SETTINGS_DOCTYPE) + + +def notify_existing_users(): + click.secho( + "Exotel integration is moved to a separate app and will be removed from ERPNext in version-15.\n" + "Please install the app to continue using the integration: https://github.com/frappe/exotel_integration", + fg="yellow", + ) + + notification = { + "subject": _( + "WARNING: Exotel app has been separated from ERPNext, please install the app to continue using Exotel integration." + ), + "type": "Alert", + } + make_notification_logs(notification, get_system_managers(only_name=True)) diff --git a/erpnext/telephony/doctype/call_log/call_log.py b/erpnext/telephony/doctype/call_log/call_log.py index 7725e71f19..1d6839c1e6 100644 --- a/erpnext/telephony/doctype/call_log/call_log.py +++ b/erpnext/telephony/doctype/call_log/call_log.py @@ -24,12 +24,10 @@ class CallLog(Document): lead_number = self.get("from") if self.is_incoming_call() else self.get("to") lead_number = strip_number(lead_number) - contact = get_contact_with_phone_number(strip_number(lead_number)) - if contact: + if contact := get_contact_with_phone_number(strip_number(lead_number)): self.add_link(link_type="Contact", link_name=contact) - lead = get_lead_with_phone_number(lead_number) - if lead: + if lead := get_lead_with_phone_number(lead_number): self.add_link(link_type="Lead", link_name=lead) # Add Employee Name @@ -70,28 +68,30 @@ class CallLog(Document): self.append("links", {"link_doctype": link_type, "link_name": link_name}) def trigger_call_popup(self): - if self.is_incoming_call(): - scheduled_employees = get_scheduled_employees_for_popup(self.medium) - employees = get_employees_with_number(self.to) - employee_emails = [employee.get("user_id") for employee in employees] + if not self.is_incoming_call(): + return - # check if employees with matched number are scheduled to receive popup - emails = set(scheduled_employees).intersection(employee_emails) + scheduled_employees = get_scheduled_employees_for_popup(self.medium) + employees = get_employees_with_number(self.to) + employee_emails = [employee.get("user_id") for employee in employees] - if frappe.conf.developer_mode: - self.add_comment( - text=f""" + # check if employees with matched number are scheduled to receive popup + emails = set(scheduled_employees).intersection(employee_emails) + + if frappe.conf.developer_mode: + self.add_comment( + text=f""" Scheduled Employees: {scheduled_employees} Matching Employee: {employee_emails} Show Popup To: {emails} """ - ) + ) - if employee_emails and not emails: - self.add_comment(text=_("No employee was scheduled for call popup")) + if employee_emails and not emails: + self.add_comment(text=_("No employee was scheduled for call popup")) - for email in emails: - frappe.publish_realtime("show_call_popup", self, user=email) + for email in emails: + frappe.publish_realtime("show_call_popup", self, user=email) def update_received_by(self): if employees := get_employees_with_number(self.get("to")): @@ -154,8 +154,8 @@ def link_existing_conversations(doc, state): ELSE 0 END )=0 - """, - dict(phone_number="%{}".format(number), docname=doc.name, doctype=doc.doctype), + """, + dict(phone_number=f"%{number}", docname=doc.name, doctype=doc.doctype), ) for log in logs: @@ -175,7 +175,7 @@ def get_linked_call_logs(doctype, docname): filters={"parenttype": "Call Log", "link_doctype": doctype, "link_name": docname}, ) - logs = set([log.parent for log in logs]) + logs = {log.parent for log in logs} logs = frappe.get_all("Call Log", fields=["*"], filters={"name": ["in", logs]}) diff --git a/erpnext/tests/exotel_test_data.py b/erpnext/tests/exotel_test_data.py deleted file mode 100644 index 3ad2575c23..0000000000 --- a/erpnext/tests/exotel_test_data.py +++ /dev/null @@ -1,122 +0,0 @@ -import frappe - -call_initiation_data = frappe._dict( - { - "CallSid": "23c162077629863c1a2d7f29263a162m", - "CallFrom": "09999999991", - "CallTo": "09999999980", - "Direction": "incoming", - "Created": "Wed, 23 Feb 2022 12:31:59", - "From": "09999999991", - "To": "09999999988", - "CurrentTime": "2022-02-23 12:32:02", - "DialWhomNumber": "09999999999", - "Status": "busy", - "EventType": "Dial", - "AgentEmail": "test_employee_exotel@company.com", - } -) - -call_end_data = frappe._dict( - { - "CallSid": "23c162077629863c1a2d7f29263a162m", - "CallFrom": "09999999991", - "CallTo": "09999999980", - "Direction": "incoming", - "ForwardedFrom": "null", - "Created": "Wed, 23 Feb 2022 12:31:59", - "DialCallDuration": "17", - "RecordingUrl": "https://s3-ap-southeast-1.amazonaws.com/random.mp3", - "StartTime": "2022-02-23 12:31:58", - "EndTime": "1970-01-01 05:30:00", - "DialCallStatus": "completed", - "CallType": "completed", - "DialWhomNumber": "09999999999", - "ProcessStatus": "null", - "flow_id": "228040", - "tenant_id": "67291", - "From": "09999999991", - "To": "09999999988", - "RecordingAvailableBy": "Wed, 23 Feb 2022 12:37:25", - "CurrentTime": "2022-02-23 12:32:25", - "OutgoingPhoneNumber": "09999999988", - "Legs": [ - { - "Number": "09999999999", - "Type": "single", - "OnCallDuration": "10", - "CallerId": "09999999980", - "CauseCode": "NORMAL_CLEARING", - "Cause": "16", - } - ], - } -) - -call_disconnected_data = frappe._dict( - { - "CallSid": "d96421addce69e24bdc7ce5880d1162l", - "CallFrom": "09999999991", - "CallTo": "09999999980", - "Direction": "incoming", - "ForwardedFrom": "null", - "Created": "Mon, 21 Feb 2022 15:58:12", - "DialCallDuration": "0", - "StartTime": "2022-02-21 15:58:12", - "EndTime": "1970-01-01 05:30:00", - "DialCallStatus": "canceled", - "CallType": "client-hangup", - "DialWhomNumber": "09999999999", - "ProcessStatus": "null", - "flow_id": "228040", - "tenant_id": "67291", - "From": "09999999991", - "To": "09999999988", - "CurrentTime": "2022-02-21 15:58:47", - "OutgoingPhoneNumber": "09999999988", - "Legs": [ - { - "Number": "09999999999", - "Type": "single", - "OnCallDuration": "0", - "CallerId": "09999999980", - "CauseCode": "RING_TIMEOUT", - "Cause": "1003", - } - ], - } -) - -call_not_answered_data = frappe._dict( - { - "CallSid": "fdb67a2b4b2d057b610a52ef43f81622", - "CallFrom": "09999999991", - "CallTo": "09999999980", - "Direction": "incoming", - "ForwardedFrom": "null", - "Created": "Mon, 21 Feb 2022 15:47:02", - "DialCallDuration": "0", - "StartTime": "2022-02-21 15:47:02", - "EndTime": "1970-01-01 05:30:00", - "DialCallStatus": "no-answer", - "CallType": "incomplete", - "DialWhomNumber": "09999999999", - "ProcessStatus": "null", - "flow_id": "228040", - "tenant_id": "67291", - "From": "09999999991", - "To": "09999999988", - "CurrentTime": "2022-02-21 15:47:40", - "OutgoingPhoneNumber": "09999999988", - "Legs": [ - { - "Number": "09999999999", - "Type": "single", - "OnCallDuration": "0", - "CallerId": "09999999980", - "CauseCode": "RING_TIMEOUT", - "Cause": "1003", - } - ], - } -) diff --git a/erpnext/tests/test_exotel.py b/erpnext/tests/test_exotel.py deleted file mode 100644 index 9b91414571..0000000000 --- a/erpnext/tests/test_exotel.py +++ /dev/null @@ -1,68 +0,0 @@ -import frappe -from frappe.contacts.doctype.contact.test_contact import create_contact -from frappe.tests.test_api import FrappeAPITestCase - -from erpnext.setup.doctype.employee.test_employee import make_employee - - -class TestExotel(FrappeAPITestCase): - @classmethod - def setUpClass(cls): - cls.CURRENT_DB_CONNECTION = frappe.db - cls.test_employee_name = make_employee( - user="test_employee_exotel@company.com", cell_number="9999999999" - ) - frappe.db.set_single_value("Exotel Settings", "enabled", 1) - phones = [{"phone": "+91 9999999991", "is_primary_phone": 0, "is_primary_mobile_no": 1}] - create_contact(name="Test Contact", salutation="Mr", phones=phones) - frappe.db.commit() - - def test_for_successful_call(self): - from .exotel_test_data import call_end_data, call_initiation_data - - api_method = "handle_incoming_call" - end_call_api_method = "handle_end_call" - - self.emulate_api_call_from_exotel(api_method, call_initiation_data) - self.emulate_api_call_from_exotel(end_call_api_method, call_end_data) - call_log = frappe.get_doc("Call Log", call_initiation_data.CallSid) - - self.assertEqual(call_log.get("from"), call_initiation_data.CallFrom) - self.assertEqual(call_log.get("to"), call_initiation_data.DialWhomNumber) - self.assertEqual(call_log.get("call_received_by"), self.test_employee_name) - self.assertEqual(call_log.get("status"), "Completed") - - def test_for_disconnected_call(self): - from .exotel_test_data import call_disconnected_data - - api_method = "handle_missed_call" - self.emulate_api_call_from_exotel(api_method, call_disconnected_data) - call_log = frappe.get_doc("Call Log", call_disconnected_data.CallSid) - self.assertEqual(call_log.get("from"), call_disconnected_data.CallFrom) - self.assertEqual(call_log.get("to"), call_disconnected_data.DialWhomNumber) - self.assertEqual(call_log.get("call_received_by"), self.test_employee_name) - self.assertEqual(call_log.get("status"), "Canceled") - - def test_for_call_not_answered(self): - from .exotel_test_data import call_not_answered_data - - api_method = "handle_missed_call" - self.emulate_api_call_from_exotel(api_method, call_not_answered_data) - call_log = frappe.get_doc("Call Log", call_not_answered_data.CallSid) - self.assertEqual(call_log.get("from"), call_not_answered_data.CallFrom) - self.assertEqual(call_log.get("to"), call_not_answered_data.DialWhomNumber) - self.assertEqual(call_log.get("call_received_by"), self.test_employee_name) - self.assertEqual(call_log.get("status"), "No Answer") - - def emulate_api_call_from_exotel(self, api_method, data): - self.post( - f"/api/method/erpnext.erpnext_integrations.exotel_integration.{api_method}", - data=frappe.as_json(data), - content_type="application/json", - ) - # restart db connection to get latest data - frappe.connect() - - @classmethod - def tearDownClass(cls): - frappe.db = cls.CURRENT_DB_CONNECTION