snapshot of https://github.com/frappe/crm.git (develop, 2026-09-18; blobs > 131072B omitted)
Some checks failed
Frontend / Unit Tests & Coverage (push) Has been skipped
Regenerate POT file (translatable strings) / Regenerate POT file (develop) (push) Failing after 1m13s
Regenerate POT file (translatable strings) / Regenerate POT file (main-hotfix) (push) Failing after 6s
Server / Tests (frappe ${{ matrix.frappe-branch }}) (push) Has been skipped
Server / Resolve frappe matrix (push) Failing after 1s
Frontend / Lint (push) Failing after 1s
Frontend / Production Build (push) Failing after 1s
Server / Coverage Wrap Up (push) Has been skipped
Frontend / Coverage Wrap Up (push) Has been skipped
Some checks failed
Frontend / Unit Tests & Coverage (push) Has been skipped
Regenerate POT file (translatable strings) / Regenerate POT file (develop) (push) Failing after 1m13s
Regenerate POT file (translatable strings) / Regenerate POT file (main-hotfix) (push) Failing after 6s
Server / Tests (frappe ${{ matrix.frappe-branch }}) (push) Has been skipped
Server / Resolve frappe matrix (push) Failing after 1s
Frontend / Lint (push) Failing after 1s
Frontend / Production Build (push) Failing after 1s
Server / Coverage Wrap Up (push) Has been skipped
Frontend / Coverage Wrap Up (push) Has been skipped
This commit is contained in:
0
crm/integrations/__init__.py
Normal file
0
crm/integrations/__init__.py
Normal file
381
crm/integrations/api.py
Normal file
381
crm/integrations/api.py
Normal file
@@ -0,0 +1,381 @@
|
||||
import ipaddress
|
||||
import socket
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
import frappe
|
||||
import requests
|
||||
from frappe import _
|
||||
from frappe.query_builder import Order
|
||||
from pypika.functions import Replace
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
from crm.utils import are_same_phone_number, parse_phone_number
|
||||
|
||||
|
||||
def _get_recording_credentials(telephony_medium: str) -> tuple | None:
|
||||
"""Return (api_key, secret) for the given telephony medium, or None when the
|
||||
recording needs no auth.
|
||||
|
||||
A manual/unrecognized medium (a recording added by hand) is fetched as-is, and
|
||||
a provider whose credentials aren't configured yet falls back to no auth rather
|
||||
than raising — so the proxy attempts the fetch and lets the provider decide,
|
||||
instead of 500-ing before the request is even made.
|
||||
"""
|
||||
if telephony_medium == "Twilio":
|
||||
s = frappe.get_single("CRM Twilio Settings")
|
||||
secret = s.get_password("api_secret", raise_exception=False)
|
||||
return (s.api_key, secret) if s.api_key and secret else None
|
||||
elif telephony_medium == "Exotel":
|
||||
s = frappe.get_single("CRM Exotel Settings")
|
||||
token = s.get_password("api_token", raise_exception=False)
|
||||
return (s.api_key, token) if s.api_key and token else None
|
||||
# manual or unrecognized medium: no provider auth to apply
|
||||
return None
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def is_call_integration_enabled():
|
||||
return {
|
||||
"integrations": {
|
||||
"twilio": bool(frappe.db.get_single_value("CRM Twilio Settings", "enabled")),
|
||||
"exotel": bool(frappe.db.get_single_value("CRM Exotel Settings", "enabled")),
|
||||
},
|
||||
"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: str):
|
||||
if not frappe.db.exists("CRM Telephony Agent", frappe.session.user):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "CRM Telephony Agent",
|
||||
"user": 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()
|
||||
def add_note_to_call_log(call_sid: str, note: dict):
|
||||
"""Add/Update note to call log based on call sid."""
|
||||
if not frappe.has_permission("CRM Call Log", "write", call_sid):
|
||||
frappe.throw(_("Not permitted"), frappe.PermissionError)
|
||||
|
||||
_note = None
|
||||
if not note.get("name"):
|
||||
_note = frappe.get_doc(
|
||||
{
|
||||
"doctype": "FCRM Note",
|
||||
"title": note.get("title", "Call Note"),
|
||||
"content": note.get("content"),
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
else:
|
||||
_note = frappe.set_value("FCRM Note", note.get("name"), "content", note.get("content"))
|
||||
|
||||
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)
|
||||
|
||||
return _note
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def add_task_to_call_log(call_sid: str, task: dict):
|
||||
"""Add/Update task to call log based on call sid."""
|
||||
if not frappe.has_permission("CRM Call Log", "write", call_sid):
|
||||
frappe.throw(_("Not permitted"), frappe.PermissionError)
|
||||
|
||||
_task = None
|
||||
if not task.get("name"):
|
||||
_task = frappe.get_doc(
|
||||
{
|
||||
"doctype": "CRM Task",
|
||||
"title": task.get("title"),
|
||||
"description": task.get("description"),
|
||||
"assigned_to": task.get("assigned_to"),
|
||||
"due_date": task.get("due_date"),
|
||||
"status": task.get("status"),
|
||||
"priority": task.get("priority"),
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
else:
|
||||
_task = frappe.get_doc("CRM Task", task.get("name"))
|
||||
_task.update(
|
||||
{
|
||||
"title": task.get("title"),
|
||||
"description": task.get("description"),
|
||||
"assigned_to": task.get("assigned_to"),
|
||||
"due_date": task.get("due_date"),
|
||||
"status": task.get("status"),
|
||||
"priority": task.get("priority"),
|
||||
}
|
||||
)
|
||||
_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()
|
||||
def get_contact_lead_or_deal_from_number(number: str):
|
||||
"""Get contact, lead or deal from the given number."""
|
||||
contact = get_contact_by_phone_number(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")
|
||||
return docname, doctype
|
||||
return None, None
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_contact_by_phone_number(phone_number: str):
|
||||
"""Get contact by phone number."""
|
||||
number = parse_phone_number(phone_number)
|
||||
|
||||
if number.get("is_valid"):
|
||||
return get_contact(number.get("national_number"), number.get("country"))
|
||||
else:
|
||||
return get_contact(phone_number, number.get("country"), exact_match=True)
|
||||
|
||||
|
||||
def _resolve_validated_ip(hostname: str, port: int) -> str:
|
||||
# Refuse any host that resolves to a non-public address (cloud metadata, localhost,
|
||||
# private/link-local ranges) and return a single validated IP to connect to. Returning
|
||||
# the exact resolved IP — rather than re-resolving at connect time — is what closes the
|
||||
# DNS-rebinding TOCTOU window.
|
||||
try:
|
||||
addrinfos = socket.getaddrinfo(hostname, port, proto=socket.IPPROTO_TCP)
|
||||
except socket.gaierror:
|
||||
frappe.throw(_("Invalid recording URL"), frappe.ValidationError)
|
||||
|
||||
ips = [info[4][0] for info in addrinfos]
|
||||
if not ips:
|
||||
frappe.throw(_("Invalid recording URL"), frappe.ValidationError)
|
||||
|
||||
for ip in ips:
|
||||
if not ipaddress.ip_address(ip).is_global:
|
||||
frappe.throw(_("Recording URL is not allowed"), frappe.ValidationError)
|
||||
|
||||
return ips[0]
|
||||
|
||||
|
||||
class _PinnedIPAdapter(requests.adapters.HTTPAdapter):
|
||||
# Connect to a pre-validated IP while keeping the original hostname for the Host header,
|
||||
# TLS SNI and certificate verification. The socket therefore reaches the exact IP that was
|
||||
# checked, so DNS can't be rebound to an internal address between check and connect.
|
||||
def __init__(self, pinned_ip: str, hostname: str, **kwargs):
|
||||
self._pinned_ip = pinned_ip
|
||||
self._hostname = hostname
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def send(self, request, **kwargs):
|
||||
parsed = urlparse(request.url)
|
||||
literal_ip = (
|
||||
f"[{self._pinned_ip}]" if ipaddress.ip_address(self._pinned_ip).version == 6 else self._pinned_ip
|
||||
)
|
||||
netloc = f"{literal_ip}:{parsed.port}" if parsed.port else literal_ip
|
||||
request.url = urlunparse(parsed._replace(netloc=netloc))
|
||||
host = f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname
|
||||
request.headers["Host"] = f"{host}:{parsed.port}" if parsed.port else host
|
||||
if parsed.scheme == "https":
|
||||
self.poolmanager.connection_pool_kw["server_hostname"] = self._hostname
|
||||
self.poolmanager.connection_pool_kw["assert_hostname"] = self._hostname
|
||||
return super().send(request, **kwargs)
|
||||
|
||||
|
||||
def _safe_get(url: str, auth, headers: dict):
|
||||
# TODO: this SSRF-safe fetch (host validation + IP pinning) will likely need to be shared
|
||||
# with domain enrichment; until that feature ships, this local helper is the interim workaround.
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
frappe.throw(_("Invalid recording URL"), frappe.ValidationError)
|
||||
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
pinned_ip = _resolve_validated_ip(parsed.hostname, port)
|
||||
|
||||
session = requests.Session()
|
||||
session.mount(f"{parsed.scheme}://", _PinnedIPAdapter(pinned_ip, parsed.hostname))
|
||||
resp = session.get(url, auth=auth, headers=headers, stream=True, timeout=30, allow_redirects=False)
|
||||
return resp, session
|
||||
|
||||
|
||||
def _fetch_recording(url: str, auth, headers: dict):
|
||||
# Follow redirects manually so every hop is validated and IP-pinned: a provider URL can
|
||||
# 302 to a signed CDN URL (legitimate), but without per-hop checks a redirect to an
|
||||
# internal address would bypass validation. Provider credentials are dropped after the
|
||||
# first hop so they aren't leaked to the redirect target.
|
||||
current_url = url
|
||||
current_auth = auth
|
||||
for _hop in range(5):
|
||||
resp, session = _safe_get(current_url, current_auth, headers)
|
||||
if resp.is_redirect and resp.headers.get("Location"):
|
||||
current_url = requests.compat.urljoin(current_url, resp.headers["Location"])
|
||||
current_auth = None
|
||||
resp.close()
|
||||
session.close()
|
||||
continue
|
||||
resp._pinned_session = session
|
||||
return resp
|
||||
|
||||
frappe.throw(_("Too many redirects while fetching recording"), frappe.ValidationError)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_recording_url(call_log_name: str):
|
||||
"""Proxy a call recording (authenticating with the provider) so it plays in the browser.
|
||||
|
||||
Forwards the browser's Range request to the provider and passes the response back with
|
||||
Accept-Ranges/Content-Length set. Without range support the HTML <audio> element can't
|
||||
read the recording's duration (shows 0:00) or seek within it.
|
||||
"""
|
||||
if not call_log_name or not frappe.db.exists("CRM Call Log", call_log_name):
|
||||
frappe.throw(_("Call log not found"), frappe.DoesNotExistError)
|
||||
|
||||
log = frappe.get_doc("CRM Call Log", call_log_name)
|
||||
log.check_permission("read")
|
||||
|
||||
if not log.recording_url:
|
||||
frappe.throw(_("Recording URL not found"), frappe.DoesNotExistError)
|
||||
|
||||
auth = _get_recording_credentials(log.telephony_medium)
|
||||
# forward the browser's Range header so the provider (Twilio/Exotel CDN) can return
|
||||
# just the requested bytes; falls back to the full file if it doesn't support ranges
|
||||
req_headers = {}
|
||||
range_header = frappe.get_request_header("Range")
|
||||
if range_header:
|
||||
req_headers["Range"] = range_header
|
||||
|
||||
# stream instead of buffering the whole file: the provider's Content-Length reaches
|
||||
# the browser immediately so the <audio> element can show the duration right away,
|
||||
# rather than waiting for the entire recording to download server-side first
|
||||
upstream = _fetch_recording(log.recording_url, auth, req_headers)
|
||||
upstream.raise_for_status()
|
||||
|
||||
def _stream():
|
||||
try:
|
||||
yield from upstream.iter_content(chunk_size=64 * 1024)
|
||||
finally:
|
||||
upstream.close()
|
||||
session = getattr(upstream, "_pinned_session", None)
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
response = Response(
|
||||
_stream(),
|
||||
status=upstream.status_code,
|
||||
mimetype=upstream.headers.get("Content-Type") or "audio/mpeg",
|
||||
)
|
||||
response.headers["Accept-Ranges"] = "bytes"
|
||||
for header in ("Content-Length", "Content-Range"):
|
||||
if upstream.headers.get(header):
|
||||
response.headers[header] = upstream.headers[header]
|
||||
return response
|
||||
|
||||
|
||||
def get_contact(phone_number: str, country: str = "IN", exact_match: bool = False):
|
||||
if not phone_number:
|
||||
return {"mobile_no": phone_number}
|
||||
|
||||
cleaned_number = (
|
||||
phone_number.strip()
|
||||
.replace(" ", "")
|
||||
.replace("-", "")
|
||||
.replace("(", "")
|
||||
.replace(")", "")
|
||||
.replace("+", "")
|
||||
)
|
||||
|
||||
# Check if the number is associated with a contact.
|
||||
# Search all of a contact's numbers (phone_nos child table) and not just the
|
||||
# primary mobile_no, so calls from a secondary number still resolve.
|
||||
Contact = frappe.qb.DocType("Contact")
|
||||
ContactPhone = frappe.qb.DocType("Contact Phone")
|
||||
normalized_phone = Replace(
|
||||
Replace(Replace(Replace(Replace(ContactPhone.phone, " ", ""), "-", ""), "(", ""), ")", ""), "+", ""
|
||||
)
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(ContactPhone)
|
||||
.join(Contact)
|
||||
.on(ContactPhone.parent == Contact.name)
|
||||
.select(
|
||||
Contact.name,
|
||||
Contact.full_name,
|
||||
Contact.image,
|
||||
Contact.mobile_no,
|
||||
ContactPhone.phone.as_("matched_phone"),
|
||||
)
|
||||
.where(ContactPhone.parenttype == "Contact")
|
||||
.where(normalized_phone.like(f"%{cleaned_number}%"))
|
||||
.orderby(Contact.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.matched_phone, phone_number, country, validate=not exact_match
|
||||
):
|
||||
contact["deal"] = deal
|
||||
return contact
|
||||
|
||||
# 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, country, validate=not exact_match):
|
||||
lead["lead"] = lead.name
|
||||
lead["full_name"] = lead.lead_name
|
||||
return lead
|
||||
|
||||
if len(contacts) and are_same_phone_number(
|
||||
contacts[0].matched_phone, phone_number, country, validate=not exact_match
|
||||
):
|
||||
return contacts[0]
|
||||
|
||||
return {"mobile_no": phone_number}
|
||||
0
crm/integrations/erpnext/__init__.py
Normal file
0
crm/integrations/erpnext/__init__.py
Normal file
82
crm/integrations/erpnext/doc_share.py
Normal file
82
crm/integrations/erpnext/doc_share.py
Normal file
@@ -0,0 +1,82 @@
|
||||
import frappe
|
||||
|
||||
from crm.integrations.erpnext.mirror_sync import MIRROR_FLAG, MirrorSync
|
||||
from crm.integrations.erpnext.utils import should_sync
|
||||
|
||||
|
||||
def _sync(doc) -> MirrorSync:
|
||||
return MirrorSync(
|
||||
doc,
|
||||
"share_doctype",
|
||||
"share_name",
|
||||
extra_mirror_flags={"ignore_share_permission": True},
|
||||
)
|
||||
|
||||
|
||||
def before_validate(doc, method=None):
|
||||
sync = _sync(doc)
|
||||
old = doc.get_doc_before_save()
|
||||
if old and sync.has_data_updated(old) and sync.sync_active():
|
||||
sync.delete_mirror_for(old)
|
||||
|
||||
|
||||
def after_insert(doc, method=None):
|
||||
sync = _sync(doc)
|
||||
if sync.should_mirror():
|
||||
sync.create_mirror()
|
||||
|
||||
|
||||
def on_update(doc, method=None):
|
||||
sync = _sync(doc)
|
||||
if not sync.should_mirror():
|
||||
return
|
||||
old = doc.get_doc_before_save()
|
||||
if old and sync.has_data_updated(old):
|
||||
sync.create_mirror()
|
||||
else:
|
||||
sync.sync_state_to_mirror()
|
||||
|
||||
|
||||
def on_trash(doc, method=None):
|
||||
sync = _sync(doc)
|
||||
if not sync.should_mirror():
|
||||
return
|
||||
mirror = sync.find_mirror()
|
||||
if not mirror:
|
||||
return
|
||||
sync.set_mirror_flags(mirror)
|
||||
mirror.delete(ignore_permissions=True)
|
||||
|
||||
|
||||
def sync_shared_docs():
|
||||
if not should_sync():
|
||||
return
|
||||
product_shares = frappe.get_list("DocShare", filters={"share_doctype": "CRM Product"}, fields=["*"])
|
||||
item_shares = frappe.get_list("DocShare", filters={"share_doctype": "Item"}, fields=["*"])
|
||||
existing_product = {(s.user, s.share_name) for s in product_shares}
|
||||
existing_item = {(s.user, s.share_name) for s in item_shares}
|
||||
|
||||
for share in item_shares:
|
||||
product = frappe.db.get_value("Item", share.share_name, "crm_product_code")
|
||||
if not product or (share.user, product) in existing_product:
|
||||
continue
|
||||
_mirror_share(share, "CRM Product", product)
|
||||
existing_product.add((share.user, product))
|
||||
|
||||
for share in product_shares:
|
||||
item = frappe.db.get_value("CRM Product", share.share_name, "erpnext_item_code")
|
||||
if not item or (share.user, item) in existing_item:
|
||||
continue
|
||||
_mirror_share(share, "Item", item)
|
||||
existing_item.add((share.user, item))
|
||||
|
||||
|
||||
def _mirror_share(src, target_doctype, target_value):
|
||||
data = dict(src)
|
||||
for k in ("name", "owner", "creation", "modified", "modified_by", "idx"):
|
||||
data.pop(k, None)
|
||||
data.update({"doctype": "DocShare", "share_doctype": target_doctype, "share_name": target_value})
|
||||
doc = frappe.get_doc(data)
|
||||
doc.flags[MIRROR_FLAG] = True
|
||||
doc.flags.ignore_share_permission = True
|
||||
doc.insert(ignore_permissions=True)
|
||||
102
crm/integrations/erpnext/item.py
Normal file
102
crm/integrations/erpnext/item.py
Normal file
@@ -0,0 +1,102 @@
|
||||
import frappe
|
||||
|
||||
from crm.fcrm.doctype.crm_product.sync_utils import payload_differs
|
||||
from crm.integrations.erpnext.utils import (
|
||||
CASCADE_FLAG,
|
||||
cascade_rename,
|
||||
in_cascade,
|
||||
set_links,
|
||||
should_sync,
|
||||
validate_rename_conflict,
|
||||
)
|
||||
|
||||
CATALOGUE_FIELDS = ("standard_rate", "image", "disabled", "description")
|
||||
|
||||
|
||||
def _should_sync(doc) -> bool:
|
||||
return should_sync() and not doc.flags.get("ignore_crm_sync")
|
||||
|
||||
|
||||
def get_item_price_rate(item_code: str, uom: str | None = None):
|
||||
from erpnext.stock.get_item_details import get_item_price
|
||||
|
||||
price_list = frappe.db.get_single_value("Selling Settings", "selling_price_list")
|
||||
if not price_list:
|
||||
return None
|
||||
pctx = frappe._dict({"price_list": price_list, "uom": uom, "transaction_date": frappe.utils.nowdate()})
|
||||
rows = get_item_price(pctx, item_code)
|
||||
if not rows:
|
||||
return None
|
||||
row = rows[0]
|
||||
|
||||
return row.price_list_rate if isinstance(row, dict) else row[1]
|
||||
|
||||
|
||||
def _catalogue_data(doc) -> dict:
|
||||
data = {f: doc.get(f) for f in CATALOGUE_FIELDS}
|
||||
# Give preference to Item Price over standard rate
|
||||
# keep standard_rate as fallback
|
||||
rate = get_item_price_rate(doc.name, doc.get("stock_uom"))
|
||||
if rate is not None:
|
||||
data["standard_rate"] = rate
|
||||
# standard_rate is a NOT NULL Currency column and frappe.db.set_value writes
|
||||
# raw values (no flt coercion, unlike doc.insert), so never pass None through
|
||||
data["standard_rate"] = frappe.utils.flt(data["standard_rate"])
|
||||
return data
|
||||
|
||||
|
||||
def after_insert(doc, method=None):
|
||||
if not _should_sync(doc):
|
||||
return
|
||||
if frappe.db.get_value("CRM Product", {"erpnext_item_code": doc.name}):
|
||||
return
|
||||
product = frappe.get_doc(
|
||||
{
|
||||
"doctype": "CRM Product",
|
||||
"product_code": doc.item_code,
|
||||
"product_name": doc.item_name,
|
||||
**_catalogue_data(doc),
|
||||
}
|
||||
)
|
||||
product.flags.ignore_erpnext_sync = True
|
||||
product.insert(ignore_permissions=True)
|
||||
set_links(doc.name, product.name)
|
||||
|
||||
|
||||
def on_update(doc, method=None):
|
||||
if not _should_sync(doc):
|
||||
return
|
||||
product_name = frappe.db.get_value("CRM Product", {"erpnext_item_code": doc.name})
|
||||
if not product_name:
|
||||
return
|
||||
data = {
|
||||
"product_name": doc.item_name,
|
||||
**_catalogue_data(doc),
|
||||
}
|
||||
current = frappe.db.get_value("CRM Product", product_name, list(data.keys()), as_dict=True) or {}
|
||||
if not payload_differs(data, current):
|
||||
return
|
||||
frappe.db.set_value("CRM Product", product_name, data)
|
||||
|
||||
|
||||
def before_rename(doc, method=None, olddn=None, newdn=None, merge=False):
|
||||
validate_rename_conflict("Item", olddn, newdn, merge)
|
||||
|
||||
|
||||
def after_rename(doc, method=None, olddn=None, newdn=None, merge=False):
|
||||
cascade_rename("Item", olddn, newdn, merge)
|
||||
|
||||
|
||||
def on_trash(doc, method=None):
|
||||
if in_cascade() or not _should_sync(doc):
|
||||
return
|
||||
product = frappe.db.get_value("CRM Product", {"erpnext_item_code": doc.name})
|
||||
if not product:
|
||||
return
|
||||
# Clear back link first so CRMProduct.on_trash won't try to delete this Item
|
||||
frappe.db.set_value("CRM Product", product, "erpnext_item_code", None)
|
||||
frappe.flags[CASCADE_FLAG] = True
|
||||
try:
|
||||
frappe.delete_doc("CRM Product", product, ignore_permissions=True)
|
||||
finally:
|
||||
frappe.flags[CASCADE_FLAG] = False
|
||||
126
crm/integrations/erpnext/mirror_sync.py
Normal file
126
crm/integrations/erpnext/mirror_sync.py
Normal file
@@ -0,0 +1,126 @@
|
||||
import frappe
|
||||
|
||||
from crm.integrations.erpnext.utils import ALLOWED_DOCTYPES, find_target_for, should_sync
|
||||
|
||||
MIRROR_FLAG = "ignore_erpnext_sync"
|
||||
_SKIP_PAYLOAD_FIELDS = {
|
||||
"name",
|
||||
"owner",
|
||||
"creation",
|
||||
"modified",
|
||||
"modified_by",
|
||||
"idx",
|
||||
"docstatus",
|
||||
"parent",
|
||||
"parentfield",
|
||||
"parenttype",
|
||||
"doctype",
|
||||
}
|
||||
|
||||
|
||||
class MirrorSync:
|
||||
"""Generic mirror sync for lifecycle-bound docs (User Permission, DocShare)
|
||||
between Item and CRM Product. Wraps a document and is driven by doc_events
|
||||
handlers. Configure the link fields via doctype_field + value_field.
|
||||
|
||||
extra_dedup: optional callable(doc) -> dict, merged into the dedup filter.
|
||||
extra_mirror_flags: optional dict of flags set on every mirror document.
|
||||
"""
|
||||
|
||||
def __init__(self, doc, doctype_field, value_field, extra_dedup=None, extra_mirror_flags=None):
|
||||
self.doc = doc
|
||||
self.DOCTYPE_FIELD = doctype_field
|
||||
self.VALUE_FIELD = value_field
|
||||
self._extra_dedup = extra_dedup
|
||||
self._extra_mirror_flags = extra_mirror_flags or {}
|
||||
|
||||
def sync_active(self) -> bool:
|
||||
return should_sync()
|
||||
|
||||
def should_mirror(self) -> bool:
|
||||
if self.doc.flags.get(MIRROR_FLAG):
|
||||
return False
|
||||
if not self.sync_active():
|
||||
return False
|
||||
return self.doc.get(self.DOCTYPE_FIELD) in ALLOWED_DOCTYPES
|
||||
|
||||
def has_data_updated(self, old) -> bool:
|
||||
return self.doc.get(self.DOCTYPE_FIELD) != old.get(self.DOCTYPE_FIELD) or self.doc.get(
|
||||
self.VALUE_FIELD
|
||||
) != old.get(self.VALUE_FIELD)
|
||||
|
||||
def _target(self):
|
||||
return find_target_for(self.doc.get(self.DOCTYPE_FIELD), self.doc.get(self.VALUE_FIELD))
|
||||
|
||||
def dedup_filter(self, target_doctype: str, target_value: str) -> dict:
|
||||
filters = {
|
||||
self.DOCTYPE_FIELD: target_doctype,
|
||||
self.VALUE_FIELD: target_value,
|
||||
"user": self.doc.user,
|
||||
}
|
||||
if self._extra_dedup:
|
||||
filters.update(self._extra_dedup(self.doc))
|
||||
return filters
|
||||
|
||||
def find_mirror(self):
|
||||
target = self._target()
|
||||
if not target:
|
||||
return None
|
||||
target_doctype, target_value = target
|
||||
filters = self.dedup_filter(target_doctype, target_value)
|
||||
name = frappe.db.get_value(self.doc.doctype, filters, "name")
|
||||
return frappe.get_doc(self.doc.doctype, name) if name else None
|
||||
|
||||
def set_mirror_flags(self, mirror):
|
||||
mirror.flags[MIRROR_FLAG] = True
|
||||
for flag, value in self._extra_mirror_flags.items():
|
||||
mirror.flags[flag] = value
|
||||
|
||||
def _mirror_payload(self, target_doctype, target_value):
|
||||
data = self.doc.as_dict()
|
||||
for k in _SKIP_PAYLOAD_FIELDS:
|
||||
data.pop(k, None)
|
||||
data[self.DOCTYPE_FIELD] = target_doctype
|
||||
data[self.VALUE_FIELD] = target_value
|
||||
data["doctype"] = self.doc.doctype
|
||||
return data
|
||||
|
||||
def create_mirror(self):
|
||||
target = self._target()
|
||||
if not target:
|
||||
return
|
||||
target_doctype, target_value = target
|
||||
if frappe.db.exists(self.doc.doctype, self.dedup_filter(target_doctype, target_value)):
|
||||
return
|
||||
mirror = frappe.get_doc(self._mirror_payload(target_doctype, target_value))
|
||||
self.set_mirror_flags(mirror)
|
||||
mirror.insert(ignore_permissions=True)
|
||||
|
||||
def sync_state_to_mirror(self):
|
||||
mirror = self.find_mirror()
|
||||
if not mirror:
|
||||
return
|
||||
changed = False
|
||||
for field, value in self.doc.as_dict().items():
|
||||
if field in _SKIP_PAYLOAD_FIELDS or field in (self.DOCTYPE_FIELD, self.VALUE_FIELD):
|
||||
continue
|
||||
if mirror.get(field) != value:
|
||||
mirror.set(field, value)
|
||||
changed = True
|
||||
if not changed:
|
||||
return
|
||||
self.set_mirror_flags(mirror)
|
||||
mirror.save(ignore_permissions=True)
|
||||
|
||||
def delete_mirror_for(self, old):
|
||||
old_target = find_target_for(old.get(self.DOCTYPE_FIELD), old.get(self.VALUE_FIELD))
|
||||
if not old_target:
|
||||
return
|
||||
target_doctype, target_value = old_target
|
||||
filters = self.dedup_filter(target_doctype, target_value)
|
||||
name = frappe.db.get_value(self.doc.doctype, filters, "name")
|
||||
if not name:
|
||||
return
|
||||
mirror = frappe.get_doc(self.doc.doctype, name)
|
||||
self.set_mirror_flags(mirror)
|
||||
mirror.delete(ignore_permissions=True)
|
||||
138
crm/integrations/erpnext/test_utils.py
Normal file
138
crm/integrations/erpnext/test_utils.py
Normal file
@@ -0,0 +1,138 @@
|
||||
import unittest
|
||||
from importlib.util import find_spec
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
|
||||
# Skip tests that import erpnext when it isn't installed. CI only installs
|
||||
# ERPNext when integration code under crm/integrations/erpnext/** is touched.
|
||||
ERPNEXT_INSTALLED = find_spec("erpnext") is not None
|
||||
|
||||
# Doctypes whose sync behaviour moved from override_doctype_class to doc_events
|
||||
SYNC_DOCTYPES = ("Item", "User Permission", "DocShare")
|
||||
|
||||
|
||||
class TestSyncHookWiring(FrappeTestCase):
|
||||
def test_sync_doctypes_not_class_overridden(self):
|
||||
from crm.hooks import override_doctype_class
|
||||
|
||||
for doctype in SYNC_DOCTYPES:
|
||||
self.assertNotIn(doctype, override_doctype_class)
|
||||
|
||||
def test_doc_event_handlers_are_importable(self):
|
||||
from crm.hooks import doc_events
|
||||
|
||||
for doctype in SYNC_DOCTYPES:
|
||||
for handlers in doc_events[doctype].values():
|
||||
for path in handlers:
|
||||
self.assertTrue(callable(frappe.get_attr(path)), path)
|
||||
|
||||
|
||||
class TestFindTargetFor(FrappeTestCase):
|
||||
def test_returns_none_for_unknown_doctype(self):
|
||||
from crm.integrations.erpnext.utils import find_target_for
|
||||
|
||||
self.assertIsNone(find_target_for("Some Other DocType", "X"))
|
||||
|
||||
def test_returns_none_when_inputs_missing(self):
|
||||
from crm.integrations.erpnext.utils import find_target_for
|
||||
|
||||
self.assertIsNone(find_target_for(None, "X"))
|
||||
self.assertIsNone(find_target_for("Item", None))
|
||||
|
||||
|
||||
class TestItemRateFallback(FrappeTestCase):
|
||||
def test_item_price_takes_precedence_over_standard_rate(self):
|
||||
from crm.integrations.erpnext import item
|
||||
|
||||
doc = frappe._dict(
|
||||
name="ITM-A", stock_uom="Nos", standard_rate=50, image=None, disabled=0, description="d"
|
||||
)
|
||||
original = item.get_item_price_rate
|
||||
item.get_item_price_rate = lambda code, uom=None: 999
|
||||
try:
|
||||
data = item._catalogue_data(doc)
|
||||
finally:
|
||||
item.get_item_price_rate = original
|
||||
self.assertEqual(data["standard_rate"], 999)
|
||||
|
||||
def test_falls_back_to_standard_rate_when_no_item_price(self):
|
||||
from crm.integrations.erpnext import item
|
||||
|
||||
doc = frappe._dict(
|
||||
name="ITM-B", stock_uom="Nos", standard_rate=50, image=None, disabled=0, description="d"
|
||||
)
|
||||
original = item.get_item_price_rate
|
||||
item.get_item_price_rate = lambda code, uom=None: None
|
||||
try:
|
||||
data = item._catalogue_data(doc)
|
||||
finally:
|
||||
item.get_item_price_rate = original
|
||||
self.assertEqual(data["standard_rate"], 50)
|
||||
|
||||
def test_none_rate_coerces_to_zero_when_no_item_price(self):
|
||||
from crm.integrations.erpnext import item
|
||||
|
||||
doc = frappe._dict(
|
||||
name="ITM-D", stock_uom="Nos", standard_rate=None, image=None, disabled=0, description="d"
|
||||
)
|
||||
original = item.get_item_price_rate
|
||||
item.get_item_price_rate = lambda code, uom=None: None
|
||||
try:
|
||||
data = item._catalogue_data(doc)
|
||||
finally:
|
||||
item.get_item_price_rate = original
|
||||
self.assertEqual(data["standard_rate"], 0)
|
||||
|
||||
@unittest.skipUnless(ERPNEXT_INSTALLED, "erpnext not installed")
|
||||
def test_get_item_price_rate_uses_default_price_list_and_general_party(self):
|
||||
from erpnext.stock import get_item_details
|
||||
|
||||
from crm.integrations.erpnext import item
|
||||
|
||||
captured = {}
|
||||
orig_single = frappe.db.get_single_value
|
||||
orig_get_item_price = get_item_details.get_item_price
|
||||
frappe.db.get_single_value = lambda doctype, field: "Standard Selling"
|
||||
|
||||
def fake_get_item_price(pctx, item_code, **kwargs):
|
||||
captured.update(pctx=dict(pctx), item_code=item_code, kwargs=kwargs)
|
||||
return [frappe._dict(name="IP-1", price_list_rate=77, uom="Nos")]
|
||||
|
||||
get_item_details.get_item_price = fake_get_item_price
|
||||
try:
|
||||
rate = item.get_item_price_rate("ITM-C", "Nos")
|
||||
finally:
|
||||
frappe.db.get_single_value = orig_single
|
||||
get_item_details.get_item_price = orig_get_item_price
|
||||
self.assertEqual(rate, 77)
|
||||
self.assertEqual(captured["item_code"], "ITM-C")
|
||||
self.assertEqual(captured["pctx"]["price_list"], "Standard Selling")
|
||||
self.assertEqual(captured["pctx"]["uom"], "Nos")
|
||||
self.assertIn("transaction_date", captured["pctx"])
|
||||
# No customer/supplier and ignore_party unset -> ERPNext selects general prices only
|
||||
self.assertNotIn("customer", captured["pctx"])
|
||||
self.assertFalse(captured["kwargs"].get("ignore_party"))
|
||||
|
||||
@unittest.skipUnless(ERPNEXT_INSTALLED, "erpnext not installed")
|
||||
def test_get_item_price_rate_returns_none_without_default_price_list(self):
|
||||
from crm.integrations.erpnext import item
|
||||
|
||||
original = frappe.db.get_single_value
|
||||
frappe.db.get_single_value = lambda doctype, field: None
|
||||
try:
|
||||
self.assertIsNone(item.get_item_price_rate("ITM-X"))
|
||||
finally:
|
||||
frappe.db.get_single_value = original
|
||||
|
||||
|
||||
class TestCascadeFlag(FrappeTestCase):
|
||||
def test_flag_round_trip(self):
|
||||
from crm.integrations.erpnext.utils import CASCADE_FLAG, in_cascade
|
||||
|
||||
self.assertFalse(in_cascade())
|
||||
frappe.flags[CASCADE_FLAG] = True
|
||||
try:
|
||||
self.assertTrue(in_cascade())
|
||||
finally:
|
||||
frappe.flags[CASCADE_FLAG] = False
|
||||
87
crm/integrations/erpnext/user_permission.py
Normal file
87
crm/integrations/erpnext/user_permission.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import frappe
|
||||
|
||||
from crm.integrations.erpnext.mirror_sync import MIRROR_FLAG, MirrorSync
|
||||
from crm.integrations.erpnext.utils import should_sync
|
||||
|
||||
|
||||
def _dedup_extra(doc) -> dict:
|
||||
# Keep a blank applicable_for as None so dedup/find filters match the stored
|
||||
# NULL value (an empty string would never match a NULL column).
|
||||
return {
|
||||
"applicable_for": doc.applicable_for or None,
|
||||
"apply_to_all_doctypes": doc.apply_to_all_doctypes,
|
||||
}
|
||||
|
||||
|
||||
def _sync(doc) -> MirrorSync:
|
||||
return MirrorSync(doc, "allow", "for_value", extra_dedup=_dedup_extra)
|
||||
|
||||
|
||||
def before_validate(doc, method=None):
|
||||
sync = _sync(doc)
|
||||
old = doc.get_doc_before_save()
|
||||
if old and sync.has_data_updated(old) and sync.sync_active():
|
||||
sync.delete_mirror_for(old)
|
||||
|
||||
|
||||
def after_insert(doc, method=None):
|
||||
sync = _sync(doc)
|
||||
if sync.should_mirror():
|
||||
sync.create_mirror()
|
||||
|
||||
|
||||
def on_update(doc, method=None):
|
||||
sync = _sync(doc)
|
||||
if not sync.should_mirror():
|
||||
return
|
||||
old = doc.get_doc_before_save()
|
||||
if old and sync.has_data_updated(old):
|
||||
sync.create_mirror()
|
||||
else:
|
||||
sync.sync_state_to_mirror()
|
||||
|
||||
|
||||
def on_trash(doc, method=None):
|
||||
sync = _sync(doc)
|
||||
if not sync.should_mirror():
|
||||
return
|
||||
mirror = sync.find_mirror()
|
||||
if not mirror:
|
||||
return
|
||||
sync.set_mirror_flags(mirror)
|
||||
mirror.delete(ignore_permissions=True)
|
||||
|
||||
|
||||
def sync_user_permissions():
|
||||
"""Bulk-sync User Permissions between Item and CRM Product. Idempotent."""
|
||||
if not should_sync():
|
||||
return
|
||||
|
||||
product_perms = frappe.get_list("User Permission", filters={"allow": "CRM Product"}, fields=["*"])
|
||||
item_perms = frappe.get_list("User Permission", filters={"allow": "Item"}, fields=["*"])
|
||||
existing_product = {(p.user, p.for_value) for p in product_perms}
|
||||
existing_item = {(p.user, p.for_value) for p in item_perms}
|
||||
|
||||
for perm in item_perms:
|
||||
product = frappe.db.get_value("Item", perm.for_value, "crm_product_code")
|
||||
if not product or (perm.user, product) in existing_product:
|
||||
continue
|
||||
_mirror_permission(perm, "CRM Product", product)
|
||||
existing_product.add((perm.user, product))
|
||||
|
||||
for perm in product_perms:
|
||||
item = frappe.db.get_value("CRM Product", perm.for_value, "erpnext_item_code")
|
||||
if not item or (perm.user, item) in existing_item:
|
||||
continue
|
||||
_mirror_permission(perm, "Item", item)
|
||||
existing_item.add((perm.user, item))
|
||||
|
||||
|
||||
def _mirror_permission(src, target_doctype, target_value):
|
||||
data = dict(src)
|
||||
for k in ("name", "owner", "creation", "modified", "modified_by", "idx"):
|
||||
data.pop(k, None)
|
||||
data.update({"doctype": "User Permission", "allow": target_doctype, "for_value": target_value})
|
||||
doc = frappe.get_doc(data)
|
||||
doc.flags[MIRROR_FLAG] = True
|
||||
doc.insert(ignore_permissions=True)
|
||||
120
crm/integrations/erpnext/utils.py
Normal file
120
crm/integrations/erpnext/utils.py
Normal file
@@ -0,0 +1,120 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.rename_doc import rename_doc
|
||||
|
||||
CASCADE_FLAG = "crm_product_item_cascade_in_progress"
|
||||
ALLOWED_DOCTYPES = ("CRM Product", "Item")
|
||||
|
||||
|
||||
def should_sync() -> bool:
|
||||
"""ERPNext Item to CRM Product sync runs when same-site integration is enabled."""
|
||||
if "erpnext" not in frappe.get_installed_apps():
|
||||
return False
|
||||
settings = frappe.get_cached_doc("ERPNext CRM Settings")
|
||||
return bool(settings.enabled) and not settings.is_erpnext_in_different_site
|
||||
|
||||
|
||||
def should_push_to_erpnext() -> bool:
|
||||
"""CRM Product to ERPNext Item sync is optional until CRM supports ERPNext tax details."""
|
||||
if not should_sync():
|
||||
return False
|
||||
settings = frappe.get_cached_doc("ERPNext CRM Settings")
|
||||
return bool(settings.sync_products)
|
||||
|
||||
|
||||
def in_cascade() -> bool:
|
||||
return bool(frappe.flags.get(CASCADE_FLAG))
|
||||
|
||||
|
||||
def set_links(item_code: str, crm_product_name: str) -> None:
|
||||
"""Permission-bypassing link bookkeeping. Mirrors HD's set_links."""
|
||||
frappe.db.set_value(
|
||||
"CRM Product", crm_product_name, "erpnext_item_code", item_code, update_modified=False
|
||||
)
|
||||
frappe.db.set_value("Item", item_code, "crm_product_code", crm_product_name, update_modified=False)
|
||||
|
||||
|
||||
def _other_side(self_doctype: str) -> tuple[str, str]:
|
||||
if self_doctype == "CRM Product":
|
||||
return "Item", "crm_product_code"
|
||||
return "CRM Product", "erpnext_item_code"
|
||||
|
||||
|
||||
def find_target_for(doctype: str | None, value: str | None) -> tuple[str, str] | None:
|
||||
if not doctype or not value:
|
||||
return None
|
||||
if doctype == "CRM Product":
|
||||
item = frappe.db.get_value("Item", {"crm_product_code": value}, "name")
|
||||
return ("Item", item) if item else None
|
||||
if doctype == "Item":
|
||||
prod = frappe.db.get_value("CRM Product", {"erpnext_item_code": value}, "name")
|
||||
return ("CRM Product", prod) if prod else None
|
||||
return None
|
||||
|
||||
|
||||
def validate_rename_conflict(self_doctype, olddn, newdn, merge):
|
||||
if in_cascade() or not _should_sync_direction(self_doctype):
|
||||
return
|
||||
other_doctype, other_link_field = _other_side(self_doctype)
|
||||
linked = frappe.db.get_value(other_doctype, {other_link_field: olddn}, "name")
|
||||
if not linked:
|
||||
return
|
||||
if merge:
|
||||
surviving = frappe.db.get_value(other_doctype, {other_link_field: newdn}, "name")
|
||||
if surviving:
|
||||
return
|
||||
if linked == newdn:
|
||||
return
|
||||
if frappe.db.exists(other_doctype, newdn):
|
||||
existing_link = frappe.db.get_value(other_doctype, newdn, other_link_field)
|
||||
if existing_link != olddn:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot rename: an unrelated {0} '{1}' already exists on the "
|
||||
"other side. Resolve manually first."
|
||||
).format(other_doctype, newdn)
|
||||
)
|
||||
|
||||
|
||||
def cascade_rename(self_doctype, olddn, newdn, merge):
|
||||
if in_cascade() or not _should_sync_direction(self_doctype):
|
||||
return
|
||||
other_doctype, other_link_field = _other_side(self_doctype)
|
||||
linked = frappe.db.get_value(other_doctype, {other_link_field: olddn}, "name")
|
||||
if not linked:
|
||||
return
|
||||
frappe.flags[CASCADE_FLAG] = True
|
||||
try:
|
||||
if merge:
|
||||
surviving = frappe.db.get_value(other_doctype, {other_link_field: newdn}, "name")
|
||||
if surviving and surviving != linked:
|
||||
rename_doc(other_doctype, linked, surviving, merge=True, ignore_permissions=True)
|
||||
target = surviving
|
||||
else:
|
||||
if linked != newdn:
|
||||
rename_doc(other_doctype, linked, newdn, ignore_permissions=True)
|
||||
target = newdn
|
||||
else:
|
||||
if linked != newdn:
|
||||
rename_doc(other_doctype, linked, newdn, ignore_permissions=True)
|
||||
target = newdn
|
||||
_resync_links(self_doctype, newdn, target)
|
||||
finally:
|
||||
frappe.flags[CASCADE_FLAG] = False
|
||||
|
||||
|
||||
def _resync_links(self_doctype, self_name, other_name):
|
||||
if self_doctype == "CRM Product":
|
||||
prod, item = self_name, other_name
|
||||
else:
|
||||
prod, item = other_name, self_name
|
||||
if frappe.db.exists("CRM Product", prod):
|
||||
frappe.db.set_value("CRM Product", prod, "erpnext_item_code", item, update_modified=False)
|
||||
if frappe.db.exists("Item", item):
|
||||
frappe.db.set_value("Item", item, "crm_product_code", prod, update_modified=False)
|
||||
|
||||
|
||||
def _should_sync_direction(self_doctype):
|
||||
if self_doctype == "CRM Product":
|
||||
return should_push_to_erpnext()
|
||||
return should_sync()
|
||||
342
crm/integrations/exotel/handler.py
Normal file
342
crm/integrations/exotel/handler.py
Normal file
@@ -0,0 +1,342 @@
|
||||
import frappe
|
||||
import requests
|
||||
from frappe import _
|
||||
from frappe.integrations.utils import create_request_log
|
||||
|
||||
from crm.integrations.api import get_contact_by_phone_number
|
||||
|
||||
# Endpoints for webhook
|
||||
|
||||
# Incoming Call:
|
||||
# <site>/api/method/crm.integrations.exotel.handler.handle_request?key=<exotel-webhook-verify-token>
|
||||
|
||||
# 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
|
||||
|
||||
request_log = create_request_log(
|
||||
kwargs,
|
||||
request_description="Exotel Call",
|
||||
service_name="Exotel",
|
||||
request_headers=frappe.request.headers,
|
||||
is_remote_request=1,
|
||||
)
|
||||
|
||||
try:
|
||||
request_log.status = "Completed"
|
||||
exotel_settings = get_exotel_settings()
|
||||
if not exotel_settings.enabled:
|
||||
return
|
||||
|
||||
call_payload = kwargs
|
||||
|
||||
frappe.publish_realtime("exotel_call", call_payload)
|
||||
status = call_payload.get("Status")
|
||||
if status == "free":
|
||||
return
|
||||
|
||||
if call_log := get_call_log(call_payload):
|
||||
update_call_log(call_payload, call_log=call_log)
|
||||
elif call_payload.get("Direction") == "incoming":
|
||||
create_call_log(
|
||||
call_id=call_payload.get("CallSid"),
|
||||
from_number=call_payload.get("CallFrom"),
|
||||
to_number=call_payload.get("DialWhomNumber"),
|
||||
medium=call_payload.get("To"),
|
||||
status=get_call_log_status(call_payload),
|
||||
agent=call_payload.get("AgentEmail"),
|
||||
)
|
||||
elif agent := frappe.request.args.get("agent"):
|
||||
create_call_log(
|
||||
call_id=call_payload.get("CallSid"),
|
||||
from_number=call_payload.get("From") or call_payload.get("CallFrom"),
|
||||
to_number=call_payload.get("DialWhomNumber") or call_payload.get("To"),
|
||||
medium=call_payload.get("To"),
|
||||
status=get_call_log_status(call_payload, direction=call_payload.get("Direction")),
|
||||
agent=agent,
|
||||
call_type="Outgoing",
|
||||
)
|
||||
except Exception:
|
||||
request_log.status = "Failed"
|
||||
request_log.error = frappe.get_traceback()
|
||||
frappe.db.rollback()
|
||||
frappe.log_error(title="Error while creating/updating call record")
|
||||
frappe.db.commit()
|
||||
finally:
|
||||
request_log.save(ignore_permissions=True)
|
||||
frappe.db.commit()
|
||||
|
||||
|
||||
# Outgoing Call
|
||||
@frappe.whitelist()
|
||||
def make_a_call(to_number: str, from_number: str | None = None, caller_id: str | None = None):
|
||||
if not is_integration_enabled():
|
||||
frappe.throw(_("Please setup Exotel integration"), title=_("Integration Not Enabled"))
|
||||
|
||||
endpoint = get_exotel_endpoint("Calls/connect.json?details=true")
|
||||
|
||||
if not from_number:
|
||||
from_number = frappe.get_value("CRM Telephony Agent", {"user": frappe.session.user}, "mobile_no")
|
||||
|
||||
if not caller_id:
|
||||
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 Telephony Agent"), title=_("Mobile Number Missing")
|
||||
)
|
||||
|
||||
record_call = frappe.db.get_single_value("CRM Exotel Settings", "record_call")
|
||||
|
||||
call_log_creation_failed = False
|
||||
try:
|
||||
response = requests.post(
|
||||
endpoint,
|
||||
data={
|
||||
"From": from_number,
|
||||
"To": to_number,
|
||||
"CallerId": caller_id,
|
||||
"Record": "true" if record_call else "false",
|
||||
"StatusCallback": get_status_updater_url(),
|
||||
"StatusCallbackEvents[0]": "terminal",
|
||||
"StatusCallbackEvents[1]": "answered",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.HTTPError:
|
||||
if exc := response.json().get("RestException"):
|
||||
frappe.throw(exc.get("Message"), title=_("Exotel Exception"))
|
||||
else:
|
||||
res = response.json()
|
||||
call_payload = res.get("Call", {})
|
||||
|
||||
try:
|
||||
create_call_log(
|
||||
call_id=call_payload.get("Sid"),
|
||||
from_number=call_payload.get("From"),
|
||||
to_number=call_payload.get("To"),
|
||||
medium=call_payload.get("PhoneNumberSid"),
|
||||
call_type="Outgoing",
|
||||
agent=frappe.session.user,
|
||||
)
|
||||
except Exception:
|
||||
frappe.db.rollback()
|
||||
frappe.log_error(title="Error while creating Exotel call log")
|
||||
frappe.db.commit()
|
||||
call_log_creation_failed = True
|
||||
|
||||
call_details = response.json().get("Call", {})
|
||||
call_details["CallSid"] = call_details.get("Sid", "")
|
||||
call_details["call_log_creation_failed"] = call_log_creation_failed
|
||||
return call_details
|
||||
|
||||
|
||||
def get_exotel_endpoint(action=None, version="v1"):
|
||||
settings = get_exotel_settings()
|
||||
return "https://{api_key}:{api_token}@{subdomain}/{version}/Accounts/{sid}/{action}".format(
|
||||
api_key=settings.api_key,
|
||||
api_token=settings.get_password("api_token"),
|
||||
subdomain=settings.subdomain,
|
||||
version=version,
|
||||
sid=settings.account_sid,
|
||||
action=action,
|
||||
)
|
||||
|
||||
|
||||
def get_all_exophones():
|
||||
endpoint = get_exotel_endpoint("IncomingPhoneNumbers", "v2_beta")
|
||||
response = requests.get(endpoint)
|
||||
return [phone.get("friendly_name") for phone in response.json().get("incoming_phone_numbers", [])]
|
||||
|
||||
|
||||
def get_status_updater_url():
|
||||
from frappe.utils.data import get_url
|
||||
|
||||
webhook_verify_token = frappe.db.get_single_value("CRM Exotel Settings", "webhook_verify_token")
|
||||
return get_url(
|
||||
f"api/method/crm.integrations.exotel.handler.handle_request"
|
||||
f"?key={webhook_verify_token}&agent={frappe.session.user}"
|
||||
)
|
||||
|
||||
|
||||
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/<exotel-integration-method>?key=<exotel-webhook=verify-token>
|
||||
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)
|
||||
|
||||
|
||||
# Call Log Functions
|
||||
def create_call_log(
|
||||
call_id,
|
||||
from_number,
|
||||
to_number,
|
||||
medium,
|
||||
agent,
|
||||
status="Ringing",
|
||||
call_type="Incoming",
|
||||
):
|
||||
call_log = frappe.new_doc("CRM Call Log")
|
||||
call_log.id = call_id
|
||||
call_log.to = to_number
|
||||
call_log.medium = medium
|
||||
call_log.type = call_type
|
||||
call_log.status = status
|
||||
call_log.telephony_medium = "Exotel"
|
||||
setattr(call_log, "from", from_number)
|
||||
|
||||
if call_type == "Incoming":
|
||||
call_log.receiver = agent
|
||||
else:
|
||||
call_log.caller = agent
|
||||
|
||||
# link call log with lead/deal
|
||||
contact_number = from_number if call_type == "Incoming" else to_number
|
||||
link(contact_number, call_log)
|
||||
|
||||
call_log.save(ignore_permissions=True)
|
||||
frappe.db.commit()
|
||||
return call_log
|
||||
|
||||
|
||||
def link(contact_number, call_log):
|
||||
contact = get_contact_by_phone_number(contact_number)
|
||||
if contact.get("name"):
|
||||
doctype = "Contact"
|
||||
docname = contact.get("name")
|
||||
if contact.get("lead"):
|
||||
doctype = "CRM Lead"
|
||||
docname = contact.get("lead")
|
||||
elif contact.get("deal"):
|
||||
doctype = "CRM Deal"
|
||||
docname = contact.get("deal")
|
||||
call_log.link_with_reference_doc(doctype, docname)
|
||||
|
||||
|
||||
def get_call_log(call_payload):
|
||||
call_log_id = call_payload.get("CallSid")
|
||||
if frappe.db.exists("CRM Call Log", call_log_id):
|
||||
return frappe.get_doc("CRM Call Log", call_log_id)
|
||||
|
||||
|
||||
# Exotel reports call status in lowercase, hyphenated form. Map it onto the
|
||||
# options of the CRM Call Log status field, which is a mandatory Select.
|
||||
# https://support.exotel.com/support/solutions/articles/27323-call-status
|
||||
EXOTEL_CALL_STATUSES = {
|
||||
"initiated": "Initiated",
|
||||
"ringing": "Ringing",
|
||||
"in-progress": "In Progress",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed",
|
||||
"busy": "Busy",
|
||||
"no-answer": "No Answer",
|
||||
"queued": "Queued",
|
||||
"canceled": "Canceled",
|
||||
"cancelled": "Canceled",
|
||||
}
|
||||
|
||||
# Exotel sends these when a leg never connected, eg. the caller hung up before
|
||||
# an agent picked up. There is no status to record, not an unknown one.
|
||||
# Leg2Status is documented as empty when there is no second leg:
|
||||
# https://support.exotel.com/support/solutions/articles/27323-call-status
|
||||
EXOTEL_EMPTY_STATUSES = ("", "null", "none", "undefined")
|
||||
|
||||
|
||||
def normalize_call_status(status):
|
||||
"""Map a raw Exotel status onto a CRM Call Log status, or None if there isn't one."""
|
||||
if not status:
|
||||
return None
|
||||
|
||||
status = str(status).strip().lower().replace("_", "-").replace(" ", "-")
|
||||
if status in EXOTEL_EMPTY_STATUSES:
|
||||
return None
|
||||
|
||||
if status not in EXOTEL_CALL_STATUSES:
|
||||
frappe.log_error(
|
||||
title="Unknown Exotel call status",
|
||||
message=f"Exotel sent call status {status!r}, which is not in EXOTEL_CALL_STATUSES",
|
||||
)
|
||||
return None
|
||||
|
||||
return EXOTEL_CALL_STATUSES[status]
|
||||
|
||||
|
||||
def get_call_log_status(call_payload, direction="inbound"):
|
||||
if direction == "outbound-api" or direction == "outbound-dial":
|
||||
status = normalize_call_status(call_payload.get("Status"))
|
||||
if status == "Busy":
|
||||
# the call is still ringing on another leg
|
||||
status = "Ringing"
|
||||
if status:
|
||||
return status
|
||||
|
||||
call_type = call_payload.get("CallType")
|
||||
status = normalize_call_status(call_payload.get("DialCallStatus") or call_payload.get("Status"))
|
||||
|
||||
if call_type == "completed":
|
||||
status = "Completed"
|
||||
elif status == "Busy":
|
||||
status = "Ringing"
|
||||
|
||||
return status
|
||||
|
||||
|
||||
def update_call_log(call_payload, status="Ringing", call_log=None):
|
||||
direction = call_payload.get("Direction")
|
||||
call_log = call_log or get_call_log(call_payload)
|
||||
status = get_call_log_status(call_payload, direction)
|
||||
try:
|
||||
if call_log:
|
||||
if status:
|
||||
call_log.status = status
|
||||
# resetting this because call might be redirected to other number
|
||||
call_log.to = call_payload.get("DialWhomNumber") or call_payload.get("To")
|
||||
call_log.duration = (
|
||||
call_payload.get("DialCallDuration") or call_payload.get("ConversationDuration") or 0
|
||||
)
|
||||
|
||||
call_log.recording_url = (
|
||||
call_payload.get("RecordingUrl") if call_payload.get("RecordingUrl") else ""
|
||||
)
|
||||
|
||||
call_log.start_time = call_payload.get("StartTime")
|
||||
call_log.end_time = call_payload.get("EndTime")
|
||||
|
||||
if direction == "incoming" and call_payload.get("AgentEmail"):
|
||||
call_log.receiver = call_payload.get("AgentEmail")
|
||||
|
||||
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()
|
||||
222
crm/integrations/twilio/api.py
Normal file
222
crm/integrations/twilio/api.py
Normal file
@@ -0,0 +1,222 @@
|
||||
import json
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from twilio.twiml.voice_response import VoiceResponse
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
from crm.integrations.api import get_contact_by_phone_number
|
||||
|
||||
from .twilio_handler import IncomingCall, Twilio, TwilioCallDetails
|
||||
|
||||
|
||||
def validate_twilio_request(args, require_application_sid: bool = False):
|
||||
twilio = Twilio.connect()
|
||||
if not twilio:
|
||||
frappe.throw(_("Twilio configuration is missing"), frappe.PermissionError)
|
||||
|
||||
account_sid = frappe.utils.cstr(args.get("AccountSid"))
|
||||
if not account_sid or account_sid != frappe.utils.cstr(twilio.account_sid):
|
||||
frappe.throw(_("Invalid Twilio account"), frappe.PermissionError)
|
||||
|
||||
if require_application_sid:
|
||||
application_sid = frappe.utils.cstr(args.get("ApplicationSid"))
|
||||
if not application_sid or application_sid != frappe.utils.cstr(twilio.application_sid):
|
||||
frappe.throw(_("Invalid Twilio application"), frappe.PermissionError)
|
||||
|
||||
return twilio
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def is_enabled():
|
||||
return frappe.db.get_single_value("CRM Twilio Settings", "enabled")
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def generate_access_token():
|
||||
"""Returns access token that is required to authenticate Twilio Client SDK."""
|
||||
twilio = Twilio.connect()
|
||||
if not twilio:
|
||||
return {}
|
||||
|
||||
from_number = frappe.db.get_value("CRM Telephony Agent", 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",
|
||||
}
|
||||
|
||||
token = twilio.generate_voice_access_token(identity=frappe.session.user)
|
||||
return {"token": frappe.safe_decode(token)}
|
||||
|
||||
|
||||
# webhook authenticity is enforced by validate_twilio_request(); guest access itself is unchanged
|
||||
@frappe.whitelist(allow_guest=True) # nosemgrep: guest-whitelisted-method
|
||||
def voice(**kwargs):
|
||||
"""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()
|
||||
user = Twilio.emailid_from_identity(identity)
|
||||
return frappe.db.get_value("CRM Telephony Agent", user, "twilio_number")
|
||||
|
||||
args = frappe._dict(kwargs)
|
||||
twilio = validate_twilio_request(args, require_application_sid=True)
|
||||
|
||||
# Generate TwiML instructions to make a call
|
||||
from_number = _get_caller_number(args.Caller)
|
||||
if not from_number:
|
||||
resp = VoiceResponse()
|
||||
resp.say(_("Your account is not configured with a phone number. Please contact your administrator."))
|
||||
return Response(resp.to_xml(), mimetype="text/xml")
|
||||
|
||||
call_details = TwilioCallDetails(args, call_from=from_number)
|
||||
try:
|
||||
create_call_log(call_details)
|
||||
except Exception:
|
||||
frappe.db.rollback()
|
||||
frappe.log_error(title="Error while creating Twilio call log")
|
||||
frappe.db.commit()
|
||||
return Response(_call_failed_response().to_xml(), mimetype="text/xml")
|
||||
|
||||
resp = twilio.generate_twilio_dial_response(from_number, args.To)
|
||||
return Response(resp.to_xml(), mimetype="text/xml")
|
||||
|
||||
|
||||
# webhook authenticity is enforced by validate_twilio_request(); guest access itself is unchanged
|
||||
@frappe.whitelist(allow_guest=True) # nosemgrep: guest-whitelisted-method
|
||||
def twilio_incoming_call_handler(**kwargs):
|
||||
args = frappe._dict(kwargs)
|
||||
validate_twilio_request(args)
|
||||
|
||||
call_details = TwilioCallDetails(args)
|
||||
try:
|
||||
create_call_log(call_details)
|
||||
except Exception:
|
||||
frappe.db.rollback()
|
||||
frappe.log_error(title="Error while creating Twilio call log")
|
||||
frappe.db.commit()
|
||||
return Response(_call_failed_response().to_xml(), mimetype="text/xml")
|
||||
|
||||
resp = IncomingCall(args.From, args.To).process()
|
||||
return Response(resp.to_xml(), mimetype="text/xml")
|
||||
|
||||
|
||||
def _call_failed_response():
|
||||
resp = VoiceResponse()
|
||||
resp.say(_("We're unable to connect your call right now. Please try again later."))
|
||||
return resp
|
||||
|
||||
|
||||
def create_call_log(call_details: TwilioCallDetails):
|
||||
details = call_details.to_dict()
|
||||
|
||||
call_log = frappe.get_doc({**details, "doctype": "CRM Call Log", "telephony_medium": "Twilio"})
|
||||
|
||||
# link call log with lead/deal
|
||||
contact_number = details.get("from") if details.get("type") == "Incoming" else details.get("to")
|
||||
link(contact_number, call_log)
|
||||
|
||||
call_log.save(ignore_permissions=True)
|
||||
frappe.db.commit()
|
||||
return call_log
|
||||
|
||||
|
||||
def link(contact_number, call_log):
|
||||
contact = get_contact_by_phone_number(contact_number)
|
||||
if contact.get("name"):
|
||||
doctype = "Contact"
|
||||
docname = contact.get("name")
|
||||
if contact.get("lead"):
|
||||
doctype = "CRM Lead"
|
||||
docname = contact.get("lead")
|
||||
elif contact.get("deal"):
|
||||
doctype = "CRM Deal"
|
||||
docname = contact.get("deal")
|
||||
call_log.link_with_reference_doc(doctype, docname)
|
||||
|
||||
|
||||
def update_call_log(call_sid, status=None):
|
||||
"""Update call log status."""
|
||||
twilio = Twilio.connect()
|
||||
if not (twilio and frappe.db.exists("CRM Call Log", call_sid)):
|
||||
return
|
||||
|
||||
try:
|
||||
call_details = twilio.get_call_info(call_sid)
|
||||
call_log = frappe.get_doc("CRM Call Log", call_sid)
|
||||
call_log.status = TwilioCallDetails.get_call_status(status or call_details.status)
|
||||
call_log.duration = call_details.duration
|
||||
call_log.start_time = get_datetime_from_timestamp(call_details.start_time)
|
||||
call_log.end_time = get_datetime_from_timestamp(call_details.end_time)
|
||||
call_log.save(ignore_permissions=True)
|
||||
frappe.db.commit()
|
||||
return call_log
|
||||
except Exception:
|
||||
frappe.log_error(title="Error while updating call record")
|
||||
frappe.db.commit()
|
||||
|
||||
|
||||
def get_twilio_settings():
|
||||
return frappe.get_single("CRM Twilio Settings")
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def update_recording_info(**kwargs):
|
||||
args = frappe._dict(kwargs)
|
||||
validate_twilio_request(args)
|
||||
|
||||
recording_url = args.RecordingUrl
|
||||
call_sid = args.CallSid
|
||||
call_log = update_call_log(call_sid)
|
||||
if not call_log:
|
||||
frappe.throw(_("Call log not found"), frappe.DoesNotExistError)
|
||||
|
||||
try:
|
||||
frappe.db.set_value("CRM Call Log", call_sid, "recording_url", recording_url)
|
||||
frappe.db.commit()
|
||||
except Exception as exc:
|
||||
frappe.log_error(title=_("Failed to capture Twilio recording"))
|
||||
raise exc
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def update_call_status_info(**kwargs):
|
||||
args = frappe._dict(kwargs)
|
||||
validate_twilio_request(args)
|
||||
|
||||
parent_call_sid = args.ParentCallSid
|
||||
call_log = update_call_log(parent_call_sid, status=args.CallStatus)
|
||||
if not call_log:
|
||||
frappe.throw(_("Call log not found"), frappe.DoesNotExistError)
|
||||
|
||||
call_info = {
|
||||
"ParentCallSid": args.ParentCallSid,
|
||||
"CallSid": args.CallSid,
|
||||
"CallStatus": args.CallStatus,
|
||||
"CallDuration": args.CallDuration,
|
||||
"From": args.From,
|
||||
"To": args.To,
|
||||
}
|
||||
|
||||
try:
|
||||
client = Twilio.get_twilio_client()
|
||||
client.calls(args.ParentCallSid).user_defined_messages.create(content=json.dumps(call_info))
|
||||
except Exception as exc:
|
||||
frappe.log_error(title=_("Failed to update Twilio call status"))
|
||||
raise exc
|
||||
|
||||
|
||||
def get_datetime_from_timestamp(timestamp):
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
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")
|
||||
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")
|
||||
267
crm/integrations/twilio/twilio_handler.py
Normal file
267
crm/integrations/twilio/twilio_handler.py
Normal file
@@ -0,0 +1,267 @@
|
||||
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."""
|
||||
|
||||
def __init__(self, settings):
|
||||
"""
|
||||
:param settings: `CRM Twilio Settings` doctype
|
||||
"""
|
||||
self.settings = settings
|
||||
self.account_sid = settings.account_sid
|
||||
self.application_sid = settings.twiml_sid
|
||||
self.api_key = settings.api_key
|
||||
self.api_secret = settings.get_password("api_secret")
|
||||
self.twilio_client = self.get_twilio_client()
|
||||
|
||||
@classmethod
|
||||
def connect(cls):
|
||||
"""Read CRM Twilio Settings and return a configured Twilio instance, or None if not enabled."""
|
||||
settings = frappe.get_doc("CRM Twilio Settings")
|
||||
if not (settings and settings.enabled):
|
||||
return
|
||||
return Twilio(settings=settings)
|
||||
|
||||
def get_phone_numbers(self):
|
||||
"""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."""
|
||||
# identity is used by twilio to identify the user uniqueness at browser(or any endpoints).
|
||||
identity = self.safe_identity(identity)
|
||||
|
||||
# Create access token with credentials
|
||||
token = AccessToken(self.account_sid, self.api_key, self.api_secret, identity=identity, ttl=ttl)
|
||||
|
||||
# Create a Voice grant and add to token
|
||||
voice_grant = VoiceGrant(
|
||||
outgoing_application_sid=self.application_sid,
|
||||
incoming_allow=True, # Allow incoming calls
|
||||
)
|
||||
token.add_grant(voice_grant)
|
||||
return token.to_jwt()
|
||||
|
||||
@classmethod
|
||||
def safe_identity(cls, identity: str):
|
||||
"""Create a safe identity by replacing unsupported special characters `@` with (at)).
|
||||
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)")
|
||||
|
||||
@classmethod
|
||||
def emailid_from_identity(cls, identity: str):
|
||||
"""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)
|
||||
|
||||
def get_update_call_status_callback_url(self):
|
||||
url_path = "/api/method/crm.integrations.twilio.api.update_call_status_info"
|
||||
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."""
|
||||
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",
|
||||
)
|
||||
dial.number(
|
||||
to_number,
|
||||
status_callback_event="initiated ringing answered completed",
|
||||
status_callback=self.get_update_call_status_callback_url(),
|
||||
status_callback_method="POST",
|
||||
)
|
||||
resp.append(dial)
|
||||
return resp
|
||||
|
||||
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."""
|
||||
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",
|
||||
)
|
||||
dial.client(
|
||||
client,
|
||||
status_callback_event="initiated ringing answered completed",
|
||||
status_callback=self.get_update_call_status_callback_url(),
|
||||
status_callback_method="POST",
|
||||
)
|
||||
resp.append(dial)
|
||||
return resp
|
||||
|
||||
@classmethod
|
||||
def get_twilio_client(self):
|
||||
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("CRM Twilio Settings", "CRM 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
|
||||
self.to_number = to_number
|
||||
self.meta = meta
|
||||
|
||||
def process(self):
|
||||
"""Process the incoming call
|
||||
* Figure out who is going to pick the call (call attender)
|
||||
* Check call attender settings and forward the call to Phone
|
||||
"""
|
||||
twilio = Twilio.connect()
|
||||
owners = get_twilio_number_owners(self.to_number)
|
||||
attender = get_the_call_attender(owners, self.from_number)
|
||||
|
||||
if not attender:
|
||||
resp = VoiceResponse()
|
||||
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"])
|
||||
else:
|
||||
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")
|
||||
{
|
||||
'owner1': {'name': '..', 'mobile_no': '..', 'call_receiving_device': '...'},
|
||||
'owner2': {....}
|
||||
}
|
||||
"""
|
||||
# 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 == "+"])
|
||||
user_voice_settings = frappe.get_all(
|
||||
"CRM Telephony Agent",
|
||||
filters={"twilio_number": phone_number},
|
||||
fields=["name", "call_receiving_device"],
|
||||
)
|
||||
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_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(
|
||||
"""
|
||||
SELECT `user`
|
||||
FROM `tabSessions`
|
||||
WHERE `user` IN %(users)s
|
||||
""",
|
||||
{"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
|
||||
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")
|
||||
if not deal_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
|
||||
):
|
||||
return details
|
||||
|
||||
|
||||
class TwilioCallDetails:
|
||||
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")
|
||||
|
||||
def get_direction(self):
|
||||
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")
|
||||
|
||||
def get_to_number(self):
|
||||
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()
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert call details into dict."""
|
||||
direction = self.get_direction()
|
||||
from_number = self.get_from_number()
|
||||
to_number = self.get_to_number()
|
||||
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 ""
|
||||
else:
|
||||
owners = get_twilio_number_owners(to_number)
|
||||
attender = get_the_call_attender(owners, from_number)
|
||||
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,
|
||||
}
|
||||
16
crm/integrations/twilio/utils.py
Normal file
16
crm/integrations/twilio/utils.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from frappe.utils import get_url
|
||||
|
||||
|
||||
def get_public_url(path: str | None = None):
|
||||
return get_url().split(":8", 1)[0] + path
|
||||
|
||||
|
||||
def merge_dicts(d1: dict, d2: dict):
|
||||
"""Merge dicts of dictionaries.
|
||||
>>> merge_dicts(
|
||||
{'name1': {'age': 20}, 'name2': {'age': 30}},
|
||||
{'name1': {'phone': '+xxx'}, 'name2': {'phone': '+yyy'}, 'name3': {'phone': '+zzz'}}
|
||||
)
|
||||
... {'name1': {'age': 20, 'phone': '+xxx'}, 'name2': {'age': 30, 'phone': '+yyy'}}
|
||||
"""
|
||||
return {k: {**v, **d2.get(k, {})} for k, v in d1.items()}
|
||||
Reference in New Issue
Block a user