add exotel call provider (#4433)
# 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.
This commit is contained in:
parent
2e1021573e
commit
28190fe6b7
15 changed files with 478 additions and 1 deletions
|
|
@ -213,6 +213,23 @@ To connect to Grafana Cloud OnCall, refer to the **Cloud** page in your OSS Graf
|
|||
|
||||
## Supported Phone Providers
|
||||
|
||||
### Exotel
|
||||
|
||||
Grafana OnCall supports Exotel phone call notifications delivery. To configure phone call notifications using Exotel,
|
||||
complete the following steps:
|
||||
|
||||
1. Set `GRAFANA_CLOUD_NOTIFICATIONS_ENABLED` as **False** to ensure the Grafana OSS <-> Cloud connector is disabled.
|
||||
2. Change `PHONE_PROVIDER` value to `exotel`.
|
||||
3. `EXOTEL_ACCOUNT_SID` can be found under DEVELOPER SETTINGS->API Settings
|
||||
4. `EXOTEL_API_KEY` and `EXOTEL_API_TOKEN` can also be found under DEVELOPER SETTINGS->API Settings
|
||||
5. `EXOTEL_APP_ID` is the identifier of the flow (or applet) which can be found under MANAGE->App Bazaar (Installed apps)
|
||||
6. `EXOTEL_CALLER_ID` is the Exophone / Exotel virtual number.
|
||||
7. `EXOTEL_SMS_SENDER_ID` is the SMS Sender ID to use for sending verification SMS, which can be found under
|
||||
SMS SETTINGS->Sender ID.
|
||||
8. `EXOTEL_SMS_VERIFICATION_TEMPLATE` is the SMS text template to be used for sending verification SMS, add
|
||||
$verification_code as a placeholder.
|
||||
9. `EXOTEL_SMS_DLT_ENTITY_ID` is the DLT Entity ID registered with TRAI.
|
||||
|
||||
### Twilio
|
||||
|
||||
Grafana OnCall supports Twilio SMS and phone call notifications delivery. If you prefer to configure SMS and phone call
|
||||
|
|
|
|||
|
|
@ -72,6 +72,14 @@ class LiveSetting(models.Model):
|
|||
"ZVONOK_POSTBACK_USER_CHOICE",
|
||||
"ZVONOK_POSTBACK_USER_CHOICE_ACK",
|
||||
"ZVONOK_VERIFICATION_CAMPAIGN_ID",
|
||||
"EXOTEL_ACCOUNT_SID",
|
||||
"EXOTEL_API_KEY",
|
||||
"EXOTEL_API_TOKEN",
|
||||
"EXOTEL_APP_ID",
|
||||
"EXOTEL_CALLER_ID",
|
||||
"EXOTEL_SMS_SENDER_ID",
|
||||
"EXOTEL_SMS_VERIFICATION_TEMPLATE",
|
||||
"EXOTEL_SMS_DLT_ENTITY_ID",
|
||||
)
|
||||
|
||||
DESCRIPTIONS = {
|
||||
|
|
@ -171,6 +179,14 @@ class LiveSetting(models.Model):
|
|||
"ZVONOK_POSTBACK_USER_CHOICE": "'Postback' user choice (ct_user_choice) query parameter name (optional).",
|
||||
"ZVONOK_POSTBACK_USER_CHOICE_ACK": "'Postback' user choice (ct_user_choice) query parameter value for acknowledge alert group (optional).",
|
||||
"ZVONOK_VERIFICATION_CAMPAIGN_ID": "The phone number verification campaign ID. You can get it after verification campaign creation.",
|
||||
"EXOTEL_ACCOUNT_SID": "Exotel account SID. You can get it in DEVELOPER SETTINGS -> API Settings",
|
||||
"EXOTEL_API_KEY": "API Key (username)",
|
||||
"EXOTEL_API_TOKEN": "API Token (password)",
|
||||
"EXOTEL_APP_ID": "Identifier of the flow (or applet)",
|
||||
"EXOTEL_CALLER_ID": "Exophone / Exotel virtual number",
|
||||
"EXOTEL_SMS_SENDER_ID": "Exotel SMS Sender ID to use for verification SMS",
|
||||
"EXOTEL_SMS_VERIFICATION_TEMPLATE": "SMS text template to be used for sending SMS, add $verification_code as a placeholder for the verification code",
|
||||
"EXOTEL_SMS_DLT_ENTITY_ID": "DLT Entity ID registered with TRAI.",
|
||||
}
|
||||
|
||||
SECRET_SETTING_NAMES = (
|
||||
|
|
@ -187,6 +203,8 @@ class LiveSetting(models.Model):
|
|||
"TELEGRAM_TOKEN",
|
||||
"GRAFANA_CLOUD_ONCALL_TOKEN",
|
||||
"ZVONOK_API_KEY",
|
||||
"EXOTEL_ACCOUNT_SID",
|
||||
"EXOTEL_API_TOKEN",
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
|
|
|
|||
0
engine/apps/exotel/__init__.py
Normal file
0
engine/apps/exotel/__init__.py
Normal file
29
engine/apps/exotel/migrations/0001_initial.py
Normal file
29
engine/apps/exotel/migrations/0001_initial.py
Normal file
|
|
@ -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,
|
||||
},
|
||||
),
|
||||
]
|
||||
0
engine/apps/exotel/migrations/__init__.py
Normal file
0
engine/apps/exotel/migrations/__init__.py
Normal file
1
engine/apps/exotel/models/__init__.py
Normal file
1
engine/apps/exotel/models/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from .phone_call import ExotelCallStatuses, ExotelPhoneCall # noqa: F401
|
||||
45
engine/apps/exotel/models/phone_call.py
Normal file
45
engine/apps/exotel/models/phone_call.py
Normal file
|
|
@ -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,
|
||||
)
|
||||
175
engine/apps/exotel/phone_provider.py
Normal file
175
engine/apps/exotel/phone_provider.py
Normal file
|
|
@ -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,
|
||||
)
|
||||
78
engine/apps/exotel/status_callback.py
Normal file
78
engine/apps/exotel/status_callback.py
Normal file
|
|
@ -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)
|
||||
0
engine/apps/exotel/tests/__init__.py
Normal file
0
engine/apps/exotel/tests/__init__.py
Normal file
48
engine/apps/exotel/tests/test_exotel_provider.py
Normal file
48
engine/apps/exotel/tests/test_exotel_provider.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from django.test import override_settings
|
||||
|
||||
from apps.exotel.phone_provider import ExotelPhoneProvider
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def provider():
|
||||
return ExotelPhoneProvider()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_make_notification_call(provider):
|
||||
number = "1234567890"
|
||||
message = "dummy message"
|
||||
|
||||
provider._call_create = MagicMock(return_value=MagicMock(json=lambda: {"Call": {"Sid": "12345"}}))
|
||||
provider.make_notification_call(number, message)
|
||||
provider._call_create.assert_called_once_with(number)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_make_call(provider):
|
||||
number = "1234567890"
|
||||
message = "dummy message"
|
||||
|
||||
provider._call_create = MagicMock(return_value=MagicMock(json=lambda: {"Call": {"Sid": "12345"}}))
|
||||
provider.make_call(number, message)
|
||||
provider._call_create.assert_called_once_with(number, False)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_send_verification_sms(provider):
|
||||
verification_code = "123456"
|
||||
sms_template = "Your verification code for grafana oncall is $verification_code"
|
||||
message = "Your verification code for grafana oncall is 123456"
|
||||
number = "1234567890"
|
||||
|
||||
with override_settings(EXOTEL_SMS_VERIFICATION_TEMPLATE=sms_template):
|
||||
with patch("django.core.cache.cache.set"):
|
||||
provider._generate_verification_code = MagicMock(return_value=verification_code)
|
||||
provider._send_verification_code = MagicMock(
|
||||
return_value=MagicMock(json=lambda: {"SMSMessage": {"Sid": "12345"}})
|
||||
)
|
||||
provider.send_verification_sms(number)
|
||||
provider._send_verification_code.assert_called_once_with(number, message)
|
||||
9
engine/apps/exotel/urls.py
Normal file
9
engine/apps/exotel/urls.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
from django.urls import path
|
||||
|
||||
from .views import CallStatusCallback
|
||||
|
||||
app_name = "exotel"
|
||||
|
||||
urlpatterns = [
|
||||
path("call_status_events/", CallStatusCallback.as_view(), name="call_status_events"),
|
||||
]
|
||||
46
engine/apps/exotel/views.py
Normal file
46
engine/apps/exotel/views.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
from rest_framework import status
|
||||
from rest_framework.permissions import BasePermission
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from .status_callback import update_exotel_call_status
|
||||
|
||||
|
||||
class AllowOnlyExotel(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
call_id = request.data.get("CallSid")
|
||||
if not call_id:
|
||||
return False
|
||||
|
||||
status = request.data.get("Status")
|
||||
if not status:
|
||||
return False
|
||||
|
||||
from apps.exotel.models import ExotelPhoneCall
|
||||
|
||||
call = ExotelPhoneCall.objects.filter(call_id=call_id).first()
|
||||
if call:
|
||||
return self.validate_request(request)
|
||||
return False
|
||||
|
||||
def validate_request(self, request):
|
||||
# No reliable way to validate an exotel status callback as of now
|
||||
# this is confirmed by exotel customer support too
|
||||
# It is better to allow only exotel server IPs to this endpoint through firewall or similar means
|
||||
if request.META.get("HTTP_USER_AGENT") == "Exotel Servers":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Receive Call Status from Exotel
|
||||
class CallStatusCallback(APIView):
|
||||
permission_classes = [AllowOnlyExotel]
|
||||
|
||||
def post(self, request):
|
||||
self._handle_call_status(request)
|
||||
return Response(data="", status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def _handle_call_status(self, request):
|
||||
call_id = request.data.get("CallSid")
|
||||
call_status = request.data.get("Status")
|
||||
update_exotel_call_status(call_id=call_id, call_status=call_status)
|
||||
|
|
@ -67,6 +67,7 @@ if settings.IS_OPEN_SOURCE:
|
|||
urlpatterns += [
|
||||
path("api/internal/v1/", include("apps.oss_installation.urls", namespace="oss_installation")),
|
||||
path("zvonok/", include("apps.zvonok.urls")),
|
||||
path("exotel/", include("apps.exotel.urls")),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
|
|
|
|||
|
|
@ -854,7 +854,7 @@ INSTALLED_WEBHOOK_PRESETS = [
|
|||
]
|
||||
|
||||
if IS_OPEN_SOURCE:
|
||||
INSTALLED_APPS += ["apps.oss_installation", "apps.zvonok"] # noqa
|
||||
INSTALLED_APPS += ["apps.oss_installation", "apps.zvonok", "apps.exotel"] # noqa
|
||||
|
||||
CELERY_BEAT_SCHEDULE["send_usage_stats"] = { # noqa
|
||||
"task": "apps.oss_installation.tasks.send_usage_stats_report",
|
||||
|
|
@ -901,6 +901,7 @@ PHONE_PROVIDERS = {
|
|||
|
||||
if IS_OPEN_SOURCE:
|
||||
PHONE_PROVIDERS["zvonok"] = "apps.zvonok.phone_provider.ZvonokPhoneProvider"
|
||||
PHONE_PROVIDERS["exotel"] = "apps.exotel.phone_provider.ExotelPhoneProvider"
|
||||
|
||||
PHONE_PROVIDER = os.environ.get("PHONE_PROVIDER", default=DEFAULT_PHONE_PROVIDER)
|
||||
|
||||
|
|
@ -915,6 +916,15 @@ ZVONOK_POSTBACK_USER_CHOICE = os.getenv("ZVONOK_POSTBACK_USER_CHOICE", None)
|
|||
ZVONOK_POSTBACK_USER_CHOICE_ACK = os.getenv("ZVONOK_POSTBACK_USER_CHOICE_ACK", None)
|
||||
ZVONOK_VERIFICATION_CAMPAIGN_ID = os.getenv("ZVONOK_VERIFICATION_CAMPAIGN_ID", None)
|
||||
|
||||
EXOTEL_ACCOUNT_SID = os.getenv("EXOTEL_ACCOUNT_SID", None)
|
||||
EXOTEL_API_KEY = os.getenv("EXOTEL_API_KEY", None)
|
||||
EXOTEL_API_TOKEN = os.getenv("EXOTEL_API_TOKEN", None)
|
||||
EXOTEL_APP_ID = os.getenv("EXOTEL_APP_ID", None)
|
||||
EXOTEL_CALLER_ID = os.getenv("EXOTEL_CALLER_ID", None)
|
||||
EXOTEL_SMS_SENDER_ID = os.getenv("EXOTEL_SMS_SENDER_ID", None)
|
||||
EXOTEL_SMS_VERIFICATION_TEMPLATE = os.getenv("EXOTEL_SMS_VERIFICATION_TEMPLATE", None)
|
||||
EXOTEL_SMS_DLT_ENTITY_ID = os.getenv("EXOTEL_SMS_DLT_ENTITY_ID", None)
|
||||
|
||||
DETACHED_INTEGRATIONS_SERVER = getenv_boolean("DETACHED_INTEGRATIONS_SERVER", default=False)
|
||||
|
||||
ACKNOWLEDGE_REMINDER_TASK_EXPIRY_DAYS = os.environ.get("ACKNOWLEDGE_REMINDER_TASK_EXPIRY_DAYS", default=14)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue