Merge branch 'develop' of github.com:frappe/erpnext into call-summary-dialog
diff --git a/erpnext/crm/doctype/utils.py b/erpnext/crm/doctype/utils.py
new file mode 100644
index 0000000..26cb298
--- /dev/null
+++ b/erpnext/crm/doctype/utils.py
@@ -0,0 +1,81 @@
+import frappe
+from frappe import _
+import json
+
+@frappe.whitelist()
+def get_document_with_phone_number(number):
+ # finds contacts and leads
+ if not number: return
+ number = number[-10:]
+ number_filter = {
+ 'phone': ['like', '%{}'.format(number)],
+ 'mobile_no': ['like', '%{}'.format(number)]
+ }
+ contacts = frappe.get_all('Contact', or_filters=number_filter, limit=1)
+
+ if contacts:
+ return frappe.get_doc('Contact', contacts[0].name)
+
+ leads = frappe.get_all('Lead', or_filters=number_filter, limit=1)
+
+ if leads:
+ return frappe.get_doc('Lead', leads[0].name)
+
+@frappe.whitelist()
+def get_last_interaction(number, reference_doc):
+ reference_doc = json.loads(reference_doc) if reference_doc else get_document_with_phone_number(number)
+
+ if not reference_doc: return
+
+ reference_doc = frappe._dict(reference_doc)
+
+ last_communication = {}
+ last_issue = {}
+ if reference_doc.doctype == 'Contact':
+ customer_name = ''
+ query_condition = ''
+ for link in reference_doc.links:
+ link = frappe._dict(link)
+ if link.link_doctype == 'Customer':
+ customer_name = link.link_name
+ query_condition += "(`reference_doctype`='{}' AND `reference_name`='{}') OR".format(link.link_doctype, link.link_name)
+
+ if query_condition:
+ query_condition = query_condition[:-2]
+
+ last_communication = frappe.db.sql("""
+ SELECT `name`, `content`
+ FROM `tabCommunication`
+ WHERE {}
+ ORDER BY `modified`
+ LIMIT 1
+ """.format(query_condition))
+
+ if customer_name:
+ last_issue = frappe.get_all('Issue', {
+ 'customer': customer_name
+ }, ['name', 'subject', 'customer'], limit=1)
+
+ elif reference_doc.doctype == 'Lead':
+ last_communication = frappe.get_all('Communication', {
+ 'reference_doctype': reference_doc.doctype,
+ 'reference_name': reference_doc.name,
+ 'sent_or_received': 'Received'
+ }, fields=['name', 'content'], limit=1)
+
+ return {
+ 'last_communication': last_communication[0] if last_communication else None,
+ 'last_issue': last_issue[0] if last_issue else None
+ }
+
+@frappe.whitelist()
+def add_call_summary(docname, summary):
+ communication = frappe.get_doc('Communication', docname)
+ content = _('Call Summary by {0}: {1}').format(
+ frappe.utils.get_fullname(frappe.session.user), summary)
+ if not communication.content:
+ communication.content = content
+ else:
+ communication.content += '\n' + content
+ communication.save(ignore_permissions=True)
+
diff --git a/erpnext/erpnext_integrations/doctype/exotel_settings/__init__.py b/erpnext/erpnext_integrations/doctype/exotel_settings/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/erpnext/erpnext_integrations/doctype/exotel_settings/__init__.py
diff --git a/erpnext/erpnext_integrations/doctype/exotel_settings/exotel_settings.js b/erpnext/erpnext_integrations/doctype/exotel_settings/exotel_settings.js
new file mode 100644
index 0000000..bfed491
--- /dev/null
+++ b/erpnext/erpnext_integrations/doctype/exotel_settings/exotel_settings.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('Exotel Settings', {
+ // refresh: function(frm) {
+
+ // }
+});
diff --git a/erpnext/erpnext_integrations/doctype/exotel_settings/exotel_settings.json b/erpnext/erpnext_integrations/doctype/exotel_settings/exotel_settings.json
new file mode 100644
index 0000000..72f47b5
--- /dev/null
+++ b/erpnext/erpnext_integrations/doctype/exotel_settings/exotel_settings.json
@@ -0,0 +1,61 @@
+{
+ "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
new file mode 100644
index 0000000..77de84c
--- /dev/null
+++ b/erpnext/erpnext_integrations/doctype/exotel_settings/exotel_settings.py
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+from __future__ import unicode_literals
+# import frappe
+from frappe.model.document import Document
+import requests
+import frappe
+from frappe import _
+
+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"))
\ No newline at end of file
diff --git a/erpnext/erpnext_integrations/doctype/exotel_settings/test_exotel_settings.py b/erpnext/erpnext_integrations/doctype/exotel_settings/test_exotel_settings.py
new file mode 100644
index 0000000..5d85615
--- /dev/null
+++ b/erpnext/erpnext_integrations/doctype/exotel_settings/test_exotel_settings.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+from __future__ import unicode_literals
+
+# import frappe
+import unittest
+
+class TestExotelSettings(unittest.TestCase):
+ pass
diff --git a/erpnext/erpnext_integrations/exotel_integration.py b/erpnext/erpnext_integrations/exotel_integration.py
new file mode 100644
index 0000000..c70b094
--- /dev/null
+++ b/erpnext/erpnext_integrations/exotel_integration.py
@@ -0,0 +1,122 @@
+import frappe
+from erpnext.crm.doctype.utils import get_document_with_phone_number
+import requests
+
+# api/method/erpnext.erpnext_integrations.exotel_integration.handle_incoming_call
+
+@frappe.whitelist(allow_guest=True)
+def handle_incoming_call(*args, **kwargs):
+ exotel_settings = get_exotel_settings()
+ if not exotel_settings.enabled: return
+
+ employee_email = kwargs.get('AgentEmail')
+ status = kwargs.get('Status')
+
+ if status == 'free':
+ # call disconnected for agent
+ # "and get_call_status(kwargs.get('CallSid')) in ['in-progress']" - additional check to ensure if the call was redirected
+ frappe.publish_realtime('call_disconnected', user=employee_email)
+ return
+
+ call_log = get_call_log(kwargs)
+
+ data = frappe._dict({
+ 'call_from': kwargs.get('CallFrom'),
+ 'agent_email': kwargs.get('AgentEmail'),
+ 'call_type': kwargs.get('Direction'),
+ 'call_log': call_log,
+ 'call_status_method': 'erpnext.erpnext_integrations.exotel_integration.get_call_status'
+ })
+
+ frappe.publish_realtime('show_call_popup', data, user=data.agent_email)
+
+@frappe.whitelist(allow_guest=True)
+def handle_end_call(*args, **kwargs):
+ close_call_log(kwargs)
+
+@frappe.whitelist(allow_guest=True)
+def handle_missed_call(*args, **kwargs):
+ close_call_log(kwargs)
+
+def close_call_log(call_payload):
+ call_log = get_call_log(call_payload)
+ if call_log:
+ call_log.status = 'Closed'
+ call_log.save(ignore_permissions=True)
+ frappe.db.commit()
+
+
+def get_call_log(call_payload, create_new_if_not_found=True):
+ communication = frappe.get_all('Communication', {
+ 'communication_medium': 'Phone',
+ 'call_id': call_payload.get('CallSid'),
+ }, limit=1)
+
+ if communication:
+ communication = frappe.get_doc('Communication', communication[0].name)
+ return communication
+ elif create_new_if_not_found:
+ communication = frappe.new_doc('Communication')
+ communication.subject = frappe._('Call from {}').format(call_payload.get("CallFrom"))
+ communication.communication_medium = 'Phone'
+ communication.phone_no = call_payload.get("CallFrom")
+ communication.comment_type = 'Info'
+ communication.communication_type = 'Communication'
+ communication.sent_or_received = 'Received'
+ communication.communication_date = call_payload.get('StartTime')
+ communication.call_id = call_payload.get('CallSid')
+ communication.status = 'Open'
+ communication.content = frappe._('Call from {}').format(call_payload.get("CallFrom"))
+ communication.save(ignore_permissions=True)
+ frappe.db.commit()
+ return communication
+
+@frappe.whitelist()
+def get_call_status(call_id):
+ print(call_id)
+ settings = get_exotel_settings()
+ response = requests.get('https://{api_key}:{api_token}@api.exotel.com/v1/Accounts/erpnext/Calls/{call_id}.json'.format(
+ api_key=settings.api_key,
+ api_token=settings.api_token,
+ call_id=call_id
+ ))
+ status = response.json().get('Call', {}).get('Status')
+ return status
+
+@frappe.whitelist()
+def make_a_call(from_number, to_number, caller_id):
+ settings = get_exotel_settings()
+ response = requests.post('https://{api_key}:{api_token}@api.exotel.com/v1/Accounts/{sid}/Calls/connect.json?details=true'.format(
+ api_key=settings.api_key,
+ api_token=settings.api_token,
+ sid=settings.account_sid
+ ), data={
+ 'From': from_number,
+ 'To': to_number,
+ 'CallerId': caller_id
+ })
+
+ return response.json()
+
+def get_exotel_settings():
+ return frappe.get_single('Exotel Settings')
+
+@frappe.whitelist(allow_guest=True)
+def get_phone_numbers():
+ numbers = 'some number'
+ whitelist_numbers(numbers, 'for number')
+ return numbers
+
+def whitelist_numbers(numbers, caller_id):
+ settings = get_exotel_settings()
+ query = 'https://{api_key}:{api_token}@api.exotel.com/v1/Accounts/{sid}/CustomerWhitelist'.format(
+ api_key=settings.api_key,
+ api_token=settings.api_token,
+ sid=settings.account_sid
+ )
+ response = requests.post(query, data={
+ 'VirtualNumber': caller_id,
+ 'Number': numbers,
+ })
+
+ return response
\ No newline at end of file
diff --git a/erpnext/public/build.json b/erpnext/public/build.json
index 45de6eb..818f336 100644
--- a/erpnext/public/build.json
+++ b/erpnext/public/build.json
@@ -1,7 +1,8 @@
{
"css/erpnext.css": [
"public/less/erpnext.less",
- "public/less/hub.less"
+ "public/less/hub.less",
+ "public/less/call_popup.less"
],
"css/marketplace.css": [
"public/less/hub.less"
@@ -48,7 +49,8 @@
"public/js/utils/customer_quick_entry.js",
"public/js/education/student_button.html",
"public/js/education/assessment_result_tool.html",
- "public/js/hub/hub_factory.js"
+ "public/js/hub/hub_factory.js",
+ "public/js/call_popup/call_popup.js"
],
"js/item-dashboard.min.js": [
"stock/dashboard/item_dashboard.html",
diff --git a/erpnext/public/js/call_popup/call_popup.js b/erpnext/public/js/call_popup/call_popup.js
new file mode 100644
index 0000000..5693ff0
--- /dev/null
+++ b/erpnext/public/js/call_popup/call_popup.js
@@ -0,0 +1,199 @@
+class CallPopup {
+ constructor({ call_from, call_log, call_status_method }) {
+ this.caller_number = call_from;
+ this.call_log = call_log;
+ this.call_status_method = call_status_method;
+ this.make();
+ }
+
+ make() {
+ this.dialog = new frappe.ui.Dialog({
+ 'static': true,
+ 'minimizable': true,
+ 'fields': [{
+ 'fieldname': 'caller_info',
+ 'fieldtype': 'HTML'
+ }, {
+ 'fielname': 'last_interaction',
+ 'fieldtype': 'Section Break',
+ }, {
+ 'fieldtype': 'Small Text',
+ 'label': "Last Communication",
+ 'fieldname': 'last_communication',
+ 'read_only': true
+ }, {
+ 'fieldname': 'last_communication_link',
+ 'fieldtype': 'HTML',
+ }, {
+ 'fieldtype': 'Small Text',
+ 'label': "Last Issue",
+ 'fieldname': 'last_issue',
+ 'read_only': true
+ }, {
+ 'fieldname': 'last_issue_link',
+ 'fieldtype': 'HTML',
+ }, {
+ 'fieldtype': 'Column Break',
+ }, {
+ 'fieldtype': 'Small Text',
+ 'label': 'Call Summary',
+ 'fieldname': 'call_summary',
+ }, {
+ 'fieldtype': 'Button',
+ 'label': 'Submit',
+ 'click': () => {
+ const values = this.dialog.get_values();
+ if (!values.call_summary) return
+ frappe.xcall('erpnext.crm.doctype.utils.add_call_summary', {
+ 'docname': this.call_log.name,
+ 'summary': values.call_summary,
+ }).then(() => {
+ this.dialog.set_value('call_summary', '');
+ });
+ }
+ }],
+ on_minimize_toggle: () => {
+ this.set_call_status();
+ }
+ });
+ this.set_call_status(this.call_log.call_status);
+ this.make_caller_info_section();
+ this.dialog.get_close_btn().show();
+ this.setup_call_status_updater();
+ this.dialog.$body.addClass('call-popup');
+ this.dialog.set_secondary_action(() => {
+ clearInterval(this.updater);
+ delete erpnext.call_popup;
+ this.dialog.hide();
+ });
+ this.dialog.show();
+ }
+
+ make_caller_info_section() {
+ const wrapper = this.dialog.fields_dict['caller_info'].$wrapper;
+ wrapper.append('<div class="text-muted"> Loading... </div>');
+ frappe.xcall('erpnext.crm.doctype.utils.get_document_with_phone_number', {
+ 'number': this.caller_number
+ }).then(contact_doc => {
+ wrapper.empty();
+ const contact = this.contact = contact_doc;
+ if (!contact) {
+ wrapper.append(`
+ <div class="caller-info">
+ <div>Unknown Number: <b>${this.caller_number}</b></div>
+ <a class="contact-link text-medium" href="#Form/Contact/New Contact?phone=${this.caller_number}">
+ ${__('Create New Contact')}
+ </a>
+ </div>
+ `);
+ } else {
+ const link = contact.links ? contact.links[0] : null;
+ const contact_link = link ? frappe.utils.get_form_link(link.link_doctype, link.link_name, true): '';
+ wrapper.append(`
+ <div class="caller-info flex">
+ <img src="${contact.image}">
+ <div class='flex-column'>
+ <span>${contact.first_name} ${contact.last_name}</span>
+ <span>${contact.mobile_no}</span>
+ ${contact_link}
+ </div>
+ </div>
+ `);
+ this.set_call_status();
+ this.make_last_interaction_section();
+ }
+ });
+ }
+
+ set_indicator(color, blink=false) {
+ this.dialog.header.find('.indicator').removeClass('hidden').toggleClass('blink', blink).addClass(color);
+ }
+
+ set_call_status(call_status) {
+ let title = '';
+ call_status = call_status || this.call_log.call_status;
+ if (['busy', 'completed'].includes(call_status) || !call_status) {
+ title = __('Incoming call from {0}',
+ [this.contact ? `${this.contact.first_name} ${this.contact.last_name}` : this.caller_number]);
+ this.set_indicator('blue', true);
+ } else if (call_status === 'in-progress') {
+ title = __('Call Connected');
+ this.set_indicator('yellow');
+ } else if (call_status === 'missed') {
+ this.set_indicator('red');
+ title = __('Call Missed');
+ } else if (call_status === 'disconnected') {
+ this.set_indicator('red');
+ title = __('Call Disconnected');
+ } else {
+ this.set_indicator('blue');
+ title = call_status;
+ }
+ this.dialog.set_title(title);
+ }
+
+ update(data) {
+ this.call_log = data.call_log;
+ this.set_call_status();
+ }
+
+ setup_call_status_updater() {
+ this.updater = setInterval(this.get_call_status.bind(this), 20000);
+ }
+
+ get_call_status() {
+ frappe.xcall(this.call_status_method, {
+ 'call_id': this.call_log.call_id
+ }).then((call_status) => {
+ if (call_status === 'completed') {
+ clearInterval(this.updater);
+ }
+ });
+ }
+
+ disconnect_call() {
+ this.set_call_status('disconnected');
+ clearInterval(this.updater);
+ }
+
+ make_last_interaction_section() {
+ frappe.xcall('erpnext.crm.doctype.utils.get_last_interaction', {
+ 'number': this.caller_number,
+ 'reference_doc': this.contact
+ }).then(data => {
+ if (data.last_communication) {
+ const comm = data.last_communication;
+ // this.dialog.set_df_property('last_interaction', 'hidden', false);
+ const comm_field = this.dialog.fields_dict["last_communication"];
+ comm_field.set_value(comm.content);
+ comm_field.$wrapper.append(frappe.utils.get_form_link('Communication', comm.name));
+ }
+
+ if (data.last_issue) {
+ const issue = data.last_issue;
+ // this.dialog.set_df_property('last_interaction', 'hidden', false);
+ const issue_field = this.dialog.fields_dict["last_issue"];
+ issue_field.set_value(issue.subject);
+ issue_field.$wrapper
+ .append(`<a class="text-medium" href="#List/Issue/List?customer=${issue.customer}">View all issues from ${issue.customer}</a>`);
+ }
+ });
+ }
+}
+
+$(document).on('app_ready', function () {
+ frappe.realtime.on('show_call_popup', data => {
+ if (!erpnext.call_popup) {
+ erpnext.call_popup = new CallPopup(data);
+ } else {
+ erpnext.call_popup.update(data);
+ erpnext.call_popup.dialog.show();
+ }
+ });
+
+ frappe.realtime.on('call_disconnected', () => {
+ if (erpnext.call_popup) {
+ erpnext.call_popup.disconnect_call();
+ }
+ });
+});
diff --git a/erpnext/public/less/call_popup.less b/erpnext/public/less/call_popup.less
new file mode 100644
index 0000000..3f4ffef
--- /dev/null
+++ b/erpnext/public/less/call_popup.less
@@ -0,0 +1,13 @@
+.call-popup {
+ .caller-info {
+ padding: 0 15px;
+ }
+ img {
+ width: auto;
+ height: 100px;
+ margin-right: 15px;
+ }
+ a:hover {
+ text-decoration: underline;
+ }
+}
\ No newline at end of file