From f68d3f214664cb49ec687f59ee30cbc8f25ca69c Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Fri, 3 Jun 2022 14:59:43 -0300 Subject: [PATCH] Plug in sms/phone cloud notifications --- engine/apps/alerts/tasks/notify_user.py | 15 ++++++- engine/apps/base/models/live_setting.py | 2 + .../public_api/views/phone_notifications.py | 43 ++++++++++--------- engine/apps/twilioapp/models/phone_call.py | 37 ++++++++++++++-- engine/apps/twilioapp/models/sms_message.py | 37 ++++++++++++++-- engine/settings/base.py | 1 + 6 files changed, 106 insertions(+), 29 deletions(-) diff --git a/engine/apps/alerts/tasks/notify_user.py b/engine/apps/alerts/tasks/notify_user.py index 05a9456f..e2c8cb23 100644 --- a/engine/apps/alerts/tasks/notify_user.py +++ b/engine/apps/alerts/tasks/notify_user.py @@ -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: diff --git a/engine/apps/base/models/live_setting.py b/engine/apps/base/models/live_setting.py index c08ab11f..1c0b806a 100644 --- a/engine/apps/base/models/live_setting.py +++ b/engine/apps/base/models/live_setting.py @@ -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 = ( diff --git a/engine/apps/public_api/views/phone_notifications.py b/engine/apps/public_api/views/phone_notifications.py index eed301a2..5269d4a9 100644 --- a/engine/apps/public_api/views/phone_notifications.py +++ b/engine/apps/public_api/views/phone_notifications.py @@ -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) diff --git a/engine/apps/twilioapp/models/phone_call.py b/engine/apps/twilioapp/models/phone_call.py index 2cfe44b4..72389811 100644 --- a/engine/apps/twilioapp/models/phone_call.py +++ b/engine/apps/twilioapp/models/phone_call.py @@ -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, diff --git a/engine/apps/twilioapp/models/sms_message.py b/engine/apps/twilioapp/models/sms_message.py index 4046f464..433419e5 100644 --- a/engine/apps/twilioapp/models/sms_message.py +++ b/engine/apps/twilioapp/models/sms_message.py @@ -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, diff --git a/engine/settings/base.py b/engine/settings/base.py index b2150a47..9bb227f9 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -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)