-
Notifications
You must be signed in to change notification settings - Fork 279
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
# What this PR does Added support for [Exotel](https://exotel.com/) call provider. Features: - Sending verification code through SMS - Making test call - Making notification call ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] Added the relevant release notes label (see labels prefixed w/ `release:`). These labels dictate how your PR will show up in the autogenerated release notes.
- Loading branch information
Showing
15 changed files
with
478 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
# Generated by Django 4.2.11 on 2024-05-25 14:59 | ||
|
||
from django.db import migrations, models | ||
import django.db.models.deletion | ||
|
||
|
||
class Migration(migrations.Migration): | ||
|
||
initial = True | ||
|
||
dependencies = [ | ||
('phone_notifications', '0001_initial'), | ||
] | ||
|
||
operations = [ | ||
migrations.CreateModel( | ||
name='ExotelPhoneCall', | ||
fields=[ | ||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||
('created_at', models.DateTimeField(auto_now_add=True)), | ||
('status', models.PositiveSmallIntegerField(blank=True, choices=[(10, 'queued'), (20, 'in-progress'), (30, 'completed'), (40, 'failed'), (50, 'busy'), (60, 'no-answer')], null=True)), | ||
('call_id', models.CharField(blank=True, max_length=50)), | ||
('phone_call_record', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_related', related_query_name='%(app_label)s_%(class)ss', to='phone_notifications.phonecallrecord')), | ||
], | ||
options={ | ||
'abstract': False, | ||
}, | ||
), | ||
] |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
from .phone_call import ExotelCallStatuses, ExotelPhoneCall # noqa: F401 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
from django.db import models | ||
|
||
from apps.phone_notifications.phone_provider import ProviderPhoneCall | ||
|
||
|
||
class ExotelCallStatuses: | ||
QUEUED = 10 | ||
IN_PROGRESS = 20 | ||
COMPLETED = 30 | ||
FAILED = 40 | ||
BUSY = 50 | ||
NO_ANSWER = 60 | ||
|
||
CHOICES = ( | ||
(QUEUED, "queued"), | ||
(IN_PROGRESS, "in-progress"), | ||
(COMPLETED, "completed"), | ||
(FAILED, "failed"), | ||
(BUSY, "busy"), | ||
(NO_ANSWER, "no-answer"), | ||
) | ||
|
||
DETERMINANT = { | ||
"queued": QUEUED, | ||
"in-progress": IN_PROGRESS, | ||
"completed": COMPLETED, | ||
"failed": FAILED, | ||
"busy": BUSY, | ||
"no-answer": NO_ANSWER, | ||
} | ||
|
||
|
||
class ExotelPhoneCall(ProviderPhoneCall, models.Model): | ||
created_at = models.DateTimeField(auto_now_add=True) | ||
|
||
status = models.PositiveSmallIntegerField( | ||
blank=True, | ||
null=True, | ||
choices=ExotelCallStatuses.CHOICES, | ||
) | ||
|
||
call_id = models.CharField( | ||
blank=True, | ||
max_length=50, | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,175 @@ | ||
import logging | ||
from random import randint | ||
from string import Template | ||
|
||
import requests | ||
from django.core.cache import cache | ||
from requests.auth import HTTPBasicAuth | ||
|
||
from apps.base.models import LiveSetting | ||
from apps.base.utils import live_settings | ||
from apps.exotel.models.phone_call import ExotelCallStatuses, ExotelPhoneCall | ||
from apps.exotel.status_callback import get_call_status_callback_url | ||
from apps.phone_notifications.exceptions import FailedToMakeCall, FailedToStartVerification | ||
from apps.phone_notifications.phone_provider import PhoneProvider, ProviderFlags | ||
|
||
EXOTEL_ENDPOINT = "https://twilix.exotel.com/v1/Accounts/" | ||
EXOTEL_SMS_API = "/Sms/send.json" | ||
EXOTEL_CALL_API = "/Calls/connect.json" | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
||
class ExotelPhoneProvider(PhoneProvider): | ||
""" | ||
ExotelPhoneProvider is an implementation of phone provider (exotel.com). | ||
""" | ||
|
||
def make_notification_call(self, number: str, message: str) -> ExotelPhoneCall: | ||
body = None | ||
try: | ||
response = self._call_create(number) | ||
response.raise_for_status() | ||
body = response.json() | ||
if not body: | ||
logger.error("ExotelPhoneProvider.make_notification_call: failed, empty body") | ||
raise FailedToMakeCall(graceful_msg=f"Failed make notification call to {number}, empty body") | ||
|
||
sid = body.get("Call").get("Sid") | ||
|
||
if not sid: | ||
logger.error("ExotelPhoneProvider.make_notification_call: failed, missing sid") | ||
raise FailedToMakeCall(graceful_msg=f"Failed make notification call to {number} missing sid") | ||
|
||
logger.info(f"ExotelPhoneProvider.make_notification_call: success, sid {sid}") | ||
|
||
return ExotelPhoneCall( | ||
status=ExotelCallStatuses.IN_PROGRESS, | ||
call_id=sid, | ||
) | ||
|
||
except requests.exceptions.HTTPError as http_err: | ||
logger.error(f"ExotelPhoneProvider.make_notification_call: failed {http_err}") | ||
raise FailedToMakeCall(graceful_msg=f"Failed make notification call to {number} http error") | ||
except (requests.exceptions.ConnectionError, requests.exceptions.JSONDecodeError, TypeError) as err: | ||
logger.error(f"ExotelPhoneProvider.make_notification_call: failed {err}") | ||
raise FailedToMakeCall(graceful_msg=f"Failed make notification call to {number}") | ||
|
||
def make_call(self, number: str, message: str): | ||
body = None | ||
|
||
try: | ||
response = self._call_create(number, False) | ||
response.raise_for_status() | ||
body = response.json() | ||
if not body: | ||
logger.error("ExotelPhoneProvider.make_call: failed, empty body") | ||
raise FailedToMakeCall(graceful_msg=f"Failed make call to {number}, empty body") | ||
|
||
sid = body.get("Call").get("Sid") | ||
|
||
if not sid: | ||
logger.error("ExotelPhoneProvider.make_call: failed, missing sid") | ||
raise FailedToMakeCall(graceful_msg=f"Failed make call to {number} missing sid") | ||
|
||
logger.info(f"ExotelPhoneProvider.make_call: success, sid {sid}") | ||
|
||
except requests.exceptions.HTTPError as http_err: | ||
logger.error(f"ExotelPhoneProvider.make_call: failed {http_err}") | ||
raise FailedToMakeCall(graceful_msg=f"Failed make call to {number} http error") | ||
except (requests.exceptions.ConnectionError, requests.exceptions.JSONDecodeError, TypeError) as err: | ||
logger.error(f"ExotelPhoneProvider.make_call: failed {err}") | ||
raise FailedToMakeCall(graceful_msg=f"Failed make call to {number}") | ||
|
||
def _call_create(self, number: str, with_callback: bool = True): | ||
params = { | ||
"From": number, | ||
"CallerId": live_settings.EXOTEL_CALLER_ID, | ||
"Url": f"http://my.exotel.in/exoml/start/{live_settings.EXOTEL_APP_ID}", | ||
} | ||
|
||
if with_callback: | ||
params.update( | ||
{ | ||
"StatusCallback": get_call_status_callback_url(), | ||
"StatusCallbackContentType": "application/json", | ||
} | ||
) | ||
|
||
auth = HTTPBasicAuth(live_settings.EXOTEL_API_KEY, live_settings.EXOTEL_API_TOKEN) | ||
|
||
exotel_call_url = f"{EXOTEL_ENDPOINT}{live_settings.EXOTEL_ACCOUNT_SID}{EXOTEL_CALL_API}" | ||
|
||
return requests.post(exotel_call_url, auth=auth, params=params) | ||
|
||
def _get_graceful_msg(self, body, number): | ||
if body: | ||
status = body.get("SMSMessage").get("Status") | ||
data = body.get("SMSMessage").get("DetailedStatus") | ||
if status == "failed" and data: | ||
return f"Failed sending sms to {number} with error: {data}" | ||
return f"Failed sending sms to {number}" | ||
|
||
def send_verification_sms(self, number: str): | ||
code = self._generate_verification_code() | ||
cache.set(self._cache_key(number), code, timeout=10 * 60) | ||
|
||
body = None | ||
message = Template(live_settings.EXOTEL_SMS_VERIFICATION_TEMPLATE).safe_substitute(verification_code=code) | ||
try: | ||
response = self._send_verification_code( | ||
number, | ||
message, | ||
) | ||
response.raise_for_status() | ||
body = response.json() | ||
if not body: | ||
logger.error("ExotelPhoneProvider.send_verification_sms: failed, empty body") | ||
raise FailedToStartVerification(graceful_msg=f"Failed sending verification sms to {number}, empty body") | ||
|
||
sid = body.get("SMSMessage").get("Sid") | ||
if not sid: | ||
raise FailedToStartVerification(graceful_msg=self._get_graceful_msg(body, number)) | ||
except requests.exceptions.HTTPError as http_err: | ||
logger.error(f"ExotelPhoneProvider.send_verification_sms: failed {http_err}") | ||
raise FailedToStartVerification(graceful_msg=self._get_graceful_msg(body, number)) | ||
except (requests.exceptions.ConnectionError, requests.exceptions.JSONDecodeError, TypeError) as err: | ||
logger.error(f"ExotelPhoneProvider.send_verification_sms: failed {err}") | ||
raise FailedToStartVerification(graceful_msg=f"Failed sending verification SMS to {number}") | ||
|
||
def _send_verification_code(self, number: str, body: str): | ||
params = { | ||
"From": live_settings.EXOTEL_SMS_SENDER_ID, | ||
"DltEntityId": live_settings.EXOTEL_SMS_DLT_ENTITY_ID, | ||
"To": number, | ||
"Body": body, | ||
} | ||
|
||
auth = HTTPBasicAuth(live_settings.EXOTEL_API_KEY, live_settings.EXOTEL_API_TOKEN) | ||
|
||
exotel_sms_url = f"{EXOTEL_ENDPOINT}{live_settings.EXOTEL_ACCOUNT_SID}{EXOTEL_SMS_API}" | ||
|
||
return requests.post(exotel_sms_url, auth=auth, params=params) | ||
|
||
def finish_verification(self, number, code): | ||
has = cache.get(self._cache_key(number)) | ||
if has is not None and has == code: | ||
return number | ||
else: | ||
return None | ||
|
||
def _cache_key(self, number): | ||
return f"exotel_provider_{number}" | ||
|
||
def _generate_verification_code(self): | ||
return str(randint(100000, 999999)) | ||
|
||
@property | ||
def flags(self) -> ProviderFlags: | ||
return ProviderFlags( | ||
configured=not LiveSetting.objects.filter(name__startswith="EXOTEL", error__isnull=False).exists(), | ||
test_sms=False, | ||
test_call=True, | ||
verification_call=False, | ||
verification_sms=True, | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,78 @@ | ||
import logging | ||
from typing import Optional | ||
|
||
from django.urls import reverse | ||
|
||
from apps.alerts.signals import user_notification_action_triggered_signal | ||
from apps.exotel.models.phone_call import ExotelCallStatuses, ExotelPhoneCall | ||
from common.api_helpers.utils import create_engine_url | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
||
def get_call_status_callback_url(): | ||
return create_engine_url(reverse("exotel:call_status_events")) | ||
|
||
|
||
def update_exotel_call_status(call_id: str, call_status: str, user_choice: Optional[str] = None): | ||
from apps.base.models import UserNotificationPolicyLogRecord | ||
|
||
status_code = ExotelCallStatuses.DETERMINANT.get(call_status) | ||
if status_code is None: | ||
logger.warning(f"exotel.update_exotel_call_status: unexpected status call_id={call_id} status={call_status}") | ||
return | ||
|
||
exotel_phone_call = ExotelPhoneCall.objects.filter(call_id=call_id).first() | ||
if exotel_phone_call is None: | ||
logger.warning(f"exotel.update_exotel_call_status: exotel_phone_call not found call_id={call_id}") | ||
return | ||
|
||
logger.info(f"exotel.update_exotel_call_status: found exotel_phone_call call_id={call_id}") | ||
|
||
exotel_phone_call.status = status_code | ||
exotel_phone_call.save(update_fields=["status"]) | ||
phone_call_record = exotel_phone_call.phone_call_record | ||
|
||
if phone_call_record is None: | ||
logger.warning( | ||
f"exotel.update_exotel_call_status: exotel_phone_call has no phone_call record call_id={call_id} " | ||
f"status={call_status}" | ||
) | ||
return | ||
|
||
logger.info( | ||
f"exotel.update_exotel_call_status: found phone_call_record id={phone_call_record.id} " | ||
f"call_id={call_id} status={call_status}" | ||
) | ||
log_record_type = None | ||
log_record_error_code = None | ||
|
||
success_statuses = [ExotelCallStatuses.COMPLETED] | ||
|
||
if status_code in success_statuses: | ||
log_record_type = UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_SUCCESS | ||
else: | ||
log_record_type = UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED | ||
log_record_error_code = UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_CALL_FAILED | ||
|
||
if log_record_type is not None: | ||
log_record = UserNotificationPolicyLogRecord( | ||
type=log_record_type, | ||
notification_error_code=log_record_error_code, | ||
author=phone_call_record.receiver, | ||
notification_policy=phone_call_record.notification_policy, | ||
alert_group=phone_call_record.represents_alert_group, | ||
notification_step=phone_call_record.notification_policy.step | ||
if phone_call_record.notification_policy | ||
else None, | ||
notification_channel=phone_call_record.notification_policy.notify_by | ||
if phone_call_record.notification_policy | ||
else None, | ||
) | ||
log_record.save() | ||
logger.info( | ||
f"exotel.update_exotel_call_status: created log_record log_record_id={log_record.id} " | ||
f"type={log_record_type}" | ||
) | ||
|
||
user_notification_action_triggered_signal.send(sender=update_exotel_call_status, log_record=log_record) |
Empty file.
Oops, something went wrong.