Plug in sms/phone cloud notifications

This commit is contained in:
Matias Bordese 2022-06-03 14:59:43 -03:00
parent 0cdd2d7b8b
commit f68d3f2146
6 changed files with 106 additions and 29 deletions

View file

@ -12,6 +12,7 @@ from apps.alerts.constants import NEXT_ESCALATION_DELAY
from apps.alerts.incident_appearance.renderers.web_renderer import AlertGroupWebRenderer
from apps.alerts.signals import user_notification_action_triggered_signal
from apps.base.messaging import get_messaging_backend_from_id
from apps.base.utils import live_settings
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
from .task_logger import task_logger
@ -258,10 +259,20 @@ def perform_notification(log_record_pk):
return
if notification_channel == UserNotificationPolicy.NotificationChannel.SMS:
SMSMessage.send_sms(user, alert_group, notification_policy)
SMSMessage.send_sms(
user,
alert_group,
notification_policy,
is_cloud_notification=live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED,
)
elif notification_channel == UserNotificationPolicy.NotificationChannel.PHONE_CALL:
PhoneCall.make_call(user, alert_group, notification_policy)
PhoneCall.make_call(
user,
alert_group,
notification_policy,
is_cloud_notification=live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED,
)
elif notification_channel == UserNotificationPolicy.NotificationChannel.TELEGRAM:
if alert_group.notify_in_telegram_enabled is True:

View file

@ -44,6 +44,7 @@ class LiveSetting(models.Model):
"SEND_ANONYMOUS_USAGE_STATS",
"GRAFANA_CLOUD_ONCALL_TOKEN",
"GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED",
"GRAFANA_CLOUD_NOTIFICATIONS_ENABLED",
)
DESCRIPTIONS = {
@ -106,6 +107,7 @@ class LiveSetting(models.Model):
),
"GRAFANA_CLOUD_ONCALL_TOKEN": "Secret token for Grafana Cloud OnCall instance.",
"GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED": "Enable hearbeat integration with Grafana Cloud OnCall.",
"GRAFANA_CLOUD_NOTIFICATIONS_ENABLED": "Enable SMS/call notifications via Grafana Cloud OnCall",
}
SECRET_SETTING_NAMES = (

View file

@ -1,4 +1,3 @@
# TODO: move to serializers
from rest_framework import serializers, status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
@ -11,7 +10,7 @@ from apps.twilioapp.models import PhoneCall, SMSMessage
class PhoneNotificationDataSerializer(serializers.Serializer):
email = serializers.EmailField()
message = serializers.CharField(max_length=200)
message = serializers.CharField(max_length=1024)
class MakeCallView(APIView):
@ -21,27 +20,26 @@ class MakeCallView(APIView):
# TODO: add ratelimit
def post(self, request):
# TODO: Grafana Twilio:
# 1. Validate user's email
# 2. Validate payload: clean_markup, escape_for_twilio_phone_call
# 3. Create LogRecord (User notification policy or implement new one)
serializer = PhoneNotificationDataSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
response_data = {}
organization = self.request.auth.organization
# TODO: filter by verified phone number?
user = organization.users.filter(email=serializer.validated_data["email"]).first()
if not user or not user.verified_phone_number:
return Response(status=status.HTTP_400_BAD_REQUEST)
user = organization.users.filter(
email=serializer.validated_data["email"], _verified_phone_number__isnull=False
).first()
if user is None:
response_data = {"error": "user-not-found"}
return Response(status=status.HTTP_404_NOT_FOUND, data=response_data)
try:
PhoneCall.make_grafana_cloud_call(user, serializer.validated_data["message"])
except TwilioRestException:
return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE)
return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE, data=response_data)
except PhoneCall.PhoneCallsLimitExceeded:
return Response(status=status.HTTP_400_BAD_REQUEST)
return Response(status=status.HTTP_400_BAD_REQUEST, data={"error": "limit-exceeded"})
return Response(status=status.HTTP_200_OK)
return Response(status=status.HTTP_200_OK, data=response_data)
class SendSMSView(APIView):
@ -52,17 +50,20 @@ class SendSMSView(APIView):
serializer = PhoneNotificationDataSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
response_data = {}
organization = self.request.auth.organization
# TODO: filter by verified phone number?
user = organization.users.filter(email=serializer.validated_data["email"]).first()
if not user or not user.verified_phone_number:
return Response(status=status.HTTP_400_BAD_REQUEST)
user = organization.users.filter(
email=serializer.validated_data["email"], _verified_phone_number__isnull=False
).first()
if user is None:
response_data = {"error": "user-not-found"}
return Response(status=status.HTTP_404_NOT_FOUND, data=response_data)
try:
SMSMessage.send_cloud_sms(user, serializer.validated_data["message"])
SMSMessage.send_grafana_cloud_sms(user, serializer.validated_data["message"])
except TwilioRestException:
return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE)
return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE, data=response_data)
except SMSMessage.SMSLimitExceeded:
return Response(status=status.HTTP_400_BAD_REQUEST)
return Response(status=status.HTTP_400_BAD_REQUEST, data={"error": "limit-exceeded"})
return Response(status=status.HTTP_200_OK)
return Response(status=status.HTTP_200_OK, data=response_data)

View file

@ -1,7 +1,11 @@
import logging
from urllib.parse import urljoin
import requests
from django.apps import apps
from django.conf import settings
from django.db import models
from rest_framework import status
from twilio.base.exceptions import TwilioRestException
from apps.alerts.constants import ActionSource
@ -125,6 +129,9 @@ class PhoneCall(models.Model):
class PhoneNumberNotVerifiedError(Exception):
"""Phone number is not verified"""
class CloudSendError(Exception):
"""Error making call through cloud"""
def process_digit(self, digit):
"""The function process pressed digit at time of call to user
@ -148,14 +155,38 @@ class PhoneCall(models.Model):
return bool(self.represents_alert_group.slack_message)
@classmethod
def make_call(cls, user, alert_group, notification_policy):
def _make_cloud_call(cls, user, message_body):
url = urljoin(settings.GRAFANA_CLOUD_ONCALL_API_URL, "api/v1/make_call")
auth = {"Authorization": settings.GRAFANA_CLOUD_ONCALL_TOKEN}
data = {
"email": user.email,
"message": message_body,
}
try:
response = requests.post(url, headers=auth, data=data, timeout=5)
except requests.exceptions.RequestException as e:
logger.warning(f"Unable to make call through cloud. Request exception {str(e)}")
raise PhoneCall.CloudSendError("Unable to make call through cloud: request failed")
if response.status_code == status.HTTP_400_BAD_REQUEST and response.json().get("error") == "limit-exceeded":
raise PhoneCall.PhoneCallsLimitExceeded("Organization calls limit exceeded")
elif response.status_code == status.HTTP_404_NOT_FOUND:
raise PhoneCall.CloudSendError("Unable to make call through cloud: user not found")
else:
raise PhoneCall.CloudSendError("Unable to make call through cloud: server error")
@classmethod
def make_call(cls, user, alert_group, notification_policy, is_cloud_notification=False):
UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord")
log_record = None
renderer = AlertGroupPhoneCallRenderer(alert_group)
message_body = renderer.render()
try:
cls._make_call(user, message_body, alert_group=alert_group, notification_policy=notification_policy)
except TwilioRestException:
if is_cloud_notification:
cls._make_cloud_call(user, message_body)
else:
cls._make_call(user, message_body, alert_group=alert_group, notification_policy=notification_policy)
except (TwilioRestException, PhoneCall.CloudSendError):
log_record = UserNotificationPolicyLogRecord(
author=user,
type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED,

View file

@ -1,7 +1,11 @@
import logging
from urllib.parse import urljoin
import requests
from django.apps import apps
from django.conf import settings
from django.db import models
from rest_framework import status
from twilio.base.exceptions import TwilioRestException
from apps.alerts.incident_appearance.renderers.sms_renderer import AlertGroupSmsRenderer
@ -108,20 +112,47 @@ class SMSMessage(models.Model):
class PhoneNumberNotVerifiedError(Exception):
"""Phone number is not verified"""
class CloudSendError(Exception):
"""SMS sending through cloud error"""
@property
def created_for_slack(self):
return bool(self.represents_alert_group.slack_message)
@classmethod
def send_sms(cls, user, alert_group, notification_policy):
def _send_cloud_sms(cls, user, message_body):
url = urljoin(settings.GRAFANA_CLOUD_ONCALL_API_URL, "api/v1/send_sms")
auth = {"Authorization": settings.GRAFANA_CLOUD_ONCALL_TOKEN}
data = {
"email": user.email,
"message": message_body,
}
try:
response = requests.post(url, headers=auth, data=data, timeout=5)
except requests.exceptions.RequestException as e:
logger.warning(f"Unable to send SMS through cloud. Request exception {str(e)}")
raise SMSMessage.CloudSendError("Unable to send SMS through cloud: request failed")
if response.status_code == status.HTTP_400_BAD_REQUEST and response.json().get("error") == "limit-exceeded":
raise SMSMessage.SMSLimitExceeded("Organization sms limit exceeded")
elif response.status_code == status.HTTP_404_NOT_FOUND:
raise SMSMessage.CloudSendError("Unable to send SMS through cloud: user not found")
else:
raise SMSMessage.CloudSendError("Unable to send SMS through cloud: server error")
@classmethod
def send_sms(cls, user, alert_group, notification_policy, is_cloud_notification=False):
UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord")
log_record = None
renderer = AlertGroupSmsRenderer(alert_group)
message_body = renderer.render()
try:
cls._send_sms(user, message_body, alert_group=alert_group, notification_policy=notification_policy)
except TwilioRestException:
if is_cloud_notification:
cls._send_cloud_sms(user, message_body)
else:
cls._send_sms(user, message_body, alert_group=alert_group, notification_policy=notification_policy)
except (TwilioRestException, SMSMessage.CloudSendError):
log_record = UserNotificationPolicyLogRecord(
author=user,
type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED,

View file

@ -412,6 +412,7 @@ SELF_HOSTED_SETTINGS = {
GRAFANA_CLOUD_ONCALL_API_URL = os.environ.get("GRAFANA_CLOUD_ONCALL_API_URL", "https://a-prod-us-central-0.grafana.net")
GRAFANA_CLOUD_ONCALL_TOKEN = os.environ.get("GRAFANA_CLOUD_ONCALL_TOKEN", None)
GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED = getenv_boolean("GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED", default=True)
GRAFANA_CLOUD_NOTIFICATIONS_ENABLED = getenv_boolean("GRAFANA_CLOUD_NOTIFICATIONS_ENABLED", default=True)
GRAFANA_INCIDENT_STATIC_API_KEY = os.environ.get("GRAFANA_INCIDENT_STATIC_API_KEY", None)