Merge branch 'develop' of https://github.com/frappe/erpnext into move-exotel-to-separate-app
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 e69de29..0000000
--- a/erpnext/erpnext_integrations/doctype/exotel_settings/__init__.py
+++ /dev/null
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 72f47b5..0000000
--- a/erpnext/erpnext_integrations/doctype/exotel_settings/exotel_settings.json
+++ /dev/null
@@ -1,61 +0,0 @@
-{
- "creation": "2019-05-21 07:41:53.536536",
- "doctype": "DocType",
- "engine": "InnoDB",
- "field_order": [
-  "enabled",
-  "section_break_2",
-  "account_sid",
-  "api_key",
-  "api_token"
- ],
- "fields": [
-  {
-   "fieldname": "enabled",
-   "fieldtype": "Check",
-   "label": "Enabled"
-  },
-  {
-   "depends_on": "enabled",
-   "fieldname": "section_break_2",
-   "fieldtype": "Section Break"
-  },
-  {
-   "fieldname": "account_sid",
-   "fieldtype": "Data",
-   "label": "Account SID"
-  },
-  {
-   "fieldname": "api_token",
-   "fieldtype": "Data",
-   "label": "API Token"
-  },
-  {
-   "fieldname": "api_key",
-   "fieldtype": "Data",
-   "label": "API Key"
-  }
- ],
- "issingle": 1,
- "modified": "2019-05-22 06:25:18.026997",
- "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",
- "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 4879cb5..0000000
--- 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 fd9f74e..0000000
--- a/erpnext/erpnext_integrations/exotel_integration.py
+++ /dev/null
@@ -1,131 +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.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 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"))
-	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):
-	endpoint = get_exotel_endpoint("Calls/connect.json?details=true")
-	response = requests.post(
-		endpoint, data={"From": from_number, "To": to_number, "CallerId": caller_id}
-	)
-
-	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/patches/v13_0/exotel_integration_deprecation_warning.py b/erpnext/patches/v13_0/exotel_integration_deprecation_warning.py
new file mode 100644
index 0000000..6e84ba9
--- /dev/null
+++ b/erpnext/patches/v13_0/exotel_integration_deprecation_warning.py
@@ -0,0 +1,10 @@
+import click
+
+
+def execute():
+
+	click.secho(
+		"Exotel integration is moved to a separate app and will be removed from ERPNext in version-14.\n"
+		"Please install the app to continue using the integration: https://github.com/frappe/exotel_integration",
+		fg="yellow",
+	)
diff --git a/erpnext/telephony/doctype/call_log/call_log.py b/erpnext/telephony/doctype/call_log/call_log.py
index 7725e71..1d6839c 100644
--- a/erpnext/telephony/doctype/call_log/call_log.py
+++ b/erpnext/telephony/doctype/call_log/call_log.py
@@ -24,12 +24,10 @@
 		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 @@
 		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 @@
 						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 @@
 		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 3ad2575..0000000
--- 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 f5cca72..0000000
--- 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_value("Exotel Settings", "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