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:
clemthom 2024-06-06 11:49:02 +05:30 committed by GitHub
parent 2e1021573e
commit 28190fe6b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 478 additions and 1 deletions

View file

@ -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

View file

@ -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):

View file

View 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,
},
),
]

View file

@ -0,0 +1 @@
from .phone_call import ExotelCallStatuses, ExotelPhoneCall # noqa: F401

View 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,
)

View 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,
)

View 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)

View file

View 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)

View 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"),
]

View 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)

View file

@ -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:

View file

@ -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)