diff --git a/CHANGELOG.md b/CHANGELOG.md index c6cff975af..39bc4452f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Changed + +- Phone provider refactoring + ### Fixed - Improve plugin authentication by @vadimkerr ([#1995](https://github.com/grafana/oncall/pull/1995)) diff --git a/engine/apps/alerts/constants.py b/engine/apps/alerts/constants.py index 6d5dd0b87c..38508e5272 100644 --- a/engine/apps/alerts/constants.py +++ b/engine/apps/alerts/constants.py @@ -2,7 +2,7 @@ class ActionSource: ( SLACK, WEB, - TWILIO, + PHONE, TELEGRAM, ) = range(4) diff --git a/engine/apps/alerts/incident_appearance/templaters/phone_call_templater.py b/engine/apps/alerts/incident_appearance/templaters/phone_call_templater.py index 3d0127cacd..eb13d86bdc 100644 --- a/engine/apps/alerts/incident_appearance/templaters/phone_call_templater.py +++ b/engine/apps/alerts/incident_appearance/templaters/phone_call_templater.py @@ -1,5 +1,5 @@ from apps.alerts.incident_appearance.templaters.alert_templater import AlertTemplater -from common.utils import clean_markup, escape_for_twilio_phone_call +from common.utils import clean_markup class AlertPhoneCallTemplater(AlertTemplater): @@ -14,7 +14,7 @@ def _postformat(self, templated_alert): return templated_alert def _postformat_pipeline(self, text): - return self._escape(clean_markup(self._slack_format_for_phone_call(text))) if text is not None else text + return clean_markup(self._slack_format_for_phone_call(text)).replace('"', "") if text is not None else text def _slack_format_for_phone_call(self, data): sf = self.slack_formatter @@ -22,6 +22,3 @@ def _slack_format_for_phone_call(self, data): sf.channel_mention_format = "#{}" sf.hyperlink_mention_format = "{title}" return sf.format(data) - - def _escape(self, data): - return escape_for_twilio_phone_call(data) diff --git a/engine/apps/alerts/tasks/notify_user.py b/engine/apps/alerts/tasks/notify_user.py index 3fbca1afe5..e41a4bcfc6 100644 --- a/engine/apps/alerts/tasks/notify_user.py +++ b/engine/apps/alerts/tasks/notify_user.py @@ -9,7 +9,7 @@ from apps.alerts.constants import NEXT_ESCALATION_DELAY 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 apps.phone_notifications.phone_backend import PhoneBackend from common.custom_celery_tasks import shared_dedicated_queue_retry_task from .task_logger import task_logger @@ -224,8 +224,6 @@ def notify_user_task( autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else None ) def perform_notification(log_record_pk): - SMSMessage = apps.get_model("twilioapp", "SMSMessage") - PhoneCall = apps.get_model("twilioapp", "PhoneCall") UserNotificationPolicy = apps.get_model("base", "UserNotificationPolicy") TelegramToUserConnector = apps.get_model("telegram", "TelegramToUserConnector") UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") @@ -259,20 +257,12 @@ def perform_notification(log_record_pk): return if notification_channel == UserNotificationPolicy.NotificationChannel.SMS: - SMSMessage.send_sms( - user, - alert_group, - notification_policy, - is_cloud_notification=live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED, - ) + phone_backend = PhoneBackend() + phone_backend.notify_by_sms(user, alert_group, notification_policy) elif notification_channel == UserNotificationPolicy.NotificationChannel.PHONE_CALL: - PhoneCall.make_call( - user, - alert_group, - notification_policy, - is_cloud_notification=live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED, - ) + phone_backend = PhoneBackend() + phone_backend.notify_by_call(user, alert_group, notification_policy) elif notification_channel == UserNotificationPolicy.NotificationChannel.TELEGRAM: TelegramToUserConnector.notify_user(user, alert_group, notification_policy) diff --git a/engine/apps/alerts/tests/test_alert_group.py b/engine/apps/alerts/tests/test_alert_group.py index 272ef0b16b..0e4d5f682b 100644 --- a/engine/apps/alerts/tests/test_alert_group.py +++ b/engine/apps/alerts/tests/test_alert_group.py @@ -38,7 +38,7 @@ def test_render_for_phone_call( ) expected_verbose_name = ( - f"You are invited to check an incident from Grafana OnCall. " + f"to check an incident from Grafana OnCall. " f"Alert via {alert_receive_channel.verbal_name} - Grafana with title TestAlert triggered 1 times" ) rendered_text = AlertGroupPhoneCallRenderer(alert_group).render() diff --git a/engine/apps/api/serializers/organization.py b/engine/apps/api/serializers/organization.py index 79c6a90a72..6f7c025725 100644 --- a/engine/apps/api/serializers/organization.py +++ b/engine/apps/api/serializers/organization.py @@ -1,3 +1,4 @@ +from dataclasses import asdict from datetime import timedelta import humanize @@ -7,6 +8,7 @@ from rest_framework import fields, serializers from apps.base.models import LiveSetting +from apps.phone_notifications.phone_provider import get_phone_provider from apps.slack.models import SlackTeamIdentity from apps.slack.tasks import resolve_archived_incidents_for_organization, unarchive_incidents_for_organization from apps.user_management.models import Organization @@ -112,14 +114,16 @@ def get_limits(self, obj): return obj.notifications_limit_web_report(user) def get_env_status(self, obj): + # deprecated in favour of ConfigAPIView. + # All new env statuses should be added there LiveSetting.populate_settings_if_needed() telegram_configured = not LiveSetting.objects.filter(name__startswith="TELEGRAM", error__isnull=False).exists() - twilio_configured = not LiveSetting.objects.filter(name__startswith="TWILIO", error__isnull=False).exists() - + phone_provider_config = get_phone_provider().flags return { "telegram_configured": telegram_configured, - "twilio_configured": twilio_configured, + "twilio_configured": phone_provider_config.configured, # keep for backward compatibility + "phone_provider": asdict(phone_provider_config), } def get_stats(self, obj): diff --git a/engine/apps/api/serializers/user.py b/engine/apps/api/serializers/user.py index 98627d4e6f..77d8cb9e3d 100644 --- a/engine/apps/api/serializers/user.py +++ b/engine/apps/api/serializers/user.py @@ -11,11 +11,11 @@ from apps.base.models import UserNotificationPolicy from apps.base.utils import live_settings from apps.oss_installation.utils import cloud_user_identity_status -from apps.twilioapp.utils import check_phone_number_is_valid from apps.user_management.models import User from apps.user_management.models.user import default_working_hours from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField from common.api_helpers.mixins import EagerLoadingMixin +from common.api_helpers.utils import check_phone_number_is_valid from common.timezones import TimeZoneField from .custom_serializers import DynamicFieldsModelSerializer diff --git a/engine/apps/api/tests/test_user.py b/engine/apps/api/tests/test_user.py index 1d4aa1b762..f64cf916c4 100644 --- a/engine/apps/api/tests/test_user.py +++ b/engine/apps/api/tests/test_user.py @@ -17,6 +17,7 @@ RBACPermission, ) from apps.base.models import UserNotificationPolicy +from apps.phone_notifications.exceptions import FailedToFinishVerification from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb from apps.user_management.models.user import default_working_hours @@ -471,7 +472,7 @@ def test_user_get_other_verification_code( client = APIClient() url = reverse("api-internal:user-get-verification-code", kwargs={"pk": admin.public_primary_key}) - with patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock()): + with patch("apps.phone_notifications.phone_backend.PhoneBackend.send_verification_sms", return_value=Mock()): response = client.get(url, format="json", **make_user_auth_headers(tester, token)) assert response.status_code == expected_status @@ -486,7 +487,7 @@ def test_validation_of_verification_code( client = APIClient() url = reverse("api-internal:user-verify-number", kwargs={"pk": user.public_primary_key}) with patch( - "apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None) + "apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number", return_value=True ) as verify_phone_number: url_with_token = f"{url}?token=some_token" r = client.put(url_with_token, format="json", **make_user_auth_headers(user, token)) @@ -504,6 +505,24 @@ def test_validation_of_verification_code( assert verify_phone_number.call_count == 1 +@pytest.mark.django_db +def test_verification_code_provider_exception( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_plugin_token() + client = APIClient() + url = reverse("api-internal:user-verify-number", kwargs={"pk": user.public_primary_key}) + with patch( + "apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number", + side_effect=FailedToFinishVerification, + ) as verify_phone_number: + url_with_token = f"{url}?token=some_token" + r = client.put(url_with_token, format="json", **make_user_auth_headers(user, token)) + assert r.status_code == 503 + assert verify_phone_number.call_count == 1 + + @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", @@ -561,7 +580,7 @@ def test_user_verify_another_phone( client = APIClient() url = reverse("api-internal:user-verify-number", kwargs={"pk": other_user.public_primary_key}) - with patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None)): + with patch("apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number", return_value=True): response = client.put(f"{url}?token=12345", format="json", **make_user_auth_headers(tester, token)) assert response.status_code == expected_status @@ -686,7 +705,7 @@ def test_admin_can_detail_users( assert response.status_code == status.HTTP_200_OK -@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock()) +@patch("apps.phone_notifications.phone_backend.PhoneBackend.send_verification_sms", return_value=Mock()) @pytest.mark.django_db def test_admin_can_get_own_verification_code( mock_verification_start, @@ -702,7 +721,7 @@ def test_admin_can_get_own_verification_code( assert response.status_code == status.HTTP_200_OK -@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock()) +@patch("apps.phone_notifications.phone_backend.PhoneBackend.send_verification_sms", return_value=Mock()) @pytest.mark.django_db def test_admin_can_get_another_user_verification_code( mock_verification_start, @@ -719,7 +738,7 @@ def test_admin_can_get_another_user_verification_code( assert response.status_code == status.HTTP_200_OK -@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None)) +@patch("apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number", return_value=True) @pytest.mark.django_db def test_admin_can_verify_own_phone( mocked_verification_check, @@ -734,7 +753,7 @@ def test_admin_can_verify_own_phone( assert response.status_code == status.HTTP_200_OK -@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None)) +@patch("apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number", return_value=True) @pytest.mark.django_db def test_admin_can_verify_another_user_phone( mocked_verification_check, @@ -912,7 +931,7 @@ def test_user_can_detail_users( assert response.status_code == status.HTTP_403_FORBIDDEN -@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock()) +@patch("apps.phone_notifications.phone_backend.PhoneBackend.send_verification_sms", return_value=Mock()) @pytest.mark.django_db def test_user_can_get_own_verification_code( mock_verification_start, make_organization_and_user_with_plugin_token, make_user_auth_headers @@ -926,7 +945,7 @@ def test_user_can_get_own_verification_code( assert response.status_code == status.HTTP_200_OK -@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock()) +@patch("apps.phone_notifications.phone_backend.PhoneBackend.send_verification_sms", return_value=Mock()) @pytest.mark.django_db def test_user_cant_get_another_user_verification_code( mock_verification_start, @@ -944,7 +963,7 @@ def test_user_cant_get_another_user_verification_code( assert response.status_code == status.HTTP_403_FORBIDDEN -@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None)) +@patch("apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number", return_value=True) @pytest.mark.django_db def test_user_can_verify_own_phone( mocked_verification_check, make_organization_and_user_with_plugin_token, make_user_auth_headers @@ -958,7 +977,7 @@ def test_user_can_verify_own_phone( assert response.status_code == status.HTTP_200_OK -@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None)) +@patch("apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number", return_value=True) @pytest.mark.django_db def test_user_cant_verify_another_user_phone( mocked_verification_check, @@ -1218,7 +1237,7 @@ def test_viewer_cant_detail_users( assert response.status_code == status.HTTP_403_FORBIDDEN -@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock()) +@patch("apps.phone_notifications.phone_backend.PhoneBackend.send_verification_sms", return_value=Mock()) @pytest.mark.django_db def test_viewer_cant_get_own_verification_code( mock_verification_start, make_organization_and_user_with_plugin_token, make_user_auth_headers @@ -1232,7 +1251,7 @@ def test_viewer_cant_get_own_verification_code( assert response.status_code == status.HTTP_403_FORBIDDEN -@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock()) +@patch("apps.phone_notifications.phone_backend.PhoneBackend.send_verification_sms", return_value=Mock()) @pytest.mark.django_db def test_viewer_cant_get_another_user_verification_code( mock_verification_start, @@ -1250,7 +1269,7 @@ def test_viewer_cant_get_another_user_verification_code( assert response.status_code == status.HTTP_403_FORBIDDEN -@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None)) +@patch("apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number", return_value=True) @pytest.mark.django_db def test_viewer_cant_verify_own_phone( mocked_verification_check, make_organization_and_user_with_plugin_token, make_user_auth_headers @@ -1264,7 +1283,7 @@ def test_viewer_cant_verify_own_phone( assert response.status_code == status.HTTP_403_FORBIDDEN -@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None)) +@patch("apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number", return_value=True) @pytest.mark.django_db def test_viewer_cant_verify_another_user_phone( mocked_verification_check, @@ -1340,9 +1359,7 @@ def test_forget_own_number( client = APIClient() url = reverse("api-internal:user-forget-number", kwargs={"pk": user.public_primary_key}) - with patch( - "apps.twilioapp.phone_manager.PhoneManager.notify_about_changed_verified_phone_number", return_value=None - ): + with patch("apps.phone_notifications.phone_backend.PhoneBackend._notify_disconnected_number", return_value=None): response = client.put(url, None, format="json", **make_user_auth_headers(user, token)) assert response.status_code == expected_status @@ -1390,9 +1407,7 @@ def test_forget_other_number( client = APIClient() url = reverse("api-internal:user-forget-number", kwargs={"pk": admin_primary_key}) - with patch( - "apps.twilioapp.phone_manager.PhoneManager.notify_about_changed_verified_phone_number", return_value=None - ): + with patch("apps.phone_notifications.phone_backend.PhoneBackend._notify_disconnected_number", return_value=None): response = client.put(url, None, format="json", **make_user_auth_headers(other_user, token)) assert response.status_code == expected_status @@ -1574,8 +1589,8 @@ def test_check_availability_other_user(make_organization_and_user_with_plugin_to assert response.status_code == status.HTTP_200_OK -@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock()) -@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None)) +@patch("apps.phone_notifications.phone_backend.PhoneBackend.send_verification_sms", return_value=Mock()) +@patch("apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number", return_value=True) @patch( "apps.api.throttlers.GetPhoneVerificationCodeThrottlerPerUser.get_throttle_limits", return_value=(1, 10 * 60), @@ -1616,8 +1631,8 @@ def test_phone_number_verification_flow_ratelimit_per_user( assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS -@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock()) -@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None)) +@patch("apps.phone_notifications.phone_backend.PhoneBackend.send_verification_sms", return_value=Mock()) +@patch("apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number", return_value=True) @patch( "apps.api.throttlers.GetPhoneVerificationCodeThrottlerPerOrg.get_throttle_limits", return_value=(1, 10 * 60), @@ -1659,7 +1674,7 @@ def test_phone_number_verification_flow_ratelimit_per_org( assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS -@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=True) +@patch("apps.phone_notifications.phone_backend.PhoneBackend.send_verification_sms", return_value=Mock()) @pytest.mark.parametrize( "recaptcha_testing_pass,expected_status", [ @@ -1686,7 +1701,7 @@ def test_phone_number_verification_recaptcha( response = client.get(url, format="json", **request_headers) assert response.status_code == expected_status if expected_status == status.HTTP_200_OK: - mock_verification_start.assert_called_once_with() + mock_verification_start.assert_called_once_with(user) else: mock_verification_start.assert_not_called() diff --git a/engine/apps/api/views/user.py b/engine/apps/api/views/user.py index 0a8a64ed5f..d1c8092cc9 100644 --- a/engine/apps/api/views/user.py +++ b/engine/apps/api/views/user.py @@ -42,11 +42,18 @@ from apps.mobile_app.auth import MobileAppAuthTokenAuthentication from apps.mobile_app.demo_push import send_test_push from apps.mobile_app.exceptions import DeviceNotSet +from apps.phone_notifications.exceptions import ( + FailedToFinishVerification, + FailedToMakeCall, + FailedToStartVerification, + NumberAlreadyVerified, + NumberNotVerified, + ProviderNotSupports, +) +from apps.phone_notifications.phone_backend import PhoneBackend from apps.schedules.models import OnCallSchedule from apps.telegram.client import TelegramClient from apps.telegram.models import TelegramVerificationCode -from apps.twilioapp.phone_manager import PhoneManager -from apps.twilioapp.twilio_client import twilio_client from apps.user_management.models import Team, User from common.api_helpers.exceptions import Conflict from common.api_helpers.mixins import FilterSerializerMixin, PublicPrimaryKeyMixin @@ -153,6 +160,7 @@ class UserView( "verify_number": [RBACPermission.Permissions.USER_SETTINGS_WRITE], "forget_number": [RBACPermission.Permissions.USER_SETTINGS_WRITE], "get_verification_code": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "get_verification_call": [RBACPermission.Permissions.USER_SETTINGS_WRITE], "get_backend_verification_code": [RBACPermission.Permissions.USER_SETTINGS_WRITE], "get_telegram_verification_code": [RBACPermission.Permissions.USER_SETTINGS_WRITE], "unlink_slack": [RBACPermission.Permissions.USER_SETTINGS_WRITE], @@ -160,6 +168,7 @@ class UserView( "unlink_backend": [RBACPermission.Permissions.USER_SETTINGS_WRITE], "make_test_call": [RBACPermission.Permissions.USER_SETTINGS_WRITE], "send_test_push": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "send_test_sms": [RBACPermission.Permissions.USER_SETTINGS_WRITE], "export_token": [RBACPermission.Permissions.USER_SETTINGS_WRITE], "upcoming_shifts": [RBACPermission.Permissions.USER_SETTINGS_WRITE], } @@ -175,12 +184,14 @@ class UserView( "verify_number", "forget_number", "get_verification_code", + "get_verification_call", "get_backend_verification_code", "get_telegram_verification_code", "unlink_slack", "unlink_telegram", "unlink_backend", "make_test_call", + "send_test_sms", "send_test_push", "export_token", "upcoming_shifts", @@ -316,9 +327,7 @@ def timezone_options(self, request): throttle_classes=[GetPhoneVerificationCodeThrottlerPerUser, GetPhoneVerificationCodeThrottlerPerOrg], ) def get_verification_code(self, request, pk): - logger.info("get_verification_code: validating reCAPTCHA code") - # valid = recaptcha.check_recaptcha_internal_api(request, "mobile_verification_code") valid = check_recaptcha_internal_api(request, "mobile_verification_code") if not valid: logger.warning(f"get_verification_code: invalid reCAPTCHA validation") @@ -326,12 +335,44 @@ def get_verification_code(self, request, pk): logger.info('get_verification_code: pass reCAPTCHA validation"') user = self.get_object() - phone_manager = PhoneManager(user) - code_sent = phone_manager.send_verification_code() + phone_backend = PhoneBackend() + try: + phone_backend.send_verification_sms(user) + except NumberAlreadyVerified: + return Response("Phone number already verified", status=status.HTTP_400_BAD_REQUEST) + except FailedToStartVerification: + return Response("Something went wrong while sending code", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + except ProviderNotSupports: + return Response( + "Phone provider not supports sms verification", status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + return Response(status=status.HTTP_200_OK) - if not code_sent: - logger.warning(f"Mobile app verification code was not successfully sent") - return Response(status=status.HTTP_400_BAD_REQUEST) + @action( + detail=True, + methods=["get"], + throttle_classes=[GetPhoneVerificationCodeThrottlerPerUser, GetPhoneVerificationCodeThrottlerPerOrg], + ) + def get_verification_call(self, request, pk): + logger.info("get_verification_code_via_call: validating reCAPTCHA code") + valid = check_recaptcha_internal_api(request, "mobile_verification_code") + if not valid: + logger.warning(f"get_verification_code_via_call: invalid reCAPTCHA validation") + return Response("failed reCAPTCHA check", status=status.HTTP_400_BAD_REQUEST) + logger.info('get_verification_code_via_call: pass reCAPTCHA validation"') + + user = self.get_object() + phone_backend = PhoneBackend() + try: + phone_backend.make_verification_call(user) + except NumberAlreadyVerified: + return Response("Phone number already verified", status=status.HTTP_400_BAD_REQUEST) + except FailedToStartVerification: + return Response("Something went wrong while calling", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + except ProviderNotSupports: + return Response( + "Phone provider not supports call verification", status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) return Response(status=status.HTTP_200_OK) @action( @@ -345,29 +386,34 @@ def verify_number(self, request, pk): if not code: return Response("Invalid verification code", status=status.HTTP_400_BAD_REQUEST) prev_state = target_user.insight_logs_serialized - phone_manager = PhoneManager(target_user) - verified, error = phone_manager.verify_phone_number(code) - - if not verified: - return Response(error, status=status.HTTP_400_BAD_REQUEST) - new_state = target_user.insight_logs_serialized - write_resource_insight_log( - instance=target_user, - author=self.request.user, - event=EntityEvent.UPDATED, - prev_state=prev_state, - new_state=new_state, - ) - return Response(status=status.HTTP_200_OK) + + phone_backend = PhoneBackend() + try: + verified = phone_backend.verify_phone_number(target_user, code) + except FailedToFinishVerification: + return Response("Something went wrong while verifying code", status=status.HTTP_503_SERVICE_UNAVAILABLE) + if verified: + new_state = target_user.insight_logs_serialized + write_resource_insight_log( + instance=target_user, + author=self.request.user, + event=EntityEvent.UPDATED, + prev_state=prev_state, + new_state=new_state, + ) + return Response(status=status.HTTP_200_OK) + else: + return Response("Verification code is not correct", status=status.HTTP_400_BAD_REQUEST) @action(detail=True, methods=["put"]) def forget_number(self, request, pk): target_user = self.get_object() prev_state = target_user.insight_logs_serialized - phone_manager = PhoneManager(target_user) - forget = phone_manager.forget_phone_number() - if forget: + phone_backend = PhoneBackend() + removed = phone_backend.forget_number(target_user) + + if removed: new_state = target_user.insight_logs_serialized write_resource_insight_log( instance=target_user, @@ -381,18 +427,34 @@ def forget_number(self, request, pk): @action(detail=True, methods=["post"], throttle_classes=[TestCallThrottler]) def make_test_call(self, request, pk): user = self.get_object() - phone_number = user.verified_phone_number + try: + phone_backend = PhoneBackend() + phone_backend.make_test_call(user) + except NumberNotVerified: + return Response("Phone number is not verified", status=status.HTTP_400_BAD_REQUEST) + except FailedToMakeCall: + return Response( + "Something went wrong while making a test call", status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + except ProviderNotSupports: + return Response("Phone provider not supports phone calls", status=status.HTTP_500_INTERNAL_SERVER_ERROR) - if phone_number is None: - return Response(status=status.HTTP_400_BAD_REQUEST) + return Response(status=status.HTTP_200_OK) + @action(detail=True, methods=["post"], throttle_classes=[TestCallThrottler]) + def send_test_sms(self, request, pk): + user = self.get_object() try: - twilio_client.make_test_call(to=phone_number) - except Exception as e: - logger.error(f"Unable to make a test call due to {e}") + phone_backend = PhoneBackend() + phone_backend.send_test_sms(user) + except NumberNotVerified: + return Response("Phone number is not verified", status=status.HTTP_400_BAD_REQUEST) + except FailedToMakeCall: return Response( - data="Something went wrong while making a test call", status=status.HTTP_500_INTERNAL_SERVER_ERROR + "Something went wrong while making a test call", status=status.HTTP_500_INTERNAL_SERVER_ERROR ) + except ProviderNotSupports: + return Response("Phone provider not supports phone calls", status=status.HTTP_500_INTERNAL_SERVER_ERROR) return Response(status=status.HTTP_200_OK) diff --git a/engine/apps/base/models/live_setting.py b/engine/apps/base/models/live_setting.py index dc3ef1feca..e6d1e70876 100644 --- a/engine/apps/base/models/live_setting.py +++ b/engine/apps/base/models/live_setting.py @@ -59,6 +59,7 @@ class LiveSetting(models.Model): "GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED", "GRAFANA_CLOUD_NOTIFICATIONS_ENABLED", "DANGEROUS_WEBHOOKS_ENABLED", + "PHONE_PROVIDER", ) DESCRIPTIONS = { @@ -146,6 +147,7 @@ class LiveSetting(models.Model): "GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED": "Enable heartbeat integration with Grafana Cloud OnCall.", "GRAFANA_CLOUD_NOTIFICATIONS_ENABLED": "Enable SMS/call notifications via Grafana Cloud OnCall", "DANGEROUS_WEBHOOKS_ENABLED": "Enable outgoing webhooks to private networks", + "PHONE_PROVIDER": f"Phone provider name. Available options: {','.join(list(settings.PHONE_PROVIDERS.keys()))}", } SECRET_SETTING_NAMES = ( @@ -217,6 +219,9 @@ def _get_setting_from_setting_file(setting_name): return getattr(settings, setting_name) def save(self, *args, **kwargs): + """ + Save validates LiveSettings values and save them in database + """ if self.name not in self.AVAILABLE_NAMES: raise ValueError( f"Setting with name '{self.name}' is not in list of available names {self.AVAILABLE_NAMES}" diff --git a/engine/apps/base/tests/test_live_settings.py b/engine/apps/base/tests/test_live_settings.py index 498be849de..6b6eca6b90 100644 --- a/engine/apps/base/tests/test_live_settings.py +++ b/engine/apps/base/tests/test_live_settings.py @@ -4,7 +4,7 @@ from apps.base.models import LiveSetting from apps.base.utils import live_settings -from apps.twilioapp.twilio_client import TwilioClient +from apps.twilioapp.phone_provider import TwilioPhoneProvider @pytest.mark.django_db @@ -61,12 +61,12 @@ def test_twilio_respects_changed_credentials(settings): settings.TWILIO_AUTH_TOKEN = "twilio_auth_token" settings.TWILIO_NUMBER = "twilio_number" - twilio_client = TwilioClient() + twilio_client = TwilioPhoneProvider() live_settings.TWILIO_ACCOUNT_SID = "new_twilio_account_sid" live_settings.TWILIO_AUTH_TOKEN = "new_twilio_auth_token" live_settings.TWILIO_NUMBER = "new_twilio_number" - assert twilio_client.twilio_api_client.username == "new_twilio_account_sid" - assert twilio_client.twilio_api_client.password == "new_twilio_auth_token" - assert twilio_client.twilio_number == "new_twilio_number" + assert twilio_client._twilio_api_client.username == "new_twilio_account_sid" + assert twilio_client._twilio_api_client.password == "new_twilio_auth_token" + assert twilio_client._twilio_number == "new_twilio_number" diff --git a/engine/apps/phone_notifications/__init__.py b/engine/apps/phone_notifications/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/engine/apps/phone_notifications/exceptions.py b/engine/apps/phone_notifications/exceptions.py new file mode 100644 index 0000000000..97b9348b2a --- /dev/null +++ b/engine/apps/phone_notifications/exceptions.py @@ -0,0 +1,34 @@ +class FailedToMakeCall(Exception): + pass + + +class FailedToSendSMS(Exception): + pass + + +class NumberNotVerified(Exception): + pass + + +class NumberAlreadyVerified(Exception): + pass + + +class FailedToStartVerification(Exception): + pass + + +class FailedToFinishVerification(Exception): + pass + + +class ProviderNotSupports(Exception): + pass + + +class CallsLimitExceeded(Exception): + pass + + +class SMSLimitExceeded(Exception): + pass diff --git a/engine/apps/phone_notifications/migrations/0001_initial.py b/engine/apps/phone_notifications/migrations/0001_initial.py new file mode 100644 index 0000000000..64fa4760c1 --- /dev/null +++ b/engine/apps/phone_notifications/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 3.2.18 on 2023-05-24 03:54 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('user_management', '0011_auto_20230411_1358'), + ('alerts', '0015_auto_20230508_1641'), + ('base', '0003_delete_organizationlogrecord'), + ('twilioapp', '0003_auto_20230408_0711'), + ] + + state_operations = [ + migrations.CreateModel( + name='SMSRecord', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('exceeded_limit', models.BooleanField(default=None, null=True)), + ('grafana_cloud_notification', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('status', models.PositiveSmallIntegerField(blank=True, choices=[(10, 'accepted'), (20, 'queued'), (30, 'sending'), (40, 'sent'), (50, 'failed'), (60, 'delivered'), (70, 'undelivered'), (80, 'receiving'), (90, 'received'), (100, 'read')], null=True)), + ('sid', models.CharField(blank=True, max_length=50)), + ('notification_policy', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='base.usernotificationpolicy')), + ('receiver', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='user_management.user')), + ('represents_alert', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='alerts.alert')), + ('represents_alert_group', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='alerts.alertgroup')), + ], + options={ + 'db_table': 'twilioapp_smsmessage', + }, + ), + migrations.CreateModel( + name='PhoneCallRecord', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('exceeded_limit', models.BooleanField(default=None, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('grafana_cloud_notification', models.BooleanField(default=False)), + ('status', models.PositiveSmallIntegerField(blank=True, choices=[(10, 'queued'), (20, 'ringing'), (30, 'in-progress'), (40, 'completed'), (50, 'busy'), (60, 'failed'), (70, 'no-answer'), (80, 'canceled')], null=True)), + ('sid', models.CharField(blank=True, max_length=50)), + ('notification_policy', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='base.usernotificationpolicy')), + ('receiver', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='user_management.user')), + ('represents_alert', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='alerts.alert')), + ('represents_alert_group', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='alerts.alertgroup')), + ], + options={ + 'db_table': 'twilioapp_phonecall', + }, + ), + ] + + operations = [ + migrations.SeparateDatabaseAndState(state_operations=state_operations) + ] + diff --git a/engine/apps/phone_notifications/migrations/__init__.py b/engine/apps/phone_notifications/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/engine/apps/phone_notifications/models/__init__.py b/engine/apps/phone_notifications/models/__init__.py new file mode 100644 index 0000000000..c3d30b71f0 --- /dev/null +++ b/engine/apps/phone_notifications/models/__init__.py @@ -0,0 +1,2 @@ +from .phone_call import PhoneCallRecord, ProviderPhoneCall # noqa: F401 +from .sms import ProviderSMS, SMSRecord # noqa: F401 diff --git a/engine/apps/phone_notifications/models/phone_call.py b/engine/apps/phone_notifications/models/phone_call.py new file mode 100644 index 0000000000..b4a9182b8f --- /dev/null +++ b/engine/apps/phone_notifications/models/phone_call.py @@ -0,0 +1,81 @@ +from django.db import models + + +# Duplicate to avoid circular import to provide values for status field +class TwilioCallStatuses: + QUEUED = 10 + RINGING = 20 + IN_PROGRESS = 30 + COMPLETED = 40 + BUSY = 50 + FAILED = 60 + NO_ANSWER = 70 + CANCELED = 80 + + CHOICES = ( + (QUEUED, "queued"), + (RINGING, "ringing"), + (IN_PROGRESS, "in-progress"), + (COMPLETED, "completed"), + (BUSY, "busy"), + (FAILED, "failed"), + (NO_ANSWER, "no-answer"), + (CANCELED, "canceled"), + ) + + +class PhoneCallRecord(models.Model): + class Meta: + db_table = "twilioapp_phonecall" + + exceeded_limit = models.BooleanField(null=True, default=None) + represents_alert = models.ForeignKey( + "alerts.Alert", on_delete=models.SET_NULL, null=True, default=None + ) # deprecateed + represents_alert_group = models.ForeignKey("alerts.AlertGroup", on_delete=models.SET_NULL, null=True, default=None) + notification_policy = models.ForeignKey( + "base.UserNotificationPolicy", on_delete=models.SET_NULL, null=True, default=None + ) + + receiver = models.ForeignKey("user_management.User", on_delete=models.CASCADE, null=True, default=None) + + created_at = models.DateTimeField(auto_now_add=True) + + grafana_cloud_notification = models.BooleanField(default=False) # rename + + # deprecated. It's here for backward compatibility for calls made during or shortly before migration. + # Should be removed soon after migration + status = models.PositiveSmallIntegerField( + blank=True, + null=True, + choices=TwilioCallStatuses.CHOICES, + ) + + sid = models.CharField( + blank=True, + max_length=50, + ) + + +class ProviderPhoneCall(models.Model): + """ + ProviderPhoneCall is an interface between PhoneCallRecord and call data returned from PhoneProvider. + + Some phone providers allows to track status of call or gather pressed digits (we use it to ack/resolve alert group). + It is needed to link phone call and alert group without exposing internals of concrete phone provider to PhoneBackend. + """ + + class Meta: + abstract = True + + phone_call_record = models.OneToOneField( + "phone_notifications.PhoneCallRecord", + on_delete=models.CASCADE, + related_name="%(app_label)s_%(class)s_related", + related_query_name="%(app_label)s_%(class)ss", + null=False, + ) + + def link_and_save(self, phone_call_record: PhoneCallRecord): + self.phone_call_record = phone_call_record + self.save() diff --git a/engine/apps/phone_notifications/models/sms.py b/engine/apps/phone_notifications/models/sms.py new file mode 100644 index 0000000000..4bad9eb426 --- /dev/null +++ b/engine/apps/phone_notifications/models/sms.py @@ -0,0 +1,87 @@ +from django.db import models + + +# Duplicate to avoid circular import to provide values for status field +class TwilioSMSstatuses: + """ + https://www.twilio.com/docs/sms/tutorials/how-to-confirm-delivery-python?code-sample=code-handle-a-sms-statuscallback&code-language=Python&code-sdk-version=5.x#receive-status-events-in-your-web-application + https://www.twilio.com/docs/sms/api/message-resource#message-status-values + """ + + ACCEPTED = 10 + QUEUED = 20 + SENDING = 30 + SENT = 40 + FAILED = 50 + DELIVERED = 60 + UNDELIVERED = 70 + RECEIVING = 80 + RECEIVED = 90 + READ = 100 + + CHOICES = ( + (ACCEPTED, "accepted"), + (QUEUED, "queued"), + (SENDING, "sending"), + (SENT, "sent"), + (FAILED, "failed"), + (DELIVERED, "delivered"), + (UNDELIVERED, "undelivered"), + (RECEIVING, "receiving"), + (RECEIVED, "received"), + (READ, "read"), + ) + + +class SMSRecord(models.Model): + class Meta: + db_table = "twilioapp_smsmessage" + + exceeded_limit = models.BooleanField(null=True, default=None) + represents_alert = models.ForeignKey( + "alerts.Alert", on_delete=models.SET_NULL, null=True, default=None + ) # deprecated + represents_alert_group = models.ForeignKey("alerts.AlertGroup", on_delete=models.SET_NULL, null=True, default=None) + notification_policy = models.ForeignKey( + "base.UserNotificationPolicy", on_delete=models.SET_NULL, null=True, default=None + ) + + receiver = models.ForeignKey("user_management.User", on_delete=models.CASCADE, null=True, default=None) + grafana_cloud_notification = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + # deprecated. It's here for backward compatibility for sms sent during or shortly before migration. + # Should be removed soon after migration + status = models.PositiveSmallIntegerField( + blank=True, + null=True, + choices=TwilioSMSstatuses.CHOICES, + ) + + sid = models.CharField( + blank=True, + max_length=50, + ) + + +class ProviderSMS(models.Model): + """ + ProviderSMS is an interface between SMSRecord and call data returned from PhoneProvider. + + The idea is same as for ProviderCall - to save provider specific data without exposing them to ProheBackend. + """ + + class Meta: + abstract = True + + sms_record = models.OneToOneField( + "phone_notifications.SMSRecord", + on_delete=models.CASCADE, + related_name="%(app_label)s_%(class)s_related", + related_query_name="%(app_label)s_%(class)ss", + null=False, + ) + + def link_and_save(self, sms_record: SMSRecord): + self.sms_record = sms_record + self.save() diff --git a/engine/apps/phone_notifications/phone_backend.py b/engine/apps/phone_notifications/phone_backend.py new file mode 100644 index 0000000000..db070e6164 --- /dev/null +++ b/engine/apps/phone_notifications/phone_backend.py @@ -0,0 +1,399 @@ +import logging +from typing import Optional + +import requests +from django.apps import apps +from django.conf import settings + +from apps.alerts.incident_appearance.renderers.phone_call_renderer import AlertGroupPhoneCallRenderer +from apps.alerts.incident_appearance.renderers.sms_renderer import AlertGroupSmsRenderer +from apps.alerts.signals import user_notification_action_triggered_signal +from apps.base.utils import live_settings +from common.api_helpers.utils import create_engine_url +from common.utils import clean_markup + +from .exceptions import ( + CallsLimitExceeded, + FailedToMakeCall, + FailedToSendSMS, + NumberAlreadyVerified, + NumberNotVerified, + ProviderNotSupports, + SMSLimitExceeded, +) +from .models import PhoneCallRecord, ProviderPhoneCall, ProviderSMS, SMSRecord +from .phone_provider import PhoneProvider, get_phone_provider + +logger = logging.getLogger(__name__) + + +class PhoneBackend: + def __init__(self): + self.phone_provider: PhoneProvider = self._get_phone_provider() + + def _get_phone_provider(self) -> PhoneProvider: + # wrapper to simplify mocking + return get_phone_provider() + + def notify_by_call(self, user, alert_group, notification_policy): + """ + notify_by_call makes a notification call to a user using configured phone provider or cloud notifications. + It handles all business logic related to the call. + """ + UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") + log_record_error_code = None + + renderer = AlertGroupPhoneCallRenderer(alert_group) + message = renderer.render() + + record = PhoneCallRecord.objects.create( + represents_alert_group=alert_group, + receiver=user, + notification_policy=notification_policy, + exceeded_limit=False, + ) + + try: + if live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED and settings.IS_OPEN_SOURCE: + self._notify_by_cloud_call(user, message) + record.save() + else: + provider_call = self._notify_by_provider_call(user, message) + # it is important that record is saved here, so it is possible to execute link_and_save + record.save() + if provider_call: + provider_call.link_and_save(record) + except FailedToMakeCall: + log_record_error_code = UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_CALL + except ProviderNotSupports: + log_record_error_code = UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_CALL + except CallsLimitExceeded: + record.exceeded_limit = True + record.save() + log_record_error_code = UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_CALLS_LIMIT_EXCEEDED + except NumberNotVerified: + log_record_error_code = UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_NUMBER_IS_NOT_VERIFIED + + if log_record_error_code is not None: + log_record = UserNotificationPolicyLogRecord( + author=user, + type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, + notification_policy=notification_policy, + alert_group=alert_group, + notification_error_code=log_record_error_code, + notification_step=notification_policy.step if notification_policy else None, + notification_channel=notification_policy.notify_by if notification_policy else None, + ) + log_record.save() + user_notification_action_triggered_signal.send(sender=PhoneBackend.notify_by_call, log_record=log_record) + + def _notify_by_provider_call(self, user, message) -> Optional[ProviderPhoneCall]: + """ + _notify_by_provider_call makes a notification call using configured phone provider. + """ + if not self._validate_user_number(user): + raise NumberNotVerified + + calls_left = self._validate_phone_calls_left(user) + if calls_left <= 0: + raise CallsLimitExceeded + elif calls_left < 3: + message = self._add_call_limit_warning(calls_left, message) + return self.phone_provider.make_notification_call(user.verified_phone_number, message) + + def _notify_by_cloud_call(self, user, message): + """ + _notify_by_cloud_call makes a call using connected Grafana Cloud Instance. + This method should be used only in OSS instances. + """ + url = create_engine_url("api/v1/make_call", override_base=settings.GRAFANA_CLOUD_ONCALL_API_URL) + auth = {"Authorization": live_settings.GRAFANA_CLOUD_ONCALL_TOKEN} + data = { + "email": user.email, + "message": message, + } + try: + response = requests.post(url, headers=auth, data=data, timeout=5) + except requests.exceptions.RequestException as e: + logger.error(f"PhoneBackend._notify_by_cloud_call: request exception {str(e)}") + raise FailedToMakeCall + if response.status_code == 200: + logger.info("PhoneBackend._notify_by_cloud_call: OK") + elif response.status_code == 400 and response.json().get("error") == "limit-exceeded": + logger.info(f"PhoneBackend._notify_by_cloud_call: phone calls limit exceeded") + raise CallsLimitExceeded + elif response.status_code == 400 and response.json().get("error") == "number-not-verified": + logger.info(f"PhoneBackend._notify_by_cloud_call: cloud number not verified") + raise NumberNotVerified + elif response.status_code == 404: + logger.info(f"PhoneBackend._notify_by_cloud_call: user not found id={user.id} email={user.email}") + raise FailedToMakeCall + else: + logger.error(f"PhoneBackend._notify_by_cloud_call: unexpected response code {response.status_code}") + raise FailedToMakeCall + + def _add_call_limit_warning(self, calls_left, message): + return f"{message} {calls_left} phone calls left. Contact your admin." + + def _validate_phone_calls_left(self, user) -> int: + return user.organization.phone_calls_left(user) + + def notify_by_sms(self, user, alert_group, notification_policy): + """ + notify_by_sms sends a notification sms to a user using configured phone provider. + It handles business logic - limits, cloud notifications and UserNotificationPolicyLogRecord creation + SMS itself is handled by phone provider. + """ + + UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") + log_record_error_code = None + + renderer = AlertGroupSmsRenderer(alert_group) + message = renderer.render() + + record = SMSRecord( + represents_alert_group=alert_group, + receiver=user, + notification_policy=notification_policy, + exceeded_limit=False, + ) + + try: + if live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED and settings.IS_OPEN_SOURCE: + self._notify_by_cloud_sms(user, message) + record.save() + else: + provider_sms = self._notify_by_provider_sms(user, message) + record.save() + if provider_sms: + provider_sms.link_and_save(record) + except FailedToSendSMS: + log_record_error_code = UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_SEND_SMS + except ProviderNotSupports: + log_record_error_code = UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_SEND_SMS + except SMSLimitExceeded: + record.exceeded_limit = True + record.save() + log_record_error_code = UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_SMS_LIMIT_EXCEEDED + except NumberNotVerified: + log_record_error_code = UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_NUMBER_IS_NOT_VERIFIED + + if log_record_error_code is not None: + log_record = UserNotificationPolicyLogRecord( + author=user, + type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, + notification_policy=notification_policy, + alert_group=alert_group, + notification_error_code=log_record_error_code, + notification_step=notification_policy.step if notification_policy else None, + notification_channel=notification_policy.notify_by if notification_policy else None, + ) + log_record.save() + user_notification_action_triggered_signal.send(sender=PhoneBackend.notify_by_sms, log_record=log_record) + + def _notify_by_provider_sms(self, user, message) -> Optional[ProviderSMS]: + """ + _notify_by_provider_sms sends a notification sms using configured phone provider. + """ + if not self._validate_user_number(user): + raise NumberNotVerified + + sms_left = self._validate_sms_left(user) + if sms_left <= 0: + raise SMSLimitExceeded + elif sms_left < 3: + message = self._add_sms_limit_warning(sms_left, message) + return self.phone_provider.send_notification_sms(user.verified_phone_number, message) + + def _notify_by_cloud_sms(self, user, message): + """ + _notify_by_cloud_sms sends a sms using connected Grafana Cloud Instance. + This method is used only in OSS instances. + """ + url = create_engine_url("api/v1/send_sms", override_base=settings.GRAFANA_CLOUD_ONCALL_API_URL) + auth = {"Authorization": live_settings.GRAFANA_CLOUD_ONCALL_TOKEN} + data = { + "email": user.email, + "message": message, + } + try: + response = requests.post(url, headers=auth, data=data, timeout=5) + except requests.exceptions.RequestException as e: + logger.error(f"Unable to send SMS through cloud. Request exception {str(e)}") + raise FailedToSendSMS + if response.status_code == 200: + logger.info("Sent cloud sms successfully") + elif response.status_code == 400 and response.json().get("error") == "limit-exceeded": + raise SMSLimitExceeded + elif response.status_code == 400 and response.json().get("error") == "number-not-verified": + raise NumberNotVerified + elif response.status_code == 404: + # user not found + raise FailedToSendSMS + else: + raise FailedToSendSMS + + def _validate_sms_left(self, user) -> int: + return user.organization.sms_left(user) + + def _add_sms_limit_warning(self, calls_left, message): + return f"{message} {calls_left} sms left. Contact your admin." + + def _validate_user_number(self, user): + return user.verified_phone_number is not None + + # relay calls/sms from oss related code + def relay_oss_call(self, user, message): + """ + relay_oss_call make phone call received from oss instance. + Caller should handle exceptions raised by phone_provider.make_call. + + The difference between relay_oss_call and notify_by_call is that relay_oss_call uses phone_provider.make_call + to only make call, not track status, gather digits or create logs. + """ + if not self._validate_user_number(user): + raise NumberNotVerified + + calls_left = self._validate_phone_calls_left(user) + if calls_left <= 0: + PhoneCallRecord.objects.create( + receiver=user, + exceeded_limit=True, + grafana_cloud_notification=True, + ) + raise CallsLimitExceeded + elif calls_left < 3: + message = self._add_call_limit_warning(calls_left, message) + + # additional cleaning, since message come from api call and wasn't cleaned by our renderer + message = clean_markup(message).replace('"', "") + + self.phone_provider.make_call(message, user.verified_phone_number) + # create PhoneCallRecord to track limits for calls from oss instances + PhoneCallRecord.objects.create( + receiver=user, + exceeded_limit=False, + grafana_cloud_notification=True, + ) + + def relay_oss_sms(self, user, message): + """ + relay_oss_sms send sms received from oss instance. + Caller should handle exceptions raised by phone_provider.send_sms. + + The difference between relay_oss_sms and notify_by_sms is that relay_oss_call uses phone_provider.make_call + to only send, not track status or create logs. + """ + if not self._validate_user_number(user): + raise NumberNotVerified + + sms_left = self._validate_sms_left(user) + if sms_left <= 0: + SMSRecord.objects.create( + receiver=user, + exceeded_limit=True, + grafana_cloud_notification=True, + ) + raise SMSLimitExceeded + elif sms_left < 3: + message = self._add_sms_limit_warning(sms_left, message) + + self.phone_provider.send_sms(message, user.verified_phone_number) + SMSRecord.objects.create( + receiver=user, + exceeded_limit=False, + grafana_cloud_notification=True, + ) + + # Number verification related code + def send_verification_sms(self, user): + """ + send_verification_sms sends a verification code to a user. + Caller should handle exceptions raised by phone_provider.send_verification_sms. + """ + logger.info(f"PhoneBackend.send_verification_sms: start verification for user {user.id}") + if self._validate_user_number(user): + logger.info(f"PhoneBackend.send_verification_sms: number already verified for user {user.id}") + raise NumberAlreadyVerified + self.phone_provider.send_verification_sms(user.unverified_phone_number) + + def make_verification_call(self, user): + """ + make_verification_call makes a verification call to a user. + Caller should handle exceptions raised by phone_provider.make_verification_call + """ + logger.info(f"PhoneBackend.make_verification_call: start verification user_id={user.id}") + if self._validate_user_number(user): + logger.info(f"PhoneBackend.make_verification_call: number already verified user_id={user.id}") + raise NumberAlreadyVerified + self.phone_provider.make_verification_call(user.unverified_phone_number) + + def verify_phone_number(self, user, code) -> bool: + prev_number = user.verified_phone_number + new_number = self.phone_provider.finish_verification(user.unverified_phone_number, code) + if new_number: + user.save_verified_phone_number(new_number) + # TODO: move this to async task + if prev_number: + self._notify_disconnected_number(user, prev_number) + self._notify_connected_number(user) + logger.info(f"PhoneBackend.verify_phone_number: verified user_id={user.id}") + return True + else: + logger.info(f"PhoneBackend.verify_phone_number: verification failed user_id={user.id}") + return False + + def forget_number(self, user) -> bool: + prev_number = user.verified_phone_number + user.clear_phone_numbers() + if prev_number: + self._notify_disconnected_number(user, prev_number) + return True + return False + + def make_test_call(self, user): + """ + make_test_call makes a test call to user's verified phone number + Caller should handle exceptions raised by phone_provider.make_call. + """ + text = "It is a test call from Grafana OnCall" + if not user.verified_phone_number: + raise NumberNotVerified + self.phone_provider.make_call(user.verified_phone_number, text) + + def send_test_sms(self, user): + """ + send_test_sms sends a test sms to user's verified phone number + Caller should handle exceptions raised by phone_provider.send_sms. + """ + text = "It is a test sms from Grafana OnCall" + if not user.verified_phone_number: + raise NumberNotVerified + self.phone_provider.send_sms(user.verified_phone_number, text) + + def _notify_connected_number(self, user): + text = ( + f"This phone number has been connected to Grafana OnCall team" + f'"{user.organization.stack_slug}"\nYour Grafana OnCall <3' + ) + try: + if not user.verified_phone_number: + logger.error("PhoneBackend._notify_connected_number: number not verified") + return + self.phone_provider.send_sms(user.verified_phone_number, text) + except FailedToSendSMS: + logger.error("PhoneBackend._notify_connected_number: failed") + except ProviderNotSupports: + logger.info("PhoneBackend._notify_connected_number: provider not supports sms") + + def _notify_disconnected_number(self, user, number): + text = ( + f"This phone number has been disconnected from Grafana OnCall team" + f'"{user.organization.stack_slug}"\nYour Grafana OnCall <3' + ) + try: + self.phone_provider.send_sms(number, text) + except FailedToSendSMS: + logger.error("PhoneBackend._notify_disconnected_number: failed") + except ProviderNotSupports: + logger.info("PhoneBackend._notify_disconnected_number: provider not supports sms") diff --git a/engine/apps/phone_notifications/phone_provider.py b/engine/apps/phone_notifications/phone_provider.py new file mode 100644 index 0000000000..68f477214e --- /dev/null +++ b/engine/apps/phone_notifications/phone_provider.py @@ -0,0 +1,174 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Optional + +from django.conf import settings +from django.utils.module_loading import import_string + +from apps.base.utils import live_settings +from apps.phone_notifications.exceptions import ProviderNotSupports +from apps.phone_notifications.models import ProviderPhoneCall, ProviderSMS + + +@dataclass +class ProviderFlags: + """ + ProviderFlags is set of feature flags enabled for concrete provider. + It is needed to show correct buttons in UI. + """ + + configured: bool # indicates if provider live settings are present and valid + test_sms: bool + test_call: bool + verification_call: bool + verification_sms: bool + + +class PhoneProvider(ABC): + """ + PhoneProvider is an interface to all phone providers. + It is needed to hide details of external phone providers from core code. + + New PhoneProviders should be added to settings.PHONE_PROVIDERS dict. + + For reference, you can check: + SimplePhoneProvider as example of tiny, but working provider. + TwilioPhoneProvider as example of complicated phone provider which supports status callbacks and gather actions. + """ + + def make_notification_call(self, number: str, text: str) -> Optional[ProviderPhoneCall]: + """ + make_notification_call makes a call to notify about alert group and optionally returns unsaved ProviderPhoneCall + instance. If returned, instance will be linked to PhoneCallRecord and saved by PhoneBackend. + Check ProviderPhoneCall doc for more info. + + If provider doesn't perform additional logic for notifications or doesn't save phone call data - wrap make_call: + def make_notification_call(self, number, text): + self.make_call(number, text) + + Args: + number: phone number to call + text: text of the call + Returns: + Unsaved ProviderPhoneCall instance to link to PhoneCallRecord or None if provider-specific data not stored. + + Raises: + FailedToMakeCall: if some exception in external provider happens. + ProviderNotSupports: if provider not supports calls (it's a valid use-case). + """ + raise ProviderNotSupports + + def send_notification_sms(self, number: str, message: str) -> Optional[ProviderSMS]: + """ + send_notification_sms sends a sms to notify about alert group. + + send_notification_sms sends a sms to notify about alert group and optionally returns unsaved ProviderSMS + instance. If returned, instance will be linked to SMSRecord and saved by PhoneBackend. + + You can just wrap send_sms if no additional logic is performed for notification sms: + + def send_notification_sms(self, number, text, phone_call_record): + self.send_sms(number, text) + + Args: + number: phone number to send sms + message: text of the sms + Returns: + Unsaved ProviderSMS instance to link to SMSRecord or None if provider-specific data not stored. + + Raises: + FailedToSendSMS: if some exception in external provider happens + ProviderNotSupports: if provider not supports sms (it's a valid use-case) + """ + raise ProviderNotSupports + + def make_call(self, number: str, text: str): + """ + make_call make a call with given text to given number. + + Args: + number: phone number to make a call + text: call text to deliver to user + + Raises: + FailedToMakeCall: if some exception in external provider happens + ProviderNotSupports: if provider not supports calls (it's a valid use-case) + """ + raise ProviderNotSupports + + def send_sms(self, number: str, text: str): + """ + send_sms sends an SMS to the specified phone number with the given text. + + Args: + number: phone number to send a sms + text: text to deliver to user + + Raises: + FailedToSendSMS: if some exception in external provider occurred + ProviderNotSupports: if provider not supports calls + + """ + raise ProviderNotSupports + + def send_verification_sms(self, number: str): + """ + send_verification_sms starts phone number verification by sending code via sms + + Args: + number: number to verify + + Raises: + FailedToStartVerification: if some exception in external provider occurred + ProviderNotSupports: if concrete provider not phone number verification via sms + """ + raise ProviderNotSupports + + def make_verification_call(self, number: str): + """ + make_verification_call starts phone number verification by calling to user + + Args: + number: number to verify + + Raises: + FailedToStartVerification: if some exception in external provider occurred + ProviderNotSupports: if concrete provider not phone number verification via call + """ + raise ProviderNotSupports + + def finish_verification(self, number: str, code: str) -> Optional[str]: + """ + finish_verification validates the verification code. + + Args: + number: number to verify + code: verification code + Returns: + verified phone number or None if code is invalid + + Raises: + FailedToFinishVerification: when some exception in external service occurred + ProviderNotSupports: if concrete provider not supports number verification + """ + raise ProviderNotSupports + + @property + @abstractmethod + def flags(self) -> ProviderFlags: + """ + flags returns ProviderFlags instance to control web UI + """ + raise NotImplementedError + + +_providers = {} + + +def get_phone_provider() -> PhoneProvider: + global _providers + # load all providers in memory on first call + if len(_providers) == 0: + for provider_alias, importpath in settings.PHONE_PROVIDERS.items(): + _providers[provider_alias] = import_string(importpath)() + return _providers[live_settings.PHONE_PROVIDER] diff --git a/engine/apps/phone_notifications/simple_phone_provider.py b/engine/apps/phone_notifications/simple_phone_provider.py new file mode 100644 index 0000000000..f6d0df03f3 --- /dev/null +++ b/engine/apps/phone_notifications/simple_phone_provider.py @@ -0,0 +1,43 @@ +from random import randint + +from django.core.cache import cache + +from .phone_provider import PhoneProvider, ProviderFlags + + +class SimplePhoneProvider(PhoneProvider): + """ + SimplePhoneProvider is an example of phone provider which supports only SMS messages. + It is not intended for real-life usage and needed only as example of PhoneProviders suitable to use ONLY in OSS. + """ + + def send_notification_sms(self, number, message): + self.send_sms(number, message) + + def send_sms(self, number, text): + print(f'SimplePhoneProvider.send_sms: send message "{text}" to {number}') + + def send_verification_sms(self, number): + code = str(randint(100000, 999999)) + cache.set(self._cache_key(number), code, timeout=10 * 60) + self.send_sms(number, f"Your verification code is {code}") + + 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"simple_provider_{number}" + + @property + def flags(self) -> ProviderFlags: + return ProviderFlags( + configured=True, + test_sms=True, + test_call=False, + verification_call=False, + verification_sms=True, + ) diff --git a/engine/apps/phone_notifications/tests/__init__.py b/engine/apps/phone_notifications/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/engine/apps/phone_notifications/tests/factories.py b/engine/apps/phone_notifications/tests/factories.py new file mode 100644 index 0000000000..d2904eb183 --- /dev/null +++ b/engine/apps/phone_notifications/tests/factories.py @@ -0,0 +1,13 @@ +import factory + +from apps.phone_notifications.models import PhoneCallRecord, SMSRecord + + +class PhoneCallRecordFactory(factory.DjangoModelFactory): + class Meta: + model = PhoneCallRecord + + +class SMSRecordFactory(factory.DjangoModelFactory): + class Meta: + model = SMSRecord diff --git a/engine/apps/phone_notifications/tests/mock_phone_provider.py b/engine/apps/phone_notifications/tests/mock_phone_provider.py new file mode 100644 index 0000000000..b964fb6dc0 --- /dev/null +++ b/engine/apps/phone_notifications/tests/mock_phone_provider.py @@ -0,0 +1,38 @@ +from apps.phone_notifications.phone_provider import PhoneProvider, ProviderFlags + + +class MockPhoneProvider(PhoneProvider): + """ + MockPhoneProvider exists only for tests, feel free to mock any method to imitate any use-case, exception, etc. + """ + + def make_notification_call(self, number: str, text: str): + pass + + def send_notification_sms(self, number: str, message: str): + pass + + def make_call(self, number: str, text: str): + pass + + def send_sms(self, number: str, text: str): + pass + + def send_verification_sms(self, number: str): + pass + + def make_verification_call(self, number: str): + pass + + def finish_verification(self, number: str, code: str): + pass + + @property + def flags(self) -> ProviderFlags: + return ProviderFlags( + configured=True, + test_sms=True, + test_call=True, + verification_call=True, + verification_sms=True, + ) diff --git a/engine/apps/phone_notifications/tests/test_phone_backend_call.py b/engine/apps/phone_notifications/tests/test_phone_backend_call.py new file mode 100644 index 0000000000..cab9e4066b --- /dev/null +++ b/engine/apps/phone_notifications/tests/test_phone_backend_call.py @@ -0,0 +1,227 @@ +from unittest import mock + +import pytest +from django.test import override_settings + +from apps.base.models import UserNotificationPolicy, UserNotificationPolicyLogRecord +from apps.phone_notifications.exceptions import ( + CallsLimitExceeded, + FailedToMakeCall, + NumberNotVerified, + ProviderNotSupports, +) +from apps.phone_notifications.models import PhoneCallRecord +from apps.phone_notifications.phone_backend import PhoneBackend + +notify = UserNotificationPolicy.Step.NOTIFY +notify_by_phone = 2 + + +@pytest.fixture() +def setup( + make_organization_and_user, make_alert_receive_channel, make_alert_group, make_alert, make_user_notification_policy +): + org, user = make_organization_and_user() + arc = make_alert_receive_channel(org) + alert_group = make_alert_group(arc) + make_alert(alert_group, {}) + notification_policy = make_user_notification_policy( + user, UserNotificationPolicy.Step.NOTIFY, notify_by=notify_by_phone + ) + + return user, alert_group, notification_policy + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._notify_by_provider_call") +@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=False) +def test_notify_by_call_uses_provider(mock_notify_by_provider_call, setup): + """ + test if make_provider_call called when GRAFANA_CLOUD_NOTIFICATIONS_ENABLED is False + """ + user, alert_group, notification_policy = setup + + phone_backend = PhoneBackend() + phone_backend.notify_by_call(user, alert_group, notification_policy) + + assert mock_notify_by_provider_call.called + assert ( + PhoneCallRecord.objects.filter( + exceeded_limit=False, + represents_alert_group=alert_group, + notification_policy=notification_policy, + receiver=user, + grafana_cloud_notification=False, + ).count() + == 1 + ) + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._notify_by_cloud_call") +@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=True) +def test_notify_by_call_uses_cloud(mock_notify_by_cloud_call, setup): + """ + test if notify_by_cloud_call called when GRAFANA_CLOUD_NOTIFICATIONS_ENABLED is True + """ + user, alert_group, notification_policy = setup + + phone_backend = PhoneBackend() + phone_backend.notify_by_call(user, alert_group, notification_policy) + + assert mock_notify_by_cloud_call.called + assert ( + PhoneCallRecord.objects.filter( + exceeded_limit=False, + represents_alert_group=alert_group, + notification_policy=notification_policy, + receiver=user, + grafana_cloud_notification=False, + ).count() + == 1 + ) + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=False) +@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=False) +def test_notify_by_provider_call_raises_number_not_verified( + mock_validate_user_number, + make_organization_and_user, +): + _, user = make_organization_and_user() + phone_backend = PhoneBackend() + + with pytest.raises(NumberNotVerified): + phone_backend._notify_by_provider_call(user, "some_message") + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=True) +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_phone_calls_left", return_value=0) +@mock.patch("apps.phone_notifications.tests.mock_phone_provider.MockPhoneProvider.make_notification_call") +@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=False) +def test_notify_by_provider_call_rases_limit_exceeded( + mock_make_notification_call, + mock_phone_calls_left, + mock_validate_user_number, + make_organization_and_user, +): + """ + test if CallsLimitExceeded raised when phone notifications limit is empty + """ + _, user = make_organization_and_user() + phone_backend = PhoneBackend() + + with pytest.raises(CallsLimitExceeded): + phone_backend._notify_by_provider_call(user, "some_message") + assert mock_make_notification_call.called is False + assert PhoneCallRecord.objects.all().count() == 0 + + +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=True) +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_phone_calls_left", return_value=2) +@mock.patch( + "apps.phone_notifications.phone_backend.PhoneBackend._add_call_limit_warning", return_value="mock warning value" +) +@mock.patch("apps.phone_notifications.tests.mock_phone_provider.MockPhoneProvider.make_notification_call") +@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=False) +@pytest.mark.django_db +def test_notify_by_provider_call_limits_warning( + mock_make_notification_call, + mock_add_call_limit_warning, + mock_validate_phone_calls_left, + mock_validate_user_number, + make_organization_and_user, +): + """ + test if warning message added to call message, when almost no phone notifications left + """ + _, user = make_organization_and_user() + phone_backend = PhoneBackend() + phone_backend._notify_by_provider_call(user, "some_message") + + assert mock_add_call_limit_warning.called_once_with(2, "some_message") + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._notify_by_provider_call") +@pytest.mark.parametrize( + "exc,log_err_code", + [ + (NumberNotVerified, UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_NUMBER_IS_NOT_VERIFIED), + (CallsLimitExceeded, UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_CALLS_LIMIT_EXCEEDED), + (FailedToMakeCall, UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_CALL), + (ProviderNotSupports, UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_CALL), + ], +) +@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=False) +def test_notify_by_call_handles_exceptions_from_provider( + mock_notify_by_provider_call, + setup, + exc, + log_err_code, +): + """ + test if UserNotificationPolicyLogRecord is created when exception is raised from _notify_by_provider_call. + _notify_by_provider_call is mocked to raise exceptions which may occur while checking if phone call possible to male and + exceptions from phone_provider also + """ + user, alert_group, notification_policy = setup + mock_notify_by_provider_call.side_effect = exc + + phone_backend = PhoneBackend() + phone_backend.notify_by_call(user, alert_group, notification_policy) + + assert ( + UserNotificationPolicyLogRecord.objects.filter( + author=user, + type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, + notification_policy=notification_policy, + alert_group=alert_group, + notification_error_code=log_err_code, + notification_step=notification_policy.step, + notification_channel=notification_policy.notify_by, + ).count() + == 1 + ) + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._notify_by_cloud_call") +@pytest.mark.parametrize( + "exc,log_err_code", + [ + (FailedToMakeCall, UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_CALL), + (NumberNotVerified, UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_NUMBER_IS_NOT_VERIFIED), + (CallsLimitExceeded, UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_CALLS_LIMIT_EXCEEDED), + ], +) +@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=True) +def test_notify_by_cloud_call_handles_exceptions_from_cloud( + mock_notify_by_cloud_call, + setup, + exc, + log_err_code, +): + """ + test if UserNotificationPolicyLogRecord is created when exception is raised from _notify_by_cloud_call + """ + user, alert_group, notification_policy = setup + mock_notify_by_cloud_call.side_effect = exc + + phone_backend = PhoneBackend() + phone_backend.notify_by_call(user, alert_group, notification_policy) + + assert ( + UserNotificationPolicyLogRecord.objects.filter( + author=user, + type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, + notification_policy=notification_policy, + alert_group=alert_group, + notification_error_code=log_err_code, + notification_step=notification_policy.step, + notification_channel=notification_policy.notify_by, + ).count() + == 1 + ) diff --git a/engine/apps/phone_notifications/tests/test_phone_backend_oss_relay.py b/engine/apps/phone_notifications/tests/test_phone_backend_oss_relay.py new file mode 100644 index 0000000000..4bacca062d --- /dev/null +++ b/engine/apps/phone_notifications/tests/test_phone_backend_oss_relay.py @@ -0,0 +1,111 @@ +from unittest import mock + +import pytest + +from apps.phone_notifications.exceptions import CallsLimitExceeded, NumberNotVerified, SMSLimitExceeded +from apps.phone_notifications.phone_backend import PhoneBackend +from apps.phone_notifications.tests.mock_phone_provider import MockPhoneProvider + + +@pytest.fixture(autouse=True) +def mock_phone_provider(monkeypatch): + def mock_get_provider(*args, **kwargs): + return MockPhoneProvider() + + monkeypatch.setattr(PhoneBackend, "_get_phone_provider", mock_get_provider) + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=True) +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_phone_calls_left", return_value=10) +@mock.patch("apps.phone_notifications.tests.mock_phone_provider.MockPhoneProvider.make_call") +def test_relay_oss_call( + mock_make_call, + mock_validate_user_number, + mock_phone_calls_left, + make_organization_and_user, +): + _, user = make_organization_and_user() + phone_backend = PhoneBackend() + phone_backend.relay_oss_call(user, "relayed_call") + assert mock_make_call.called + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=False) +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_phone_calls_left", return_value=10) +@mock.patch("apps.phone_notifications.tests.mock_phone_provider.MockPhoneProvider.make_call") +def test_relay_oss_call_number_not_verified( + mock_make_call, + mock_validate_user_number, + mock_phone_calls_left, + make_organization_and_user, +): + _, user = make_organization_and_user() + phone_backend = PhoneBackend() + with pytest.raises(NumberNotVerified): + phone_backend.relay_oss_call(user, "relayed_call") + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=True) +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_phone_calls_left", return_value=0) +@mock.patch("apps.phone_notifications.tests.mock_phone_provider.MockPhoneProvider.make_call") +def test_relay_oss_call_limit_exceed( + mock_make_call, + mock_validate_user_number, + mock_phone_calls_left, + make_organization_and_user, +): + _, user = make_organization_and_user() + phone_backend = PhoneBackend() + with pytest.raises(CallsLimitExceeded): + phone_backend.relay_oss_call(user, "relayed_call") + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=True) +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_sms_left", return_value=10) +@mock.patch("apps.phone_notifications.tests.mock_phone_provider.MockPhoneProvider.make_call") +def test_relay_oss_sms( + mock_send_sms, + mock_validate_user_number, + mock_sms_left, + make_organization_and_user, +): + _, user = make_organization_and_user() + phone_backend = PhoneBackend() + phone_backend.relay_oss_call(user, "relayed_call") + assert mock_send_sms.called + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=False) +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_sms_left", return_value=10) +@mock.patch("apps.phone_notifications.tests.mock_phone_provider.MockPhoneProvider.send_sms") +def test_relay_oss_sms_number_not_verified( + mock_send_sms, + mock_validate_user_number, + mock_sms_left, + make_organization_and_user, +): + _, user = make_organization_and_user() + phone_backend = PhoneBackend() + with pytest.raises(NumberNotVerified): + phone_backend.relay_oss_sms(user, "relayed_sms") + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=True) +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_sms_left", return_value=0) +@mock.patch("apps.phone_notifications.tests.mock_phone_provider.MockPhoneProvider.send_sms") +def test_relay_oss_sms_limit_exceed( + mock_send_sms, + mock_validate_user_number, + mock_sms_left, + make_organization_and_user, +): + _, user = make_organization_and_user() + phone_backend = PhoneBackend() + with pytest.raises(SMSLimitExceeded): + phone_backend.relay_oss_sms(user, "relayed_sms") diff --git a/engine/apps/phone_notifications/tests/test_phone_backend_phone_verification.py b/engine/apps/phone_notifications/tests/test_phone_backend_phone_verification.py new file mode 100644 index 0000000000..cfb8996c23 --- /dev/null +++ b/engine/apps/phone_notifications/tests/test_phone_backend_phone_verification.py @@ -0,0 +1,69 @@ +from unittest import mock + +import pytest + +from apps.phone_notifications.exceptions import NumberAlreadyVerified +from apps.phone_notifications.phone_backend import PhoneBackend +from apps.phone_notifications.tests.mock_phone_provider import MockPhoneProvider + + +@pytest.fixture(autouse=True) +def mock_phone_provider(monkeypatch): + def mock_get_provider(*args, **kwargs): + return MockPhoneProvider() + + monkeypatch.setattr(PhoneBackend, "_get_phone_provider", mock_get_provider) + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=False) +@mock.patch("apps.phone_notifications.tests.mock_phone_provider.MockPhoneProvider.send_verification_sms") +def test_send_verification_sms(mock_send_verification_sms, mock_validate_user_number, make_organization_and_user): + _, user = make_organization_and_user() + phone_backend = PhoneBackend() + + number_to_verify = "+1234567890" + user.unverified_phone_number = "+1234567890" + phone_backend.send_verification_sms(user) + mock_send_verification_sms.assert_called_once_with(number_to_verify) + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=True) +@mock.patch("apps.phone_notifications.tests.mock_phone_provider.MockPhoneProvider.send_verification_sms") +def test_send_verification_sms_raises_when_number_verified( + mock_send_verification_sms, mock__validate_user_number, make_organization_and_user +): + _, user = make_organization_and_user() + phone_backend = PhoneBackend() + + user.save_verified_phone_number("+1234567890") + with pytest.raises(NumberAlreadyVerified): + phone_backend.send_verification_sms(user) + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=False) +@mock.patch("apps.phone_notifications.tests.mock_phone_provider.MockPhoneProvider.make_verification_call") +def test_make_verification_call(mock_make_verification_call, mock_validate_user_number, make_organization_and_user): + _, user = make_organization_and_user() + phone_backend = PhoneBackend() + + number_to_verify = "+1234567890" + user.unverified_phone_number = "+1234567890" + phone_backend.make_verification_call(user) + mock_make_verification_call.assert_called_once_with(number_to_verify) + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=True) +@mock.patch("apps.phone_notifications.tests.mock_phone_provider.MockPhoneProvider.make_verification_call") +def test_make_verification_call_raises_when_number_verified( + mock_make_verification_call, mock__validate_user_number, make_organization_and_user +): + _, user = make_organization_and_user() + phone_backend = PhoneBackend() + + user.save_verified_phone_number("+1234567890") + with pytest.raises(NumberAlreadyVerified): + phone_backend.make_verification_call(user) diff --git a/engine/apps/phone_notifications/tests/test_phone_backend_sms.py b/engine/apps/phone_notifications/tests/test_phone_backend_sms.py new file mode 100644 index 0000000000..ec414ad548 --- /dev/null +++ b/engine/apps/phone_notifications/tests/test_phone_backend_sms.py @@ -0,0 +1,236 @@ +from unittest import mock + +import pytest +from django.test import override_settings + +from apps.base.models import UserNotificationPolicy, UserNotificationPolicyLogRecord +from apps.phone_notifications.exceptions import ( + FailedToSendSMS, + NumberNotVerified, + ProviderNotSupports, + SMSLimitExceeded, +) +from apps.phone_notifications.models import SMSRecord +from apps.phone_notifications.phone_backend import PhoneBackend +from apps.phone_notifications.tests.mock_phone_provider import MockPhoneProvider + +notify = UserNotificationPolicy.Step.NOTIFY +notify_by_phone = 2 + + +@pytest.fixture() +def setup( + make_organization_and_user, make_alert_receive_channel, make_alert_group, make_alert, make_user_notification_policy +): + org, user = make_organization_and_user() + arc = make_alert_receive_channel(org) + alert_group = make_alert_group(arc) + make_alert(alert_group, {}) + notification_policy = make_user_notification_policy( + user, UserNotificationPolicy.Step.NOTIFY, notify_by=notify_by_phone + ) + + return user, alert_group, notification_policy + + +@pytest.fixture(autouse=True) +def mock_phone_provider(monkeypatch): + def mock_get_provider(*args, **kwargs): + return MockPhoneProvider() + + monkeypatch.setattr(PhoneBackend, "_get_phone_provider", mock_get_provider) + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._notify_by_provider_sms") +@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=False) +def test_notify_by_sms_uses_provider(mock_notify_by_provider_sms, setup): + """ + test if _notify_by_provider_sms called when GRAFANA_CLOUD_NOTIFICATIONS_ENABLED is False + """ + user, alert_group, notification_policy = setup + + phone_backend = PhoneBackend() + phone_backend.notify_by_sms(user, alert_group, notification_policy) + + assert mock_notify_by_provider_sms.called + assert ( + SMSRecord.objects.filter( + exceeded_limit=False, + represents_alert_group=alert_group, + notification_policy=notification_policy, + receiver=user, + grafana_cloud_notification=False, + ).count() + == 1 + ) + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._notify_by_cloud_sms") +@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=True) +def test_notify_by_sms_uses_cloud(mock_notify_by_cloud_sms, setup): + """ + test if notify_by_cloud_sms called when GRAFANA_CLOUD_NOTIFICATIONS_ENABLED is True + """ + user, alert_group, notification_policy = setup + + phone_backend = PhoneBackend() + phone_backend.notify_by_sms(user, alert_group, notification_policy) + + assert mock_notify_by_cloud_sms.called + assert ( + SMSRecord.objects.filter( + exceeded_limit=False, + represents_alert_group=alert_group, + notification_policy=notification_policy, + receiver=user, + grafana_cloud_notification=False, + ).count() + == 1 + ) + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=False) +@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=False) +def test_notify_by_provider_sms_raises_number_not_verified( + mock_validate_user_number, + make_organization_and_user, +): + _, user = make_organization_and_user() + phone_backend = PhoneBackend() + + with pytest.raises(NumberNotVerified): + phone_backend._notify_by_provider_sms(user, "some_message") + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=True) +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_sms_left", return_value=0) +@mock.patch("apps.phone_notifications.tests.mock_phone_provider.MockPhoneProvider.send_notification_sms") +@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=False) +def test_notify_by_provider_sms_raises_limit_exceeded( + mock_send_notification_sms, + mock_sms_left, + mock_validate_user_number, + make_organization_and_user, +): + """ + test if SMSLimitExceeded raised when phone notifications limit is empty + """ + _, user = make_organization_and_user() + phone_backend = PhoneBackend() + + with pytest.raises(SMSLimitExceeded): + phone_backend._notify_by_provider_sms(user, "some_message") + assert mock_send_notification_sms.called is False + assert SMSRecord.objects.all().count() == 0 + + +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=True) +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_sms_left", return_value=2) +@mock.patch( + "apps.phone_notifications.phone_backend.PhoneBackend._add_sms_limit_warning", return_value="mock warning value" +) +@mock.patch("apps.phone_notifications.tests.mock_phone_provider.MockPhoneProvider.send_notification_sms") +@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=False) +@pytest.mark.django_db +def test_notify_by_provider_sms_limits_warning( + mock_send_notification_sms, + mock_add_sms_limit_warning, + mock_validate_phone_sms_left, + mock_validate_user_number, + make_organization_and_user, +): + """ + test if warning message added to message, when almost no phone notifications left + """ + _, user = make_organization_and_user() + phone_backend = PhoneBackend() + phone_backend._notify_by_provider_sms(user, "some_message") + + assert mock_add_sms_limit_warning.called_once_with(2, "some_message") + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._notify_by_provider_sms") +@pytest.mark.parametrize( + "exc,log_err_code", + [ + (NumberNotVerified, UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_NUMBER_IS_NOT_VERIFIED), + (SMSLimitExceeded, UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_SMS_LIMIT_EXCEEDED), + (FailedToSendSMS, UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_SEND_SMS), + (ProviderNotSupports, UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_SEND_SMS), + ], +) +@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=False) +def test_notify_by_sms_handles_exceptions_from_provider( + mock_notify_by_provider_sms, + setup, + exc, + log_err_code, +): + """ + test if UserNotificationPolicyLogRecord is created when exception is raised from _notify_by_provider_sms. + _notify_by_provider_sms is mocked to raise exceptions which may occur while checking if it's possible to send sms and + exceptions from phone_provider + """ + user, alert_group, notification_policy = setup + mock_notify_by_provider_sms.side_effect = exc + + phone_backend = PhoneBackend() + phone_backend.notify_by_sms(user, alert_group, notification_policy) + + assert ( + UserNotificationPolicyLogRecord.objects.filter( + author=user, + type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, + notification_policy=notification_policy, + alert_group=alert_group, + notification_error_code=log_err_code, + notification_step=notification_policy.step, + notification_channel=notification_policy.notify_by, + ).count() + == 1 + ) + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._notify_by_cloud_sms") +@pytest.mark.parametrize( + "exc,log_err_code", + [ + (FailedToSendSMS, UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_SEND_SMS), + (NumberNotVerified, UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_NUMBER_IS_NOT_VERIFIED), + (SMSLimitExceeded, UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_SMS_LIMIT_EXCEEDED), + ], +) +@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=True) +def test_notify_by_cloud_sms_handles_exceptions_from_cloud( + mock_notify_by_cloud_sms, + setup, + exc, + log_err_code, +): + """ + test if UserNotificationPolicyLogRecord is created when exception is raised from _notify_by_cloud_sms + """ + user, alert_group, notification_policy = setup + mock_notify_by_cloud_sms.side_effect = exc + + phone_backend = PhoneBackend() + phone_backend.notify_by_sms(user, alert_group, notification_policy) + + assert ( + UserNotificationPolicyLogRecord.objects.filter( + author=user, + type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, + notification_policy=notification_policy, + alert_group=alert_group, + notification_error_code=log_err_code, + notification_step=notification_policy.step, + notification_channel=notification_policy.notify_by, + ).count() + == 1 + ) diff --git a/engine/apps/public_api/views/phone_notifications.py b/engine/apps/public_api/views/phone_notifications.py index f9f96f74be..d52558f218 100644 --- a/engine/apps/public_api/views/phone_notifications.py +++ b/engine/apps/public_api/views/phone_notifications.py @@ -4,11 +4,17 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from twilio.base.exceptions import TwilioRestException from apps.auth_token.auth import ApiTokenAuthentication +from apps.phone_notifications.exceptions import ( + CallsLimitExceeded, + FailedToMakeCall, + FailedToSendSMS, + NumberNotVerified, + SMSLimitExceeded, +) +from apps.phone_notifications.phone_backend import PhoneBackend from apps.public_api.throttlers.phone_notification_throttler import PhoneNotificationThrottler -from apps.twilioapp.models import PhoneCall, SMSMessage logger = logging.getLogger(__name__) @@ -33,21 +39,20 @@ def post(self, request): response_data = {} organization = self.request.auth.organization logger.info(f"Making cloud call. Email {serializer.validated_data['email']}") - user = organization.users.filter( - email=serializer.validated_data["email"], _verified_phone_number__isnull=False - ).first() + user = organization.users.filter(email=serializer.validated_data["email"]).first() if user is None: response_data = {"error": "user-not-found"} return Response(status=status.HTTP_404_NOT_FOUND, data=response_data) + phone_backend = PhoneBackend() try: - PhoneCall.make_grafana_cloud_call(user, serializer.validated_data["message"]) - except TwilioRestException as e: - logger.info(f"Making cloud call. Twilio exception {str(e)}") - return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE, data=response_data) - except PhoneCall.PhoneCallsLimitExceeded: - logger.info(f"Making cloud call. PhoneCallsLimitExceeded") + phone_backend.relay_oss_call(user, serializer.validated_data["message"]) + except FailedToMakeCall: + return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE, data={"error": "failed"}) + except CallsLimitExceeded: return Response(status=status.HTTP_400_BAD_REQUEST, data={"error": "limit-exceeded"}) + except NumberNotVerified: + return Response(status=status.HTTP_400_BAD_REQUEST, data={"error": "number-not-verified"}) return Response(status=status.HTTP_200_OK, data=response_data) @@ -74,13 +79,14 @@ def post(self, request): response_data = {"error": "user-not-found"} return Response(status=status.HTTP_404_NOT_FOUND, data=response_data) + phone_backend = PhoneBackend() try: - SMSMessage.send_grafana_cloud_sms(user, serializer.validated_data["message"]) - except TwilioRestException as e: - logger.info(f"Sending cloud sms. Twilio exception {str(e)}") - return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE, data=response_data) - except SMSMessage.SMSLimitExceeded: - logger.info(f"Sending cloud sms. PhoneCallsLimitExceeded") + phone_backend.relay_oss_sms(user, serializer.validated_data["message"]) + except FailedToSendSMS: + return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE, data={"error": "failed"}) + except SMSLimitExceeded: return Response(status=status.HTTP_400_BAD_REQUEST, data={"error": "limit-exceeded"}) + except NumberNotVerified: + return Response(status=status.HTTP_400_BAD_REQUEST, data={"error": "number-not-verified"}) return Response(status=status.HTTP_200_OK, data=response_data) diff --git a/engine/apps/twilioapp/admin.py b/engine/apps/twilioapp/admin.py deleted file mode 100644 index c769ff5c0c..0000000000 --- a/engine/apps/twilioapp/admin.py +++ /dev/null @@ -1,17 +0,0 @@ -from django.contrib import admin - -from common.admin import CustomModelAdmin - -from .models import SMSMessage, TwilioLogRecord - - -@admin.register(TwilioLogRecord) -class TwilioLogRecordAdmin(CustomModelAdmin): - list_display = ("id", "user", "phone_number", "type", "status", "succeed", "created_at") - list_filter = ("created_at", "type", "status", "succeed") - - -@admin.register(SMSMessage) -class SMSMessageAdmin(CustomModelAdmin): - list_display = ("id", "receiver", "represents_alert_group", "notification_policy", "created_at") - list_filter = ("created_at",) diff --git a/engine/apps/twilioapp/constants.py b/engine/apps/twilioapp/constants.py deleted file mode 100644 index 5785077e43..0000000000 --- a/engine/apps/twilioapp/constants.py +++ /dev/null @@ -1,108 +0,0 @@ -class TwilioMessageStatuses(object): - """ - https://www.twilio.com/docs/sms/tutorials/how-to-confirm-delivery-python?code-sample=code-handle-a-sms-statuscallback&code-language=Python&code-sdk-version=5.x#receive-status-events-in-your-web-application - https://www.twilio.com/docs/sms/api/message-resource#message-status-values - """ - - ACCEPTED = 10 - QUEUED = 20 - SENDING = 30 - SENT = 40 - FAILED = 50 - DELIVERED = 60 - UNDELIVERED = 70 - RECEIVING = 80 - RECEIVED = 90 - READ = 100 - - CHOICES = ( - (ACCEPTED, "accepted"), - (QUEUED, "queued"), - (SENDING, "sending"), - (SENT, "sent"), - (FAILED, "failed"), - (DELIVERED, "delivered"), - (UNDELIVERED, "undelivered"), - (RECEIVING, "receiving"), - (RECEIVED, "received"), - (READ, "read"), - ) - - DETERMINANT = { - "accepted": ACCEPTED, - "queued": QUEUED, - "sending": SENDING, - "sent": SENT, - "failed": FAILED, - "delivered": DELIVERED, - "undelivered": UNDELIVERED, - "receiving": RECEIVING, - "received": RECEIVED, - "read": READ, - } - - -class TwilioCallStatuses(object): - """ - https://www.twilio.com/docs/voice/twiml#callstatus-values - """ - - QUEUED = 10 - RINGING = 20 - IN_PROGRESS = 30 - COMPLETED = 40 - BUSY = 50 - FAILED = 60 - NO_ANSWER = 70 - CANCELED = 80 - - CHOICES = ( - (QUEUED, "queued"), - (RINGING, "ringing"), - (IN_PROGRESS, "in-progress"), - (COMPLETED, "completed"), - (BUSY, "busy"), - (FAILED, "failed"), - (NO_ANSWER, "no-answer"), - (CANCELED, "canceled"), - ) - - DETERMINANT = { - "queued": QUEUED, - "ringing": RINGING, - "in-progress": IN_PROGRESS, - "completed": COMPLETED, - "busy": BUSY, - "failed": FAILED, - "no-answer": NO_ANSWER, - "canceled": CANCELED, - } - - -class TwilioLogRecordType(object): - VERIFICATION_START = 10 - VERIFICATION_CHECK = 20 - - CHOICES = ((VERIFICATION_START, "verification start"), (VERIFICATION_CHECK, "verification check")) - - -class TwilioLogRecordStatus(object): - # For verification and check it has used the same statuses - # https://www.twilio.com/docs/verify/api/verification#verification-response-properties - # https://www.twilio.com/docs/verify/api/verification-check - - PENDING = 10 - APPROVED = 20 - DENIED = 30 - # Our customized status for TwilioException - ERROR = 40 - - CHOICES = ((PENDING, "pending"), (APPROVED, "approved"), (DENIED, "denied"), (ERROR, "error")) - - DETERMINANT = {"pending": PENDING, "approved": APPROVED, "denied": DENIED, "error": ERROR} - - -TEST_CALL_TEXT = ( - "You are invited to check an incident from Grafana OnCall. " - "Alert via {channel_name} with title {alert_group_name} triggered {alerts_count} times" -) diff --git a/engine/apps/twilioapp/gather.py b/engine/apps/twilioapp/gather.py new file mode 100644 index 0000000000..fffce9aac3 --- /dev/null +++ b/engine/apps/twilioapp/gather.py @@ -0,0 +1,79 @@ +from django.apps import apps +from django.urls import reverse +from twilio.twiml.voice_response import Gather, VoiceResponse + +from apps.alerts.constants import ActionSource +from apps.twilioapp.models import TwilioPhoneCall +from common.api_helpers.utils import create_engine_url + + +def process_gather_data(call_sid: str, digit: str) -> VoiceResponse: + """ + The function processes pressed digit at call time + + Args: + call_sid (str): + digit (str): user pressed digit + + Returns: + response (VoiceResponse) + """ + + response = VoiceResponse() + + if digit in ["1", "2", "3"]: + # Success case + response.say(f"You have pressed digit {digit}") + process_digit(call_sid, digit) + else: + # Error wrong digit pressing + gather = Gather(action=get_gather_url(), method="POST", num_digits=1) + + response.say("Wrong digit") + gather.say(get_gather_message()) + + response.append(gather) + + return response + + +def process_digit(call_sid, digit): + """ + The function get Phone Call instance according to call_sid + and run process of pressed digit + + Args: + call_sid (str): + digit (str): + + Returns: + + """ + if call_sid and digit: + twilio_phone_call = TwilioPhoneCall.objects.filter(sid=call_sid).first() + # Check twilio phone call and then oncall phone call for backward compatibility after PhoneCall migration. + # Will be removed soon. + if twilio_phone_call: + phone_call_record = twilio_phone_call.phone_call_record + else: + PhoneCallRecord = apps.get_model("phone_notifications", "PhoneCallRecord") + phone_call_record = PhoneCallRecord.objects.filter(sid=call_sid).first() + + if phone_call_record is not None: + alert_group = phone_call_record.represents_alert_group + user = phone_call_record.receiver + + if digit == "1": + alert_group.acknowledge_by_user(user, action_source=ActionSource.PHONE) + elif digit == "2": + alert_group.resolve_by_user(user, action_source=ActionSource.PHONE) + elif digit == "3": + alert_group.silence_by_user(user, silence_delay=1800, action_source=ActionSource.PHONE) + + +def get_gather_url(): + return create_engine_url(reverse("twilioapp:gather")) + + +def get_gather_message(): + return "Press 1 to acknowledge, 2 to resolve, 3 to silence to 30 minutes" diff --git a/engine/apps/twilioapp/migrations/0003_auto_20230408_0711.py b/engine/apps/twilioapp/migrations/0003_auto_20230408_0711.py new file mode 100644 index 0000000000..8329119b10 --- /dev/null +++ b/engine/apps/twilioapp/migrations/0003_auto_20230408_0711.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.18 on 2023-04-08 07:11 + +from django.db import migrations +import django_migration_linter as linter + + +class Migration(migrations.Migration): + + dependencies = [ + ('twilioapp', '0002_auto_20220604_1008'), + ] + + state_operations = [ + migrations.DeleteModel('PhoneCall'), + migrations.DeleteModel('SMSMessage') + ] + + operations = [ + migrations.SeparateDatabaseAndState( + state_operations=state_operations + ) + ] diff --git a/engine/apps/twilioapp/migrations/0004_twiliophonecall_twiliosms.py b/engine/apps/twilioapp/migrations/0004_twiliophonecall_twiliosms.py new file mode 100644 index 0000000000..8be33dd1b8 --- /dev/null +++ b/engine/apps/twilioapp/migrations/0004_twiliophonecall_twiliosms.py @@ -0,0 +1,41 @@ +# Generated by Django 3.2.18 on 2023-05-24 03:54 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('phone_notifications', '0001_initial'), + ('twilioapp', '0003_auto_20230408_0711'), + ] + + operations = [ + migrations.CreateModel( + name='TwilioSMS', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.PositiveSmallIntegerField(blank=True, choices=[(10, 'accepted'), (20, 'queued'), (30, 'sending'), (40, 'sent'), (50, 'failed'), (60, 'delivered'), (70, 'undelivered'), (80, 'receiving'), (90, 'received'), (100, 'read')], null=True)), + ('sid', models.CharField(blank=True, max_length=50)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('sms_record', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='twilioapp_twiliosms_related', related_query_name='twilioapp_twiliosmss', to='phone_notifications.smsrecord')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='TwilioPhoneCall', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.PositiveSmallIntegerField(blank=True, choices=[(10, 'queued'), (20, 'ringing'), (30, 'in-progress'), (40, 'completed'), (50, 'busy'), (60, 'failed'), (70, 'no-answer'), (80, 'canceled')], null=True)), + ('sid', models.CharField(blank=True, max_length=50)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('phone_call_record', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='twilio_phone_call', to='phone_notifications.phonecallrecord')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/engine/apps/twilioapp/models/__init__.py b/engine/apps/twilioapp/models/__init__.py index b3d32d81c6..75450a50b3 100644 --- a/engine/apps/twilioapp/models/__init__.py +++ b/engine/apps/twilioapp/models/__init__.py @@ -1,3 +1,3 @@ -from .phone_call import PhoneCall # noqa: F401 -from .sms_message import SMSMessage # noqa: F401 from .twilio_log_record import TwilioLogRecord # noqa: F401 +from .twilio_phone_call import TwilioCallStatuses, TwilioPhoneCall # noqa: F401 +from .twilio_sms import TwilioSMS, TwilioSMSstatuses # noqa: F401 diff --git a/engine/apps/twilioapp/models/phone_call.py b/engine/apps/twilioapp/models/phone_call.py deleted file mode 100644 index b0db9f91f6..0000000000 --- a/engine/apps/twilioapp/models/phone_call.py +++ /dev/null @@ -1,272 +0,0 @@ -import logging - -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 -from apps.alerts.incident_appearance.renderers.phone_call_renderer import AlertGroupPhoneCallRenderer -from apps.alerts.signals import user_notification_action_triggered_signal -from apps.base.utils import live_settings -from apps.twilioapp.constants import TwilioCallStatuses -from apps.twilioapp.twilio_client import twilio_client -from common.api_helpers.utils import create_engine_url -from common.utils import clean_markup, escape_for_twilio_phone_call - -logger = logging.getLogger(__name__) - - -class PhoneCallManager(models.Manager): - def update_status(self, call_sid, call_status): - """The function checks existence of PhoneCall instance - according to call_sid and updates status on message_status - - Args: - call_sid (str): sid of Twilio call - call_status (str): new status - - Returns: - - """ - UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") - - if call_sid and call_status: - phone_call_qs = self.filter(sid=call_sid) - - status = TwilioCallStatuses.DETERMINANT.get(call_status) - - if phone_call_qs.exists() and status: - phone_call_qs.update(status=status) - phone_call = phone_call_qs.first() - if phone_call.grafana_cloud_notification: - # If call was made via grafana twilio it is don't needed to create logs on it's delivery status. - return - log_record = None - if status == TwilioCallStatuses.COMPLETED: - log_record = UserNotificationPolicyLogRecord( - author=phone_call.receiver, - type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_SUCCESS, - notification_policy=phone_call.notification_policy, - alert_group=phone_call.represents_alert_group, - notification_step=phone_call.notification_policy.step - if phone_call.notification_policy - else None, - notification_channel=phone_call.notification_policy.notify_by - if phone_call.notification_policy - else None, - ) - elif status in [TwilioCallStatuses.FAILED, TwilioCallStatuses.BUSY, TwilioCallStatuses.NO_ANSWER]: - log_record = UserNotificationPolicyLogRecord( - author=phone_call.receiver, - type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, - notification_policy=phone_call.notification_policy, - alert_group=phone_call.represents_alert_group, - notification_error_code=PhoneCall.get_error_code_by_twilio_status(status), - notification_step=phone_call.notification_policy.step - if phone_call.notification_policy - else None, - notification_channel=phone_call.notification_policy.notify_by - if phone_call.notification_policy - else None, - ) - - if log_record is not None: - log_record.save() - user_notification_action_triggered_signal.send( - sender=PhoneCall.objects.update_status, log_record=log_record - ) - - def get_and_process_digit(self, call_sid, digit): - """The function get Phone Call instance according to call_sid - and run process of pressed digit - - Args: - call_sid (str): - digit (str): - - Returns: - - """ - if call_sid and digit: - phone_call = self.filter(sid=call_sid).first() - - if phone_call: - phone_call.process_digit(digit=digit) - - -class PhoneCall(models.Model): - - objects = PhoneCallManager() - - exceeded_limit = models.BooleanField(null=True, default=None) - represents_alert = models.ForeignKey("alerts.Alert", on_delete=models.SET_NULL, null=True, default=None) - represents_alert_group = models.ForeignKey("alerts.AlertGroup", on_delete=models.SET_NULL, null=True, default=None) - notification_policy = models.ForeignKey( - "base.UserNotificationPolicy", on_delete=models.SET_NULL, null=True, default=None - ) - - receiver = models.ForeignKey("user_management.User", on_delete=models.CASCADE, null=True, default=None) - - status = models.PositiveSmallIntegerField( - blank=True, - null=True, - choices=TwilioCallStatuses.CHOICES, - ) - - sid = models.CharField( - blank=True, - max_length=50, - ) - - created_at = models.DateTimeField(auto_now_add=True) - - grafana_cloud_notification = models.BooleanField(default=False) - - class PhoneCallsLimitExceeded(Exception): - """Phone calls limit exceeded""" - - 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 - - Args: - digit (str): - - Returns: - - """ - alert_group = self.represents_alert_group - - if digit == "1": - alert_group.acknowledge_by_user(self.receiver, action_source=ActionSource.TWILIO) - elif digit == "2": - alert_group.resolve_by_user(self.receiver, action_source=ActionSource.TWILIO) - elif digit == "3": - alert_group.silence_by_user(self.receiver, silence_delay=1800, action_source=ActionSource.TWILIO) - - @property - def created_for_slack(self): - return bool(self.represents_alert_group.slack_message) - - @classmethod - def _make_cloud_call(cls, user, message_body): - url = create_engine_url("api/v1/make_call", override_base=settings.GRAFANA_CLOUD_ONCALL_API_URL) - auth = {"Authorization": live_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_200_OK: - logger.info("Make cloud call successfully") - 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: - 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, - notification_policy=notification_policy, - alert_group=alert_group, - notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_CALL, - notification_step=notification_policy.step if notification_policy else None, - notification_channel=notification_policy.notify_by if notification_policy else None, - ) - except PhoneCall.PhoneCallsLimitExceeded: - log_record = UserNotificationPolicyLogRecord( - author=user, - type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, - notification_policy=notification_policy, - alert_group=alert_group, - notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_CALLS_LIMIT_EXCEEDED, - notification_step=notification_policy.step if notification_policy else None, - notification_channel=notification_policy.notify_by if notification_policy else None, - ) - except PhoneCall.PhoneNumberNotVerifiedError: - log_record = UserNotificationPolicyLogRecord( - author=user, - type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, - notification_policy=notification_policy, - alert_group=alert_group, - notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_NUMBER_IS_NOT_VERIFIED, - notification_step=notification_policy.step if notification_policy else None, - notification_channel=notification_policy.notify_by if notification_policy else None, - ) - - if log_record is not None: - log_record.save() - user_notification_action_triggered_signal.send(sender=PhoneCall.make_call, log_record=log_record) - - @classmethod - def make_grafana_cloud_call(cls, user, message_body): - message_body = escape_for_twilio_phone_call(clean_markup(message_body)) - cls._make_call(user, message_body, grafana_cloud=True) - - @classmethod - def _make_call(cls, user, message_body, alert_group=None, notification_policy=None, grafana_cloud=False): - if not user.verified_phone_number: - raise PhoneCall.PhoneNumberNotVerifiedError("User phone number is not verified") - - phone_call = PhoneCall( - represents_alert_group=alert_group, - receiver=user, - notification_policy=notification_policy, - grafana_cloud_notification=grafana_cloud, - ) - phone_calls_left = user.organization.phone_calls_left(user) - - if phone_calls_left <= 0: - phone_call.exceeded_limit = True - phone_call.save() - raise PhoneCall.PhoneCallsLimitExceeded("Organization calls limit exceeded") - - phone_call.exceeded_limit = False - if phone_calls_left < 3: - message_body += " {} phone calls left. Contact your admin.".format(phone_calls_left) - - twilio_call = twilio_client.make_call(message_body, user.verified_phone_number, grafana_cloud=grafana_cloud) - if twilio_call.status and twilio_call.sid: - phone_call.status = TwilioCallStatuses.DETERMINANT.get(twilio_call.status, None) - phone_call.sid = twilio_call.sid - phone_call.save() - - return phone_call - - @staticmethod - def get_error_code_by_twilio_status(status): - UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") - - TWILIO_ERRORS_TO_ERROR_CODES_MAP = { - TwilioCallStatuses.BUSY: UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_CALL_LINE_BUSY, - TwilioCallStatuses.FAILED: UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_CALL_FAILED, - TwilioCallStatuses.NO_ANSWER: UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_CALL_NO_ANSWER, - } - - return TWILIO_ERRORS_TO_ERROR_CODES_MAP.get(status, None) diff --git a/engine/apps/twilioapp/models/sms_message.py b/engine/apps/twilioapp/models/sms_message.py deleted file mode 100644 index 55aea7e8a0..0000000000 --- a/engine/apps/twilioapp/models/sms_message.py +++ /dev/null @@ -1,240 +0,0 @@ -import logging - -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 -from apps.alerts.signals import user_notification_action_triggered_signal -from apps.base.utils import live_settings -from apps.twilioapp.constants import TwilioMessageStatuses -from apps.twilioapp.twilio_client import twilio_client -from common.api_helpers.utils import create_engine_url -from common.utils import clean_markup - -logger = logging.getLogger(__name__) - - -class SMSMessageManager(models.Manager): - def update_status(self, message_sid, message_status): - """The function checks existence of SMSMessage - instance according to message_sid and updates status on - message_status - - Args: - message_sid (str): sid of Twilio message - message_status (str): new status - - Returns: - - """ - UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") - - if message_sid and message_status: - sms_message_qs = self.filter(sid=message_sid) - - status = TwilioMessageStatuses.DETERMINANT.get(message_status) - - if sms_message_qs.exists() and status: - sms_message_qs.update(status=status) - - sms_message = sms_message_qs.first() - if sms_message.grafana_cloud_notification: - # If sms was sent via grafana cloud notifications don't create logs on its delivery status. - return - log_record = None - - if status == TwilioMessageStatuses.DELIVERED: - log_record = UserNotificationPolicyLogRecord( - author=sms_message.receiver, - type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_SUCCESS, - notification_policy=sms_message.notification_policy, - alert_group=sms_message.represents_alert_group, - notification_step=sms_message.notification_policy.step - if sms_message.notification_policy - else None, - notification_channel=sms_message.notification_policy.notify_by - if sms_message.notification_policy - else None, - ) - elif status in [TwilioMessageStatuses.UNDELIVERED, TwilioMessageStatuses.FAILED]: - log_record = UserNotificationPolicyLogRecord( - author=sms_message.receiver, - type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, - notification_policy=sms_message.notification_policy, - alert_group=sms_message.represents_alert_group, - notification_error_code=sms_message.get_error_code_by_twilio_status(status), - notification_step=sms_message.notification_policy.step - if sms_message.notification_policy - else None, - notification_channel=sms_message.notification_policy.notify_by - if sms_message.notification_policy - else None, - ) - if log_record is not None: - log_record.save() - user_notification_action_triggered_signal.send( - sender=SMSMessage.objects.update_status, log_record=log_record - ) - - -class SMSMessage(models.Model): - objects = SMSMessageManager() - - exceeded_limit = models.BooleanField(null=True, default=None) - represents_alert = models.ForeignKey("alerts.Alert", on_delete=models.SET_NULL, null=True, default=None) - represents_alert_group = models.ForeignKey("alerts.AlertGroup", on_delete=models.SET_NULL, null=True, default=None) - notification_policy = models.ForeignKey( - "base.UserNotificationPolicy", on_delete=models.SET_NULL, null=True, default=None - ) - - receiver = models.ForeignKey("user_management.User", on_delete=models.CASCADE, null=True, default=None) - - status = models.PositiveSmallIntegerField( - blank=True, - null=True, - choices=TwilioMessageStatuses.CHOICES, - ) - grafana_cloud_notification = models.BooleanField(default=False) - - # https://www.twilio.com/docs/sms/api/message-resource#message-properties - sid = models.CharField( - blank=True, - max_length=50, - ) - - created_at = models.DateTimeField(auto_now_add=True) - - class SMSLimitExceeded(Exception): - """SMS limit exceeded""" - - 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_cloud_sms(cls, user, message_body): - url = create_engine_url("api/v1/send_sms", override_base=settings.GRAFANA_CLOUD_ONCALL_API_URL) - auth = {"Authorization": live_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_200_OK: - logger.info("Sent cloud sms successfully") - elif 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: - 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) as e: - logger.warning(f"Unable to send sms. Exception {e}") - log_record = UserNotificationPolicyLogRecord( - author=user, - type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, - notification_policy=notification_policy, - alert_group=alert_group, - notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_SEND_SMS, - notification_step=notification_policy.step if notification_policy else None, - notification_channel=notification_policy.notify_by if notification_policy else None, - ) - except SMSMessage.SMSLimitExceeded as e: - logger.warning(f"Unable to send sms. Exception {e}") - log_record = UserNotificationPolicyLogRecord( - author=user, - type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, - notification_policy=notification_policy, - alert_group=alert_group, - notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_SMS_LIMIT_EXCEEDED, - notification_step=notification_policy.step if notification_policy else None, - notification_channel=notification_policy.notify_by if notification_policy else None, - ) - except SMSMessage.PhoneNumberNotVerifiedError as e: - logger.warning(f"Unable to send sms. Exception {e}") - log_record = UserNotificationPolicyLogRecord( - author=user, - type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, - notification_policy=notification_policy, - alert_group=alert_group, - notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_NUMBER_IS_NOT_VERIFIED, - notification_step=notification_policy.step if notification_policy else None, - notification_channel=notification_policy.notify_by if notification_policy else None, - ) - - if log_record is not None: - log_record.save() - user_notification_action_triggered_signal.send(sender=SMSMessage.send_sms, log_record=log_record) - - @classmethod - def send_grafana_cloud_sms(cls, user, message_body): - message_body = clean_markup(message_body) - cls._send_sms(user, message_body, grafana_cloud=True) - - @classmethod - def _send_sms(cls, user, message_body, alert_group=None, notification_policy=None, grafana_cloud=False): - if not user.verified_phone_number: - raise SMSMessage.PhoneNumberNotVerifiedError("User phone number is not verified") - - sms_message = SMSMessage( - represents_alert_group=alert_group, - receiver=user, - notification_policy=notification_policy, - grafana_cloud_notification=grafana_cloud, - ) - sms_left = user.organization.sms_left(user) - - if sms_left <= 0: - sms_message.exceeded_limit = True - sms_message.save() - raise SMSMessage.SMSLimitExceeded("Organization sms limit exceeded") - - sms_message.exceeded_limit = False - if sms_left < 3: - message_body += " {} sms left. Contact your admin.".format(sms_left) - - twilio_message = twilio_client.send_message(message_body, user.verified_phone_number) - if twilio_message.status and twilio_message.sid: - sms_message.status = TwilioMessageStatuses.DETERMINANT.get(twilio_message.status, None) - sms_message.sid = twilio_message.sid - sms_message.save() - - return sms_message - - @staticmethod - def get_error_code_by_twilio_status(status): - UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") - - TWILIO_ERRORS_TO_ERROR_CODES_MAP = { - TwilioMessageStatuses.UNDELIVERED: UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_SMS_DELIVERY_FAILED, - TwilioMessageStatuses.FAILED: UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_SMS_DELIVERY_FAILED, - } - - return TWILIO_ERRORS_TO_ERROR_CODES_MAP.get(status, None) diff --git a/engine/apps/twilioapp/models/twilio_log_record.py b/engine/apps/twilioapp/models/twilio_log_record.py index f4530b5d30..bec915c2ef 100644 --- a/engine/apps/twilioapp/models/twilio_log_record.py +++ b/engine/apps/twilioapp/models/twilio_log_record.py @@ -1,8 +1,30 @@ from django.db import models -from apps.twilioapp.constants import TwilioLogRecordStatus, TwilioLogRecordType +class TwilioLogRecordType(object): + VERIFICATION_START = 10 + VERIFICATION_CHECK = 20 + CHOICES = ((VERIFICATION_START, "verification start"), (VERIFICATION_CHECK, "verification check")) + + +class TwilioLogRecordStatus(object): + # For verification and check it has used the same statuses + # https://www.twilio.com/docs/verify/api/verification#verification-response-properties + # https://www.twilio.com/docs/verify/api/verification-check + + PENDING = 10 + APPROVED = 20 + DENIED = 30 + # Our customized status for TwilioException + ERROR = 40 + + CHOICES = ((PENDING, "pending"), (APPROVED, "approved"), (DENIED, "denied"), (ERROR, "error")) + + DETERMINANT = {"pending": PENDING, "approved": APPROVED, "denied": DENIED, "error": ERROR} + + +# Deprecated model. Kept here for backward compatibility, should be removed after phone notificator release class TwilioLogRecord(models.Model): user = models.ForeignKey("user_management.User", on_delete=models.CASCADE) diff --git a/engine/apps/twilioapp/models/twilio_phone_call.py b/engine/apps/twilioapp/models/twilio_phone_call.py new file mode 100644 index 0000000000..4b4423ebfa --- /dev/null +++ b/engine/apps/twilioapp/models/twilio_phone_call.py @@ -0,0 +1,72 @@ +import logging + +from django.db import models + +from apps.phone_notifications.models import PhoneCallRecord +from apps.phone_notifications.phone_provider import ProviderPhoneCall + +logger = logging.getLogger(__name__) + + +class TwilioCallStatuses: + """ + https://www.twilio.com/docs/voice/twiml#callstatus-values + """ + + QUEUED = 10 + RINGING = 20 + IN_PROGRESS = 30 + COMPLETED = 40 + BUSY = 50 + FAILED = 60 + NO_ANSWER = 70 + CANCELED = 80 + + CHOICES = ( + (QUEUED, "queued"), + (RINGING, "ringing"), + (IN_PROGRESS, "in-progress"), + (COMPLETED, "completed"), + (BUSY, "busy"), + (FAILED, "failed"), + (NO_ANSWER, "no-answer"), + (CANCELED, "canceled"), + ) + + DETERMINANT = { + "queued": QUEUED, + "ringing": RINGING, + "in-progress": IN_PROGRESS, + "completed": COMPLETED, + "busy": BUSY, + "failed": FAILED, + "no-answer": NO_ANSWER, + "canceled": CANCELED, + } + + +class TwilioPhoneCall(ProviderPhoneCall, models.Model): + + status = models.PositiveSmallIntegerField( + blank=True, + null=True, + choices=TwilioCallStatuses.CHOICES, + ) + + phone_call_record = models.OneToOneField( + "phone_notifications.PhoneCallRecord", + on_delete=models.CASCADE, + related_name="twilio_phone_call", + null=False, + ) + + sid = models.CharField( + blank=True, + max_length=50, + ) + + created_at = models.DateTimeField(auto_now_add=True) + + def link_and_save(self, phone_call_record: PhoneCallRecord): + self.phone_call_record = phone_call_record + self.save() diff --git a/engine/apps/twilioapp/models/twilio_sms.py b/engine/apps/twilioapp/models/twilio_sms.py new file mode 100644 index 0000000000..8050d50bce --- /dev/null +++ b/engine/apps/twilioapp/models/twilio_sms.py @@ -0,0 +1,63 @@ +from django.db import models + +from apps.phone_notifications.models import ProviderSMS + + +class TwilioSMSstatuses: + """ + https://www.twilio.com/docs/sms/tutorials/how-to-confirm-delivery-python?code-sample=code-handle-a-sms-statuscallback&code-language=Python&code-sdk-version=5.x#receive-status-events-in-your-web-application + https://www.twilio.com/docs/sms/api/message-resource#message-status-values + """ + + ACCEPTED = 10 + QUEUED = 20 + SENDING = 30 + SENT = 40 + FAILED = 50 + DELIVERED = 60 + UNDELIVERED = 70 + RECEIVING = 80 + RECEIVED = 90 + READ = 100 + + CHOICES = ( + (ACCEPTED, "accepted"), + (QUEUED, "queued"), + (SENDING, "sending"), + (SENT, "sent"), + (FAILED, "failed"), + (DELIVERED, "delivered"), + (UNDELIVERED, "undelivered"), + (RECEIVING, "receiving"), + (RECEIVED, "received"), + (READ, "read"), + ) + + DETERMINANT = { + "accepted": ACCEPTED, + "queued": QUEUED, + "sending": SENDING, + "sent": SENT, + "failed": FAILED, + "delivered": DELIVERED, + "undelivered": UNDELIVERED, + "receiving": RECEIVING, + "received": RECEIVED, + "read": READ, + } + + +class TwilioSMS(ProviderSMS, models.Model): + status = models.PositiveSmallIntegerField( + blank=True, + null=True, + choices=TwilioSMSstatuses.CHOICES, + ) + + # https://www.twilio.com/docs/sms/api/message-resource#message-properties + sid = models.CharField( + blank=True, + max_length=50, + ) + + created_at = models.DateTimeField(auto_now_add=True) diff --git a/engine/apps/twilioapp/phone_manager.py b/engine/apps/twilioapp/phone_manager.py deleted file mode 100644 index e26c64e88b..0000000000 --- a/engine/apps/twilioapp/phone_manager.py +++ /dev/null @@ -1,75 +0,0 @@ -import logging - -from twilio.base.exceptions import TwilioRestException - -from apps.twilioapp.twilio_client import twilio_client - -logger = logging.getLogger(__name__) - - -class PhoneManager: - def __init__(self, user): - self.user = user - - def send_verification_code(self): - if self.user.unverified_phone_number != self.user.verified_phone_number: - res = twilio_client.verification_start_via_twilio( - user=self.user, phone_number=self.user.unverified_phone_number, via="sms" - ) - if res and res.status != "denied": - return True - else: - logger.error(f"Failed to send verification code to User {self.user.pk}:\n{res}") - return False - - def verify_phone_number(self, code): - normalized_phone_number, _ = twilio_client.normalize_phone_number_via_twilio(self.user.unverified_phone_number) - if normalized_phone_number: - if normalized_phone_number == self.user.verified_phone_number: - verified = False - error = "This Phone Number has already been verified." - elif twilio_client.verification_check_via_twilio( - user=self.user, - phone_number=normalized_phone_number, - code=code, - ): - old_verified_phone_number = self.user.verified_phone_number - self.user.save_verified_phone_number(normalized_phone_number) - # send sms to the new number and to the old one - if old_verified_phone_number: - # notify about disconnect - self.notify_about_changed_verified_phone_number(old_verified_phone_number) - # notify about new connection - self.notify_about_changed_verified_phone_number(normalized_phone_number, True) - - verified = True - error = None - else: - verified = False - error = "Verification code is not correct." - else: - verified = False - error = "Phone Number is incorrect." - return verified, error - - def forget_phone_number(self): - if self.user.verified_phone_number or self.user.unverified_phone_number: - old_verified_phone_number = self.user.verified_phone_number - self.user.clear_phone_numbers() - if old_verified_phone_number: - self.notify_about_changed_verified_phone_number(old_verified_phone_number) - return True - return False - - def notify_about_changed_verified_phone_number(self, phone_number, connected=False): - text = ( - f"This phone number has been {'connected to' if connected else 'disconnected from'} Grafana OnCall team " - f'"{self.user.organization.stack_slug}"\nYour Grafana OnCall <3' - ) - try: - twilio_client.send_message(text, phone_number) - except TwilioRestException as e: - logger.error( - f"Failed to notify user {self.user.pk} about phone number " - f"{'connection' if connected else 'disconnection'}:\n{e}" - ) diff --git a/engine/apps/twilioapp/phone_provider.py b/engine/apps/twilioapp/phone_provider.py new file mode 100644 index 0000000000..09b2480d37 --- /dev/null +++ b/engine/apps/twilioapp/phone_provider.py @@ -0,0 +1,256 @@ +import logging +import urllib.parse +from string import digits + +from phonenumbers import COUNTRY_CODE_TO_REGION_CODE +from twilio.base.exceptions import TwilioRestException +from twilio.rest import Client + +from apps.base.models import LiveSetting +from apps.base.utils import live_settings +from apps.phone_notifications.exceptions import ( + FailedToFinishVerification, + FailedToMakeCall, + FailedToSendSMS, + FailedToStartVerification, +) +from apps.phone_notifications.phone_provider import PhoneProvider, ProviderFlags +from apps.twilioapp.gather import get_gather_message, get_gather_url +from apps.twilioapp.models import TwilioCallStatuses, TwilioPhoneCall, TwilioSMS +from apps.twilioapp.status_callback import get_call_status_callback_url, get_sms_status_callback_url + +logger = logging.getLogger(__name__) + + +class TwilioPhoneProvider(PhoneProvider): + def make_notification_call(self, number: str, message: str) -> TwilioPhoneCall: + message = self._escape_call_message(message) + + twiml_query = self._message_to_twiml(message, with_gather=True) + + response = None + try_without_callback = False + + try: + response = self._call_create(twiml_query, number, with_callback=True) + except TwilioRestException as e: + # If status callback is not valid and not accessible from public url then trying to send message without it + # https://www.twilio.com/docs/api/errors/21609 + if e.code == 21609: + logger.info(f"TwilioPhoneProvider.make_notification_call: error 21609, calling without callback_url") + try_without_callback = True + else: + logger.error(f"TwilioPhoneProvider.make_notification_call: failed {e}") + raise FailedToMakeCall + + if try_without_callback: + try: + response = self._call_create(twiml_query, number, with_callback=False) + except TwilioRestException as e: + logger.error(f"TwilioPhoneProvider.make_notification_call: failed {e}") + raise FailedToMakeCall + + if response and response.status and response.sid: + return TwilioPhoneCall( + status=TwilioCallStatuses.DETERMINANT.get(response.status, None), + sid=response.sid, + ) + + def send_notification_sms(self, number: str, message: str) -> TwilioSMS: + try_without_callback = False + response = None + + try: + response = self._messages_create(number, message, with_callback=True) + except TwilioRestException as e: + # If status callback is not valid and not accessible from public url then trying to send message without it + # https://www.twilio.com/docs/api/errors/21609 + if e.code == 21609: + logger.info(f"TwilioPhoneProvider.send_notification_sms: error 21609, sending without callback_url") + try_without_callback = True + else: + logger.error(f"TwilioPhoneProvider.send_notification_sms: failed {e}") + raise FailedToSendSMS + + if try_without_callback: + try: + response = self._messages_create(number, message, with_callback=False) + except TwilioRestException as e: + logger.error(f"TwilioPhoneProvider.send_notification_sms: failed {e}") + raise FailedToSendSMS + + if response and response.status and response.sid: + return TwilioSMS( + status=TwilioCallStatuses.DETERMINANT.get(response.status, None), + sid=response.sid, + ) + + def send_verification_sms(self, number: str): + self._send_verification_code(number, via="sms") + + def finish_verification(self, number: str, code: str): + # I'm not sure if we need verification_and_parse via twilio pipeline here + # Verification code anyway is sent to not verified phone number. + # Just leaving it as it was before phone_provider refactoring. + normalized_number, _ = self._normalize_phone_number(number) + if normalized_number: + try: + verification_check = self._twilio_api_client.verify.services( + live_settings.TWILIO_VERIFY_SERVICE_SID + ).verification_checks.create(to=normalized_number, code=code) + logger.info(f"TwilioPhoneProvider.finish_verification: verification_status {verification_check.status}") + if verification_check.status == "approved": + return normalized_number + except TwilioRestException as e: + logger.error(f"TwilioPhoneProvider.finish_verification: failed to verify number {number}: {e}") + raise FailedToFinishVerification + else: + return None + + def make_call(self, number: str, message: str): + twiml_query = self._message_to_twiml(message, with_gather=False) + try: + self._call_create(twiml_query, number, with_callback=False) + except TwilioRestException as e: + logger.error(f"TwilioPhoneProvider.make_call: failed {e}") + raise FailedToMakeCall + + def send_sms(self, number: str, message: str): + try: + self._messages_create(number, message, with_callback=False) + except TwilioRestException as e: + logger.error(f"TwilioPhoneProvider.send_sms: failed {e}") + raise FailedToSendSMS + + def _message_to_twiml(self, message: str, with_gather=False): + q = f"{message}" + if with_gather: + gather_subquery = f'{get_gather_message()}' + q = f"{message}{gather_subquery}" + return urllib.parse.quote( + q, + safe="", + ) + + def _call_create(self, twiml_query: str, to: str, with_callback: bool): + url = "http://twimlets.com/echo?Twiml=" + twiml_query + if with_callback: + status_callback = get_call_status_callback_url() + status_callback_events = ["initiated", "ringing", "answered", "completed"] + return self._twilio_api_client.calls.create( + url=url, + to=to, + from_=self._twilio_number, + method="GET", + status_callback=status_callback, + status_callback_event=status_callback_events, + status_callback_method="POST", + ) + else: + return self._twilio_api_client.calls.create( + url=url, + to=to, + from_=self._twilio_number, + method="GET", + ) + + def _messages_create(self, number: str, text: str, with_callback: bool): + if with_callback: + status_callback = get_sms_status_callback_url() + return self._twilio_api_client.messages.create( + body=text, to=number, from_=self._twilio_number, status_callback=status_callback + ) + else: + return self._twilio_api_client.messages.create( + body=text, + to=number, + from_=self._twilio_number, + ) + + def _send_verification_code(self, number: str, via: str): + # https://www.twilio.com/docs/verify/api/verification?code-sample=code-start-a-verification-with-sms&code-language=Python&code-sdk-version=6.x + try: + verification = self._twilio_api_client.verify.services( + live_settings.TWILIO_VERIFY_SERVICE_SID + ).verifications.create(to=number, channel=via) + logger.info(f"TwilioPhoneProvider._send_verification_code: verification status {verification.status}") + except TwilioRestException as e: + logger.error(f"Twilio verification start error: {e} to number {number}") + raise FailedToStartVerification + + def _normalize_phone_number(self, number: str): + # TODO: phone_provider: is it best place to parse phone number? + number = self._parse_phone_number(number) + + # Verify and parse phone number with Twilio API + normalized_phone_number = None + country_code = None + if number != "" and number != "+": + try: + ok, normalized_phone_number, country_code = self._parse_number(number) + if normalized_phone_number == "": + normalized_phone_number = None + country_code = None + if not ok: + normalized_phone_number = None + country_code = None + except TypeError: + return None, None + + return normalized_phone_number, country_code + + # Use responsibly + def _parse_number(self, number: str): + try: + response = self._twilio_api_client.lookups.phone_numbers(number).fetch() + return True, response.phone_number, self._get_calling_code(response.country_code) + except TwilioRestException as e: + if e.code == 20404: + # Not sure, why 20404 (NotFound according to TwilioDocs) handled gracefully, leaving it as it is. + # https://www.twilio.com/docs/api/errors/20404" + return False, None, None + if e.code == 20003: + raise e + except KeyError as e: + # Don't know why KeyError is gracefully handled here, probably exception raised by twilio_client. + logger.info(f"twilio_client._parse_number: Gracefully handle KeyError: {e}") + return False, None, None + + @property + def _twilio_api_client(self): + if live_settings.TWILIO_API_KEY_SID and live_settings.TWILIO_API_KEY_SECRET: + return Client( + live_settings.TWILIO_API_KEY_SID, live_settings.TWILIO_API_KEY_SECRET, live_settings.TWILIO_ACCOUNT_SID + ) + else: + return Client(live_settings.TWILIO_ACCOUNT_SID, live_settings.TWILIO_AUTH_TOKEN) + + def _get_calling_code(self, iso): + for code, isos in COUNTRY_CODE_TO_REGION_CODE.items(): + if iso.upper() in isos: + return code + return None + + @property + def _twilio_number(self): + return live_settings.TWILIO_NUMBER + + def _escape_call_message(self, message): + # https://www.twilio.com/docs/api/errors/12100 + message = message.replace("&", "&") + message = message.replace(">", ">") + message = message.replace("<", "<") + return message + + def _parse_phone_number(self, raw_phone_number): + return "+" + "".join(c for c in raw_phone_number if c in digits) + + @property + def flags(self) -> ProviderFlags: + return ProviderFlags( + configured=not LiveSetting.objects.filter(name__startswith="TWILIO", error__isnull=False).exists(), + test_sms=True, + test_call=True, + verification_call=True, + verification_sms=True, + ) diff --git a/engine/apps/twilioapp/status_callback.py b/engine/apps/twilioapp/status_callback.py new file mode 100644 index 0000000000..067884aeb6 --- /dev/null +++ b/engine/apps/twilioapp/status_callback.py @@ -0,0 +1,142 @@ +from django.apps import apps +from django.urls import reverse + +from apps.alerts.signals import user_notification_action_triggered_signal +from apps.twilioapp.models import TwilioCallStatuses, TwilioPhoneCall, TwilioSMS, TwilioSMSstatuses +from common.api_helpers.utils import create_engine_url + + +def update_twilio_call_status(call_sid, call_status): + """The function checks existence of TwilioPhoneCall instance + according to call_sid and updates status on message_status + + Args: + call_sid (str): sid of Twilio call + call_status (str): new status + + Returns: + + """ + UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") + + if call_sid and call_status: + status = TwilioCallStatuses.DETERMINANT.get(call_status) + + twilio_phone_call = TwilioPhoneCall.objects.filter(sid=call_sid).first() + + # Check twilio phone call and then oncall phone call for backward compatibility after PhoneCall migration. + # Will be removed soon. + if twilio_phone_call: + status = TwilioCallStatuses.DETERMINANT.get(call_status) + twilio_phone_call.status = status + twilio_phone_call.save(update_fields=["status"]) + phone_call_record = twilio_phone_call.phone_call_record + else: + PhoneCallRecord = apps.get_model("phone_notifications", "PhoneCallRecord") + phone_call_record = PhoneCallRecord.objects.filter(sid=call_sid).first() + + if phone_call_record and status: + log_record_type = None + log_record_error_code = None + + if status == TwilioCallStatuses.COMPLETED: + log_record_type = UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_SUCCESS + elif status in [TwilioCallStatuses.FAILED, TwilioCallStatuses.BUSY, TwilioCallStatuses.NO_ANSWER]: + log_record_type = UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED + log_record_error_code = get_error_code_by_twilio_status(status) + + 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, + ) + user_notification_action_triggered_signal.send(sender=update_twilio_call_status, log_record=log_record) + + +def get_error_code_by_twilio_status(status): + UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") + TWILIO_ERRORS_TO_ERROR_CODES_MAP = { + TwilioCallStatuses.BUSY: UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_CALL_LINE_BUSY, + TwilioCallStatuses.FAILED: UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_CALL_FAILED, + TwilioCallStatuses.NO_ANSWER: UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_CALL_NO_ANSWER, + } + return TWILIO_ERRORS_TO_ERROR_CODES_MAP.get(status, None) + + +def update_twilio_sms_status(message_sid, message_status): + """The function checks existence of SMSMessage + instance according to message_sid and updates status on + message_status + + Args: + message_sid (str): sid of Twilio message + message_status (str): new status + + Returns: + + """ + UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") + + if message_sid and message_status: + status = TwilioSMSstatuses.DETERMINANT.get(message_status) + + twilio_sms = TwilioSMS.objects.filter(sid=message_sid).first() + + # Check twilio phone call and then oncall phone call for backward compatibility after PhoneCall migration. + # Will be removed soon. + if twilio_sms: + twilio_sms.status = status + twilio_sms.save(update_fields=["status"]) + sms_record = twilio_sms.sms_record + else: + PhoneCallRecord = apps.get_model("phone_notifications", "PhoneCallRecord") + sms_record = PhoneCallRecord.objects.filter(sid=message_sid).first() + + if sms_record and status: + log_record_type = None + log_record_error_code = None + if status == TwilioSMSstatuses.DELIVERED: + log_record_type = UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_SUCCESS + elif status in [TwilioSMSstatuses.UNDELIVERED, TwilioSMSstatuses.FAILED]: + log_record_type = UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED + log_record_error_code = get_sms_error_code_by_twilio_status(status) + + if log_record_type is not None: + log_record = UserNotificationPolicyLogRecord( + type=log_record_type, + notification_error_code=log_record_error_code, + author=sms_record.receiver, + notification_policy=sms_record.notification_policy, + alert_group=sms_record.represents_alert_group, + notification_step=sms_record.notification_policy.step if sms_record.notification_policy else None, + notification_channel=sms_record.notification_policy.notify_by + if sms_record.notification_policy + else None, + ) + user_notification_action_triggered_signal.send(sender=update_twilio_sms_status, log_record=log_record) + + +def get_sms_error_code_by_twilio_status(status): + UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") + TWILIO_ERRORS_TO_ERROR_CODES_MAP = { + TwilioSMSstatuses.UNDELIVERED: UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_SMS_DELIVERY_FAILED, + TwilioSMSstatuses.FAILED: UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_SMS_DELIVERY_FAILED, + } + return TWILIO_ERRORS_TO_ERROR_CODES_MAP.get(status, None) + + +def get_call_status_callback_url(): + return create_engine_url(reverse("twilioapp:call_status_events")) + + +def get_sms_status_callback_url(): + return create_engine_url(reverse("twilioapp:sms_status_events")) diff --git a/engine/apps/twilioapp/tests/factories.py b/engine/apps/twilioapp/tests/factories.py deleted file mode 100644 index e1b49940ca..0000000000 --- a/engine/apps/twilioapp/tests/factories.py +++ /dev/null @@ -1,13 +0,0 @@ -import factory - -from apps.twilioapp.models import PhoneCall, SMSMessage - - -class PhoneCallFactory(factory.DjangoModelFactory): - class Meta: - model = PhoneCall - - -class SMSFactory(factory.DjangoModelFactory): - class Meta: - model = SMSMessage diff --git a/engine/apps/twilioapp/tests/test_phone_calls.py b/engine/apps/twilioapp/tests/test_phone_calls.py index 17ec355624..4fa2aaed29 100644 --- a/engine/apps/twilioapp/tests/test_phone_calls.py +++ b/engine/apps/twilioapp/tests/test_phone_calls.py @@ -1,81 +1,46 @@ -import urllib from unittest import mock import pytest from bs4 import BeautifulSoup from django.urls import reverse -from django.utils import timezone from django.utils.datastructures import MultiValueDict from django.utils.http import urlencode from rest_framework.test import APIClient from apps.base.models import UserNotificationPolicy -from apps.twilioapp.constants import TwilioCallStatuses -from apps.twilioapp.models import PhoneCall -from apps.twilioapp.utils import get_gather_message - - -class FakeTwilioCall: - def __init__(self): - self.sid = "123" - self.status = TwilioCallStatuses.COMPLETED +from apps.twilioapp.models import TwilioCallStatuses, TwilioPhoneCall @pytest.fixture -def phone_call_setup( +def make_twilio_phone_call( make_organization_and_user, make_alert_receive_channel, make_user_notification_policy, make_alert_group, + make_phone_call_record, make_alert, - make_phone_call, ): organization, user = make_organization_and_user() alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) - make_alert( - alert_group, - raw_request_data={ - "status": "firing", - "labels": { - "alertname": "TestAlert", - "region": "eu-1", - }, - "annotations": {}, - "startsAt": "2018-12-25T15:47:47.377363608Z", - "endsAt": "0001-01-01T00:00:00Z", - "generatorURL": "", - }, - ) - + make_alert(alert_group, raw_request_data="{}") notification_policy = make_user_notification_policy( user=user, step=UserNotificationPolicy.Step.NOTIFY, notify_by=UserNotificationPolicy.NotificationChannel.PHONE_CALL, ) - - phone_call = make_phone_call( + phone_call_record = make_phone_call_record( receiver=user, - status=TwilioCallStatuses.QUEUED, represents_alert_group=alert_group, - sid="SMa12312312a123a123123c6dd2f1aee77", notification_policy=notification_policy, ) - - return phone_call, alert_group - - -@pytest.mark.django_db -def test_phone_call_creation(phone_call_setup): - phone_call, _ = phone_call_setup - assert PhoneCall.objects.count() == 1 - assert phone_call == PhoneCall.objects.first() + return TwilioPhoneCall.objects.create(sid="SMa12312312a123a123123c6dd2f1aee77", phone_call_record=phone_call_record) @pytest.mark.django_db -def test_forbidden_requests(phone_call_setup): +def test_forbidden_requests(make_twilio_phone_call): """Tests check inaccessibility of twilio urls for unauthorized requests""" - phone_call, _ = phone_call_setup + twilio_phone_call = make_twilio_phone_call # empty data case data = {} @@ -91,7 +56,7 @@ def test_forbidden_requests(phone_call_setup): assert response.data["detail"] == "You do not have permission to perform this action." # wrong AccountSid data - data = {"CallSid": phone_call.sid, "CallStatus": "completed", "AccountSid": "TopSecretAccountSid"} + data = {"CallSid": twilio_phone_call.sid, "CallStatus": "completed", "AccountSid": "TopSecretAccountSid"} client = APIClient() response = client.post( @@ -118,19 +83,16 @@ def test_forbidden_requests(phone_call_setup): @mock.patch("apps.twilioapp.views.AllowOnlyTwilio.has_permission") -@mock.patch("apps.slack.slack_client.SlackClientWithErrorHandling.api_call") @pytest.mark.django_db -def test_update_status(mock_has_permission, mock_slack_api_call, phone_call_setup): +def test_update_status(mock_has_permission, make_twilio_phone_call): """The test for PhoneCall status update via api""" - phone_call, _ = phone_call_setup + twilio_phone_call = make_twilio_phone_call mock_has_permission.return_value = True for status in ["in-progress", "completed", "busy", "failed", "no-answer", "canceled"]: - mock_slack_api_call.return_value = {"ok": True, "ts": timezone.now().timestamp()} - data = { - "CallSid": phone_call.sid, + "CallSid": twilio_phone_call.sid, "CallStatus": status, "AccountSid": "Because of mock_has_permission there are may be any value", } @@ -145,21 +107,21 @@ def test_update_status(mock_has_permission, mock_slack_api_call, phone_call_setu assert response.status_code == 204 assert response.data == "" - phone_call.refresh_from_db() - assert phone_call.status == TwilioCallStatuses.DETERMINANT[status] + twilio_phone_call.refresh_from_db() + assert twilio_phone_call.status == TwilioCallStatuses.DETERMINANT[status] @mock.patch("apps.twilioapp.views.AllowOnlyTwilio.has_permission") -@mock.patch("apps.twilioapp.utils.get_gather_url") +@mock.patch("apps.twilioapp.gather.get_gather_url") @pytest.mark.django_db -def test_acknowledge_by_phone(mock_has_permission, mock_get_gather_url, phone_call_setup): - phone_call, alert_group = phone_call_setup - +def test_acknowledge_by_phone(mock_has_permission, mock_get_gather_url, make_twilio_phone_call): + twilio_phone_call = make_twilio_phone_call + alert_group = twilio_phone_call.phone_call_record.represents_alert_group mock_has_permission.return_value = True mock_get_gather_url.return_value = reverse("twilioapp:gather") data = { - "CallSid": phone_call.sid, + "CallSid": twilio_phone_call.sid, "Digits": "1", "AccountSid": "Because of mock_has_permission there are may be any value", } @@ -183,20 +145,21 @@ def test_acknowledge_by_phone(mock_has_permission, mock_get_gather_url, phone_ca @mock.patch("apps.twilioapp.views.AllowOnlyTwilio.has_permission") -@mock.patch("apps.twilioapp.utils.get_gather_url") +@mock.patch("apps.twilioapp.gather.get_gather_url") @pytest.mark.django_db -def test_resolve_by_phone(mock_has_permission, mock_get_gather_url, phone_call_setup): - phone_call, alert_group = phone_call_setup +def test_resolve_by_phone(mock_has_permission, mock_get_gather_url, make_twilio_phone_call): + twilio_phone_call = make_twilio_phone_call mock_has_permission.return_value = True mock_get_gather_url.return_value = reverse("twilioapp:gather") data = { - "CallSid": phone_call.sid, + "CallSid": twilio_phone_call.sid, "Digits": "2", "AccountSid": "Because of mock_has_permission there are may be any value", } + alert_group = twilio_phone_call.phone_call_record.represents_alert_group assert alert_group.resolved is False client = APIClient() @@ -217,21 +180,22 @@ def test_resolve_by_phone(mock_has_permission, mock_get_gather_url, phone_call_s @mock.patch("apps.twilioapp.views.AllowOnlyTwilio.has_permission") -@mock.patch("apps.twilioapp.utils.get_gather_url") +@mock.patch("apps.twilioapp.gather.get_gather_url") @pytest.mark.django_db -def test_silence_by_phone(mock_has_permission, mock_get_gather_url, phone_call_setup): - phone_call, alert_group = phone_call_setup +def test_silence_by_phone(mock_has_permission, mock_get_gather_url, make_twilio_phone_call): + twilio_phone_call = make_twilio_phone_call mock_has_permission.return_value = True mock_get_gather_url.return_value = reverse("twilioapp:gather") data = { - "CallSid": phone_call.sid, + "CallSid": twilio_phone_call.sid, "Digits": "3", "AccountSid": "Because of mock_has_permission there are may be any value", } - assert alert_group.silenced_until is None + alert_group = twilio_phone_call.phone_call_record.represents_alert_group + assert alert_group.resolved is False client = APIClient() response = client.post( @@ -250,16 +214,16 @@ def test_silence_by_phone(mock_has_permission, mock_get_gather_url, phone_call_s @mock.patch("apps.twilioapp.views.AllowOnlyTwilio.has_permission") -@mock.patch("apps.twilioapp.utils.get_gather_url") +@mock.patch("apps.twilioapp.gather.get_gather_url") @pytest.mark.django_db -def test_wrong_pressed_digit(mock_has_permission, mock_get_gather_url, phone_call_setup): - phone_call, _ = phone_call_setup +def test_wrong_pressed_digit(mock_has_permission, mock_get_gather_url, make_twilio_phone_call): + twilio_phone_call = make_twilio_phone_call mock_has_permission.return_value = True mock_get_gather_url.return_value = reverse("twilioapp:gather") data = { - "CallSid": phone_call.sid, + "CallSid": twilio_phone_call.sid, "Digits": "0", "AccountSid": "Because of mock_has_permission there are may be any value", } @@ -276,58 +240,3 @@ def test_wrong_pressed_digit(mock_has_permission, mock_get_gather_url, phone_cal assert response.status_code == 200 assert "Wrong digit" in content - - -@mock.patch("apps.twilioapp.twilio_client.Client") -@pytest.mark.django_db -def test_make_cloud_phone_call_not_gathering_digit(mock_twilio_client, make_organization, make_user): - organization = make_organization() - user = make_user(organization=organization, _verified_phone_number="9999555") - mock_twilio_client.return_value.calls.create.return_value = FakeTwilioCall() - - PhoneCall.make_grafana_cloud_call(user, "the message") - - gather_message = urllib.parse.quote(get_gather_message()) - assert gather_message not in mock_twilio_client.return_value.calls.create.call_args.kwargs["url"] - - -@mock.patch("apps.twilioapp.twilio_client.Client") -@pytest.mark.django_db -def test_make_phone_call_gathering_digit( - mock_twilio_client, - make_organization, - make_user, - make_user_notification_policy, - make_alert_receive_channel, - make_alert_group, - make_alert, -): - organization = make_organization() - user = make_user(organization=organization, _verified_phone_number="9999555") - alert_receive_channel = make_alert_receive_channel(organization) - alert_group = make_alert_group(alert_receive_channel) - notification_policy = make_user_notification_policy( - user=user, - step=UserNotificationPolicy.Step.NOTIFY, - notify_by=UserNotificationPolicy.NotificationChannel.PHONE_CALL, - ) - make_alert( - alert_group, - raw_request_data={ - "status": "firing", - "labels": { - "alertname": "TestAlert", - "region": "eu-1", - }, - "annotations": {}, - "startsAt": "2018-12-25T15:47:47.377363608Z", - "endsAt": "0001-01-01T00:00:00Z", - "generatorURL": "", - }, - ) - mock_twilio_client.return_value.calls.create.return_value = FakeTwilioCall() - - PhoneCall.make_call(user, alert_group, notification_policy) - - gather_message = urllib.parse.quote(get_gather_message()) - assert gather_message in mock_twilio_client.return_value.calls.create.call_args.kwargs["url"] diff --git a/engine/apps/twilioapp/tests/test_sms_message.py b/engine/apps/twilioapp/tests/test_sms_message.py index 86ab2390a0..bba035b5bb 100644 --- a/engine/apps/twilioapp/tests/test_sms_message.py +++ b/engine/apps/twilioapp/tests/test_sms_message.py @@ -2,72 +2,44 @@ import pytest from django.urls import reverse -from django.utils import timezone from django.utils.datastructures import MultiValueDict from django.utils.http import urlencode from rest_framework.test import APIClient from apps.base.models import UserNotificationPolicy -from apps.twilioapp.constants import TwilioMessageStatuses -from apps.twilioapp.models import SMSMessage +from apps.twilioapp.models import TwilioSMS, TwilioSMSstatuses @pytest.fixture -def sms_message_setup( +def make_twilio_sms( make_organization_and_user, make_alert_receive_channel, make_user_notification_policy, make_alert_group, make_alert, - make_phone_call, + make_sms_record, ): organization, user = make_organization_and_user() alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) - make_alert( - alert_group, - raw_request_data={ - "status": "firing", - "labels": { - "alertname": "TestAlert", - "region": "eu-1", - }, - "annotations": {}, - "startsAt": "2018-12-25T15:47:47.377363608Z", - "endsAt": "0001-01-01T00:00:00Z", - "generatorURL": "", - }, - ) - + make_alert(alert_group, raw_request_data="{}") notification_policy = make_user_notification_policy( user=user, step=UserNotificationPolicy.Step.NOTIFY, - notify_by=UserNotificationPolicy.NotificationChannel.SMS, + notify_by=UserNotificationPolicy.NotificationChannel.PHONE_CALL, ) - - sms_message = SMSMessage.objects.create( - represents_alert_group=alert_group, + sms_record = make_sms_record( receiver=user, - sid="SMa12312312a123a123123c6dd2f1aee77", - status=TwilioMessageStatuses.QUEUED, + represents_alert_group=alert_group, notification_policy=notification_policy, ) - - return sms_message, alert_group - - -@pytest.mark.django_db -def test_sms_message_creation(sms_message_setup): - sms_message, _ = sms_message_setup - - assert SMSMessage.objects.count() == 1 - assert sms_message == SMSMessage.objects.first() + return TwilioSMS.objects.create(sid="SMa12312312a123a123123c6dd2f1aee77", sms_record=sms_record) @pytest.mark.django_db -def test_forbidden_requests(sms_message_setup): +def test_forbidden_requests(make_twilio_sms): """Tests check inaccessibility of twilio urls for unauthorized requests""" - sms_message, _ = sms_message_setup + twilio_sms = make_twilio_sms # empty data case data = {} @@ -83,7 +55,7 @@ def test_forbidden_requests(sms_message_setup): assert response.data["detail"] == "You do not have permission to perform this action." # wrong AccountSid data - data = {"MessageSid": sms_message.sid, "MessageStatus": "delivered", "AccountSid": "TopSecretAccountSid"} + data = {"MessageSid": twilio_sms.sid, "MessageStatus": "delivered", "AccountSid": "TopSecretAccountSid"} response = client.post( path=reverse("twilioapp:sms_status_events"), @@ -108,35 +80,24 @@ def test_forbidden_requests(sms_message_setup): @mock.patch("apps.twilioapp.views.AllowOnlyTwilio.has_permission") -@mock.patch("apps.slack.slack_client.SlackClientWithErrorHandling.api_call") @pytest.mark.django_db -def test_update_status(mock_has_permission, mock_slack_api_call, sms_message_setup): +def test_update_status(mock_has_permission, mock_slack_api_call, make_twilio_sms): """The test for SMSMessage status update via api""" - sms_message, _ = sms_message_setup - - # https://stackoverflow.com/questions/50157543/unittest-django-mock-external-api-what-is-proper-way - # Define response for the fake SlackClientWithErrorHandling.api_call + twilio_sms = make_twilio_sms mock_has_permission.return_value = True - for status in ["delivered", "failed", "undelivered"]: - mock_slack_api_call.return_value = {"ok": True, "ts": timezone.now().timestamp()} - data = { - "MessageSid": sms_message.sid, + "MessageSid": twilio_sms.sid, "MessageStatus": status, "AccountSid": "Because of mock_has_permission there are may be any value", } - # https://stackoverflow.com/questions/11571474/djangos-test-client-with-multiple-values-for-data-keys - client = APIClient() response = client.post( path=reverse("twilioapp:sms_status_events"), data=urlencode(MultiValueDict(data), doseq=True), content_type="application/x-www-form-urlencoded", ) - assert response.status_code == 204 assert response.data == "" - - sms_message.refresh_from_db() - assert sms_message.status == TwilioMessageStatuses.DETERMINANT[status] + twilio_sms.refresh_from_db() + assert twilio_sms.status == TwilioSMSstatuses.DETERMINANT[status] diff --git a/engine/apps/twilioapp/tests/test_twilio_provider.py b/engine/apps/twilioapp/tests/test_twilio_provider.py new file mode 100644 index 0000000000..20892109bd --- /dev/null +++ b/engine/apps/twilioapp/tests/test_twilio_provider.py @@ -0,0 +1,65 @@ +from unittest import mock + +import pytest + +from apps.twilioapp.phone_provider import TwilioPhoneProvider + + +class MockTwilioCallInstance: + status = "mock_status" + sid = "mock_sid" + + +@pytest.mark.django_db +@mock.patch("apps.twilioapp.phone_provider.TwilioPhoneProvider._call_create", return_value=MockTwilioCallInstance()) +@mock.patch("apps.twilioapp.phone_provider.TwilioPhoneProvider._message_to_twiml", return_value="mocked_twiml") +def test_make_notification_call(mock_twiml, mock_call_create): + number = "+1234567890" + message = "Hello" + provider = TwilioPhoneProvider() + provider_call = provider.make_notification_call(number, message) + mock_call_create.assert_called_once_with("mocked_twiml", number, with_callback=True) + assert provider_call is not None + assert provider_call.sid == MockTwilioCallInstance.sid + assert provider_call.id is None # test that provider_call is returned by notification call and NOT saved + + +@pytest.mark.django_db +@mock.patch("apps.twilioapp.phone_provider.TwilioPhoneProvider._call_create", return_value=MockTwilioCallInstance()) +@mock.patch("apps.twilioapp.phone_provider.TwilioPhoneProvider._message_to_twiml", return_value="mocked_twiml") +def test_make_call(mock_twiml, mock_call_create): + number = "+1234567890" + message = "Hello" + provider = TwilioPhoneProvider() + provider_call = provider.make_call(number, message) + assert provider_call is None # test that provider_call is not returned from make_call + mock_call_create.assert_called_once_with("mocked_twiml", number, with_callback=False) + + +class MockTwilioSMSInstance: + status = "mock_status" + sid = "mock_sid" + + +@pytest.mark.django_db +@mock.patch("apps.twilioapp.phone_provider.TwilioPhoneProvider._messages_create", return_value=MockTwilioSMSInstance()) +def test_send_notification_sms(mock_messages_create): + number = "+1234567890" + message = "Hello" + provider = TwilioPhoneProvider() + provider_sms = provider.send_notification_sms(number, message) + mock_messages_create.assert_called_once_with(number, message, with_callback=True) + assert provider_sms is not None + assert provider_sms.sid == MockTwilioCallInstance.sid + assert provider_sms.id is None # test that provider_call is returned by notification call and NOT saved + + +@pytest.mark.django_db +@mock.patch("apps.twilioapp.phone_provider.TwilioPhoneProvider._messages_create", return_value=MockTwilioSMSInstance()) +def test_send_sms(mock_messages_create): + number = "+1234567890" + message = "Hello" + provider = TwilioPhoneProvider() + provider_sms = provider.send_sms(number, message) + assert provider_sms is None # test that provider_sms is not returned from send_sms + mock_messages_create.assert_called_once_with(number, message, with_callback=False) diff --git a/engine/apps/twilioapp/twilio_client.py b/engine/apps/twilioapp/twilio_client.py deleted file mode 100644 index 75d0640382..0000000000 --- a/engine/apps/twilioapp/twilio_client.py +++ /dev/null @@ -1,206 +0,0 @@ -import logging -import urllib.parse - -from django.apps import apps -from django.urls import reverse -from twilio.base.exceptions import TwilioRestException -from twilio.rest import Client - -from apps.base.utils import live_settings -from apps.twilioapp.constants import TEST_CALL_TEXT, TwilioLogRecordStatus, TwilioLogRecordType -from apps.twilioapp.utils import get_calling_code, get_gather_message, get_gather_url, parse_phone_number -from common.api_helpers.utils import create_engine_url - -logger = logging.getLogger(__name__) - - -class TwilioClient: - @property - def twilio_api_client(self): - if live_settings.TWILIO_API_KEY_SID and live_settings.TWILIO_API_KEY_SECRET: - return Client( - live_settings.TWILIO_API_KEY_SID, live_settings.TWILIO_API_KEY_SECRET, live_settings.TWILIO_ACCOUNT_SID - ) - else: - return Client(live_settings.TWILIO_ACCOUNT_SID, live_settings.TWILIO_AUTH_TOKEN) - - @property - def twilio_number(self): - return live_settings.TWILIO_NUMBER - - def send_message(self, body, to): - status_callback = create_engine_url(reverse("twilioapp:sms_status_events")) - try: - return self.twilio_api_client.messages.create( - body=body, to=to, from_=self.twilio_number, status_callback=status_callback - ) - except TwilioRestException as e: - # If status callback is not valid and not accessible from public url then trying to send message without it - # https://www.twilio.com/docs/api/errors/21609 - if e.code == 21609: - logger.warning("twilio_client.send_message: Twilio error 21609. Status Callback is not public url") - return self.twilio_api_client.messages.create(body=body, to=to, from_=self.twilio_number) - raise e - - # Use responsibly - def parse_number(self, number): - try: - response = self.twilio_api_client.lookups.phone_numbers(number).fetch() - return True, response.phone_number, get_calling_code(response.country_code) - except TwilioRestException as e: - if e.code == 20404: - print("Handled exception from twilio: " + str(e)) - return False, None, None - if e.code == 20003: - raise e - except KeyError as e: - print("Handled exception from twilio: " + str(e)) - return False, None, None - - def verification_start_via_twilio(self, user, phone_number, via): - # https://www.twilio.com/docs/verify/api/verification?code-sample=code-start-a-verification-with-sms&code-language=Python&code-sdk-version=6.x - verification = None - try: - verification = self.twilio_api_client.verify.services( - live_settings.TWILIO_VERIFY_SERVICE_SID - ).verifications.create(to=phone_number, channel=via) - except TwilioRestException as e: - logger.error(f"Twilio verification start error: {e} for User: {user.pk}") - - self.create_log_record( - user=user, - phone_number=(phone_number or ""), - type=TwilioLogRecordType.VERIFICATION_START, - status=TwilioLogRecordStatus.ERROR, - succeed=False, - error_message=str(e), - ) - else: - verification_status = verification.status - logger.info(f"Verification status: {verification_status}") - - self.create_log_record( - user=user, - phone_number=phone_number, - type=TwilioLogRecordType.VERIFICATION_START, - payload=str(verification._properties), - status=TwilioLogRecordStatus.DETERMINANT[verification_status], - succeed=(verification_status != "denied"), - ) - - return verification - - def verification_check_via_twilio(self, user, phone_number, code): - # https://www.twilio.com/docs/verify/api/verification-check?code-sample=code-check-a-verification-with-a-phone-number&code-language=Python&code-sdk-version=6.x - succeed = False - try: - verification_check = self.twilio_api_client.verify.services( - live_settings.TWILIO_VERIFY_SERVICE_SID - ).verification_checks.create(to=phone_number, code=code) - except TwilioRestException as e: - logger.error(f"Twilio verification check error: {e} for User: {user.pk}") - self.create_log_record( - user=user, - phone_number=(phone_number or ""), - type=TwilioLogRecordType.VERIFICATION_CHECK, - status=TwilioLogRecordStatus.ERROR, - succeed=succeed, - error_message=str(e), - ) - else: - verification_check_status = verification_check.status - logger.info(f"Verification check status: {verification_check_status}") - succeed = verification_check_status == "approved" - - self.create_log_record( - user=user, - phone_number=phone_number, - type=TwilioLogRecordType.VERIFICATION_CHECK, - payload=str(verification_check._properties), - status=TwilioLogRecordStatus.DETERMINANT[verification_check_status], - succeed=succeed, - ) - - return succeed - - def make_test_call(self, to): - message = TEST_CALL_TEXT.format( - channel_name="Test call", - alert_group_name="Test notification", - alerts_count=2, - ) - self.make_call(message=message, to=to) - - def make_call(self, message, to, grafana_cloud=False): - try: - start_message = message.replace('"', "") - - gather_message = ( - ( - f'' - f"{get_gather_message()}" - f"" - ) - if not grafana_cloud - else "" - ) - - twiml_query = urllib.parse.quote( - f"{start_message}{gather_message}", - safe="", - ) - - url = "http://twimlets.com/echo?Twiml=" + twiml_query - status_callback = create_engine_url(reverse("twilioapp:call_status_events")) - - status_callback_events = ["initiated", "ringing", "answered", "completed"] - - return self.twilio_api_client.calls.create( - url=url, - to=to, - from_=self.twilio_number, - method="GET", - status_callback=status_callback, - status_callback_event=status_callback_events, - status_callback_method="POST", - ) - except TwilioRestException as e: - # If status callback is not valid and not accessible from public url then trying to make call without it - # https://www.twilio.com/docs/api/errors/21609 - if e.code == 21609: - logger.warning("twilio_client.make_call: Twilio error 21609. Status Callback is not public url") - return self.twilio_api_client.calls.create( - url=url, - to=to, - from_=self.twilio_number, - method="GET", - ) - - raise e - - def create_log_record(self, **kwargs): - TwilioLogRecord = apps.get_model("twilioapp", "TwilioLogRecord") - TwilioLogRecord.objects.create(**kwargs) - - def normalize_phone_number_via_twilio(self, phone_number): - phone_number = parse_phone_number(phone_number) - - # Verify and parse phone number with Twilio API - normalized_phone_number = None - country_code = None - if phone_number != "" and phone_number != "+": - try: - ok, normalized_phone_number, country_code = self.parse_number(phone_number) - if normalized_phone_number == "": - normalized_phone_number = None - country_code = None - if not ok: - normalized_phone_number = None - country_code = None - except TypeError: - return None, None - - return normalized_phone_number, country_code - - -twilio_client = TwilioClient() diff --git a/engine/apps/twilioapp/utils.py b/engine/apps/twilioapp/utils.py deleted file mode 100644 index 7b14b9bdb5..0000000000 --- a/engine/apps/twilioapp/utils.py +++ /dev/null @@ -1,68 +0,0 @@ -import logging -import re -from string import digits - -from django.apps import apps -from django.urls import reverse -from phonenumbers import COUNTRY_CODE_TO_REGION_CODE -from twilio.twiml.voice_response import Gather, VoiceResponse - -from common.api_helpers.utils import create_engine_url - -logger = logging.getLogger(__name__) - - -def get_calling_code(iso): - for code, isos in COUNTRY_CODE_TO_REGION_CODE.items(): - if iso.upper() in isos: - return code - return None - - -def get_gather_url(): - gather_url = create_engine_url(reverse("twilioapp:gather")) - return gather_url - - -def get_gather_message(): - return "Press 1 to acknowledge, 2 to resolve, 3 to silence to 30 minutes" - - -def process_call_data(call_sid, digit): - """The function processes pressed digit at call time - - Args: - call_sid (str): - digit (str): user pressed digit - - Returns: - response (VoiceResponse) - """ - - response = VoiceResponse() - - if digit in ["1", "2", "3"]: - # Success case - response.say(f"You have pressed digit {digit}") - - PhoneCall = apps.get_model("twilioapp", "PhoneCall") - PhoneCall.objects.get_and_process_digit(call_sid=call_sid, digit=digit) - - else: - # Error wrong digit pressing - gather = Gather(action=get_gather_url(), method="POST", num_digits=1) - - response.say("Wrong digit") - gather.say(get_gather_message()) - - response.append(gather) - - return response - - -def check_phone_number_is_valid(phone_number): - return re.match(r"^\+\d{8,15}$", phone_number) is not None - - -def parse_phone_number(raw_phone_number): - return "+" + "".join(c for c in raw_phone_number if c in digits) diff --git a/engine/apps/twilioapp/views.py b/engine/apps/twilioapp/views.py index 76404bc584..7754bdd0c9 100644 --- a/engine/apps/twilioapp/views.py +++ b/engine/apps/twilioapp/views.py @@ -1,6 +1,5 @@ import logging -from django.apps import apps from django.http import HttpResponse from rest_framework import status from rest_framework.permissions import BasePermission @@ -9,9 +8,11 @@ from twilio.request_validator import RequestValidator from apps.base.utils import live_settings -from apps.twilioapp.utils import process_call_data from common.api_helpers.utils import create_engine_url +from .gather import process_gather_data +from .status_callback import update_twilio_call_status, update_twilio_sms_status + logger = logging.getLogger(__name__) @@ -41,13 +42,9 @@ class GatherView(APIView): permission_classes = [AllowOnlyTwilio] def post(self, request): - digit = request.POST.get("Digits") call_sid = request.POST.get("CallSid") - - logging.info(f"For CallSid: {call_sid} pressed digit: {digit}") - - response = process_call_data(call_sid=call_sid, digit=digit) - + digit = request.POST.get("Digits") + response = process_gather_data(call_sid, digit) return HttpResponse(str(response), content_type="application/xml; charset=utf-8") @@ -58,10 +55,8 @@ class SMSStatusCallback(APIView): def post(self, request): message_sid = request.POST.get("MessageSid") message_status = request.POST.get("MessageStatus") - logging.info(f"SID: {message_sid}, Status: {message_status}") - SMSMessage = apps.get_model("twilioapp", "SMSMessage") - SMSMessage.objects.update_status(message_sid=message_sid, message_status=message_status) + update_twilio_sms_status(message_sid=message_sid, message_status=message_status) return Response(data="", status=status.HTTP_204_NO_CONTENT) @@ -73,9 +68,8 @@ def post(self, request): call_sid = request.POST.get("CallSid") call_status = request.POST.get("CallStatus") - logging.info(f"SID: {call_sid}, Status: {call_status}") + logging.info(f"CallStatusCallback: SID: {call_sid}, Status: {call_status}") - PhoneCall = apps.get_model("twilioapp", "PhoneCall") - PhoneCall.objects.update_status(call_sid=call_sid, call_status=call_status) + update_twilio_call_status(call_sid=call_sid, call_status=call_status) return Response(data="", status=status.HTTP_204_NO_CONTENT) diff --git a/engine/apps/user_management/subscription_strategy/free_public_beta_subscription_strategy.py b/engine/apps/user_management/subscription_strategy/free_public_beta_subscription_strategy.py index db92ce377c..3dd9498f83 100644 --- a/engine/apps/user_management/subscription_strategy/free_public_beta_subscription_strategy.py +++ b/engine/apps/user_management/subscription_strategy/free_public_beta_subscription_strategy.py @@ -55,11 +55,11 @@ def _calculate_phone_notifications_left(self, user): Count sms and calls together and they have common limit. For FreePublicBetaSubscriptionStrategy notifications are counted per day """ - PhoneCall = apps.get_model("twilioapp", "PhoneCall") - SMSMessage = apps.get_model("twilioapp", "SMSMessage") + PhoneCallRecord = apps.get_model("phone_notifications", "PhoneCallRecord") + SMSMessage = apps.get_model("phone_notifications", "SMSRecord") now = timezone.now() day_start = now.replace(hour=0, minute=0, second=0, microsecond=0) - calls_today = PhoneCall.objects.filter( + calls_today = PhoneCallRecord.objects.filter( created_at__gte=day_start, represents_alert_group__channel__organization=self.organization, receiver=user, diff --git a/engine/apps/user_management/tests/test_free_public_beta_subcription_strategy.py b/engine/apps/user_management/tests/test_free_public_beta_subcription_strategy.py index b3b26e4f3b..c25e82dc17 100644 --- a/engine/apps/user_management/tests/test_free_public_beta_subcription_strategy.py +++ b/engine/apps/user_management/tests/test_free_public_beta_subcription_strategy.py @@ -1,14 +1,13 @@ import pytest from apps.api.permissions import LegacyAccessControlRole -from apps.twilioapp.constants import TwilioCallStatuses, TwilioMessageStatuses @pytest.mark.django_db def test_phone_calls_left( make_organization, make_user_for_organization, - make_phone_call, + make_phone_call_record, make_alert_receive_channel, make_alert_group, ): @@ -17,7 +16,7 @@ def test_phone_calls_left( user = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) - make_phone_call(receiver=admin, status=TwilioCallStatuses.COMPLETED, represents_alert_group=alert_group) + make_phone_call_record(receiver=admin, represents_alert_group=alert_group) assert organization.phone_calls_left(admin) == organization.subscription_strategy._phone_notifications_limit - 1 assert organization.phone_calls_left(user) == organization.subscription_strategy._phone_notifications_limit @@ -25,14 +24,14 @@ def test_phone_calls_left( @pytest.mark.django_db def test_sms_left( - make_organization, make_user_for_organization, make_sms, make_alert_receive_channel, make_alert_group + make_organization, make_user_for_organization, make_sms_record, make_alert_receive_channel, make_alert_group ): organization = make_organization() admin = make_user_for_organization(organization) user = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) - make_sms(receiver=admin, status=TwilioMessageStatuses.SENT, represents_alert_group=alert_group) + make_sms_record(receiver=admin, represents_alert_group=alert_group) assert organization.sms_left(admin) == organization.subscription_strategy._phone_notifications_limit - 1 assert organization.sms_left(user) == organization.subscription_strategy._phone_notifications_limit @@ -42,8 +41,8 @@ def test_sms_left( def test_phone_calls_and_sms_counts_together( make_organization, make_user_for_organization, - make_phone_call, - make_sms, + make_phone_call_record, + make_sms_record, make_alert_receive_channel, make_alert_group, ): @@ -52,8 +51,8 @@ def test_phone_calls_and_sms_counts_together( user = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) - make_phone_call(receiver=admin, status=TwilioCallStatuses.COMPLETED, represents_alert_group=alert_group) - make_sms(receiver=admin, status=TwilioMessageStatuses.SENT, represents_alert_group=alert_group) + make_phone_call_record(receiver=admin, represents_alert_group=alert_group) + make_sms_record(receiver=admin, represents_alert_group=alert_group) assert organization.phone_calls_left(admin) == organization.subscription_strategy._phone_notifications_limit - 2 assert organization.sms_left(admin) == organization.subscription_strategy._phone_notifications_limit - 2 diff --git a/engine/apps/user_management/tests/test_organization.py b/engine/apps/user_management/tests/test_organization.py index ecab91df4b..6630f6ca30 100644 --- a/engine/apps/user_management/tests/test_organization.py +++ b/engine/apps/user_management/tests/test_organization.py @@ -8,7 +8,6 @@ from apps.base.models import UserNotificationPolicy, UserNotificationPolicyLogRecord from apps.schedules.models import OnCallScheduleICal, OnCallScheduleWeb from apps.telegram.models import TelegramMessage -from apps.twilioapp.constants import TwilioCallStatuses, TwilioMessageStatuses from apps.user_management.models import Organization @@ -68,8 +67,8 @@ def test_organization_hard_delete( make_alert_group, make_alert_group_log_record, make_user_notification_policy_log_record, - make_sms, - make_phone_call, + make_sms_record, + make_phone_call_record, make_token_for_organization, make_public_api_token, make_invitation, @@ -130,12 +129,10 @@ def test_organization_hard_delete( alert_group=alert_group, ) - sms = make_sms( - receiver=user_1, status=TwilioMessageStatuses.SENT, represents_alert=alert, represents_alert_group=alert_group - ) + sms_record = make_sms_record(receiver=user_1, represents_alert=alert, represents_alert_group=alert_group) - phone_call = make_phone_call( - receiver=user_1, status=TwilioCallStatuses.COMPLETED, represents_alert=alert, represents_alert_group=alert_group + phone_call_record = make_phone_call_record( + receiver=user_1, represents_alert=alert, represents_alert_group=alert_group ) telegram_user_connector = make_telegram_user_connector(user=user_1) @@ -181,8 +178,8 @@ def test_organization_hard_delete( alert, alert_group_log_record, user_notification_policy_log_record, - phone_call, - sms, + phone_call_record, + sms_record, telegram_message, telegram_user_connector, telegram_channel, diff --git a/engine/common/api_helpers/utils.py b/engine/common/api_helpers/utils.py index cc805cccde..d81942bda4 100644 --- a/engine/common/api_helpers/utils.py +++ b/engine/common/api_helpers/utils.py @@ -155,3 +155,7 @@ def get_date_range_from_request(request): raise BadRequest(detail="Invalid days format") return user_tz, starting_date, days + + +def check_phone_number_is_valid(phone_number): + return re.match(r"^\+\d{8,15}$", phone_number) is not None diff --git a/engine/common/utils.py b/engine/common/utils.py index 9477a91a70..4dc313e5b7 100644 --- a/engine/common/utils.py +++ b/engine/common/utils.py @@ -193,14 +193,6 @@ def clean_markup(text): return cleaned -def escape_for_twilio_phone_call(text): - # https://www.twilio.com/docs/api/errors/12100 - text = text.replace("&", "&") - text = text.replace(">", ">") - text = text.replace("<", "<") - return text - - def escape_html(text): return html.escape(text) diff --git a/engine/conftest.py b/engine/conftest.py index af9559c40c..c12fbec5f3 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -6,6 +6,7 @@ from importlib import import_module, reload import pytest +from celery import Task from django.db.models.signals import post_save from django.urls import clear_url_caches from pytest_factoryboy import register @@ -56,6 +57,9 @@ from apps.email.tests.factories import EmailMessageFactory from apps.heartbeat.tests.factories import IntegrationHeartBeatFactory from apps.mobile_app.models import MobileAppAuthToken, MobileAppVerificationToken +from apps.phone_notifications.phone_backend import PhoneBackend +from apps.phone_notifications.tests.factories import PhoneCallRecordFactory, SMSRecordFactory +from apps.phone_notifications.tests.mock_phone_provider import MockPhoneProvider from apps.schedules.tests.factories import ( CustomOnCallShiftFactory, OnCallScheduleCalendarFactory, @@ -78,7 +82,6 @@ TelegramToUserConnectorFactory, TelegramVerificationCodeFactory, ) -from apps.twilioapp.tests.factories import PhoneCallFactory, SMSFactory from apps.user_management.models.user import User, listen_for_user_model_save from apps.user_management.tests.factories import OrganizationFactory, RegionFactory, TeamFactory, UserFactory from apps.webhooks.tests.factories import CustomWebhookFactory, WebhookResponseFactory @@ -114,8 +117,8 @@ register(ResolutionNoteSlackMessageFactory) -register(PhoneCallFactory) -register(SMSFactory) +register(PhoneCallRecordFactory) +register(SMSRecordFactory) register(EmailMessageFactory) register(IntegrationHeartBeatFactory) @@ -150,6 +153,22 @@ def mock_username(*args, **kwargs): monkeypatch.setattr(Bot, "username", mock_username) +@pytest.fixture(autouse=True) +def mock_phone_provider(monkeypatch): + def mock_get_provider(*args, **kwargs): + return MockPhoneProvider() + + monkeypatch.setattr(PhoneBackend, "_get_phone_provider", mock_get_provider) + + +@pytest.fixture(autouse=True) +def mock_apply_async(monkeypatch): + def mock_apply_async(*args, **kwargs): + return uuid.uuid4() + + monkeypatch.setattr(Task, "apply_async", mock_apply_async) + + @pytest.fixture def make_organization(): def _make_organization(**kwargs): @@ -757,19 +776,19 @@ def _make_telegram_message(alert_group, message_type, **kwargs): @pytest.fixture() -def make_phone_call(): - def _make_phone_call(receiver, status, **kwargs): - return PhoneCallFactory(receiver=receiver, status=status, **kwargs) +def make_phone_call_record(): + def _make_phone_call_record(receiver, **kwargs): + return PhoneCallRecordFactory(receiver=receiver, **kwargs) - return _make_phone_call + return _make_phone_call_record @pytest.fixture() -def make_sms(): - def _make_sms(receiver, status, **kwargs): - return SMSFactory(receiver=receiver, status=status, **kwargs) +def make_sms_record(): + def _make_sms_record(receiver, **kwargs): + return SMSRecordFactory(receiver=receiver, **kwargs) - return _make_sms + return _make_sms_record @pytest.fixture() diff --git a/engine/engine/management/commands/verify_phone.py b/engine/engine/management/commands/verify_phone.py deleted file mode 100644 index 80e6a7d7be..0000000000 --- a/engine/engine/management/commands/verify_phone.py +++ /dev/null @@ -1,52 +0,0 @@ -from django.core.management.base import BaseCommand - -from apps.twilioapp.twilio_client import twilio_client -from apps.twilioapp.utils import check_phone_number_is_valid -from apps.user_management.models import User - - -class Command(BaseCommand): - """ - This command is to manually verify user's phone numbers. - """ - - def add_arguments(self, parser): - parser.add_argument("user_id", type=int, help="User id to manually verify phone number") - parser.add_argument("phone_number", type=str, help="Phone number to verify") - - parser.add_argument( - "--override", - action="store_true", - help="Override existing phone number", - ) - - def handle(self, *args, **options): - user_id = options["user_id"] - phone_number = options["phone_number"] - - if not check_phone_number_is_valid(phone_number): - self.stdout.write(self.style.ERROR('Invalid phone number "%s"' % phone_number)) - return - - try: - user = User.objects.get(pk=user_id) - except User.objects.DoesNotExists: - self.stdout.write(self.style.ERROR('Invalid user_id "%s"' % user_id)) - return - - if user.verified_phone_number and not options["override"]: - self.stdout.write(self.style.ERROR('User "%s" already has a phone number' % user_id)) - return - - normalized_phone_number, _ = twilio_client.normalize_phone_number_via_twilio(phone_number) - if normalized_phone_number: - user.save_verified_phone_number(normalized_phone_number) - user.unverified_phone_number = phone_number - user.save(update_fields=["unverified_phone_number"]) - else: - self.stdout.write(self.style.ERROR('Invalid phone number "%s"' % phone_number)) - return - - self.stdout.write( - self.style.SUCCESS('Successfully verified phone number "%s" for user "%s"' % (phone_number, user_id)) - ) diff --git a/engine/settings/base.py b/engine/settings/base.py index 12c7ee6604..aa4e6d67fa 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -227,6 +227,7 @@ class DatabaseTypes: "django_migration_linter", "fcm_django", "django_dbconn_retry", + "apps.phone_notifications", ] REST_FRAMEWORK = { @@ -704,3 +705,11 @@ class BrokerTypes: PYROSCOPE_APPLICATION_NAME = os.getenv("PYROSCOPE_APPLICATION_NAME", "oncall") PYROSCOPE_SERVER_ADDRESS = os.getenv("PYROSCOPE_SERVER_ADDRESS", "http://pyroscope:4040") PYROSCOPE_AUTH_TOKEN = os.getenv("PYROSCOPE_AUTH_TOKEN", "") + +# map of phone provider alias to importpath. +# Used in get_phone_provider function to dynamically load current provider. +PHONE_PROVIDERS = { + "twilio": "apps.twilioapp.phone_provider.TwilioPhoneProvider", + # "simple": "apps.phone_notifications.simple_phone_provider.SimplePhoneProvider", +} +PHONE_PROVIDER = os.environ.get("PHONE_PROVIDER", default="twilio") diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification.tsx index 8393a2c84b..578b093fba 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification.tsx @@ -24,7 +24,8 @@ interface PhoneVerificationProps extends HTMLAttributes { interface PhoneVerificationState { phone: string; code: string; - isCodeSent: boolean; + isCodeSent?: boolean; + isPhoneCallInitiated?: boolean; isPhoneNumberHidden: boolean; isLoading: boolean; showForgetScreen: boolean; @@ -41,7 +42,10 @@ const PhoneVerification = observer((props: PhoneVerificationProps) => { const user = userStore.items[userPk]; const isCurrentUser = userStore.currentUserPk === user.pk; - const [{ showForgetScreen, phone, code, isCodeSent, isPhoneNumberHidden, isLoading }, setState] = useReducer( + const [ + { showForgetScreen, phone, code, isCodeSent, isPhoneCallInitiated, isPhoneNumberHidden, isLoading }, + setState, + ] = useReducer( (state: PhoneVerificationState, newState: Partial) => ({ ...state, ...newState, @@ -51,6 +55,7 @@ const PhoneVerification = observer((props: PhoneVerificationProps) => { phone: user.verified_phone_number || '+', isLoading: false, isCodeSent: false, + isPhoneCallInitiated: false, showForgetScreen: false, isPhoneNumberHidden: user.hide_phone_number, } @@ -70,7 +75,7 @@ const PhoneVerification = observer((props: PhoneVerificationProps) => { ); const onChangePhoneCallback = useCallback((event: React.ChangeEvent) => { - setState({ isCodeSent: false, phone: event.target.value }); + setState({ isCodeSent: false, isPhoneCallInitiated: false, phone: event.target.value }); }, []); const onChangeCodeCallback = useCallback((event: React.ChangeEvent) => { @@ -81,51 +86,81 @@ const PhoneVerification = observer((props: PhoneVerificationProps) => { userStore.makeTestCall(userPk); }, [userPk, userStore.makeTestCall]); + const handleSendTestSmsClick = useCallback(() => { + userStore.sendTestSms(userPk); + }, [userPk, userStore.sendTestSms]); + const handleForgetNumberClick = useCallback(() => { userStore.forgetPhone(userPk).then(async () => { await userStore.loadUser(userPk); - setState({ phone: '', showForgetScreen: false, isCodeSent: false }); + setState({ phone: '', showForgetScreen: false, isCodeSent: false, isPhoneCallInitiated: false }); }); }, [userPk, userStore.forgetPhone, userStore.loadUser]); - const onSubmitCallback = useCallback(async () => { - if (isCodeSent) { - userStore.verifyPhone(userPk, code).then(() => { - userStore.loadUser(userPk); - }); - } else { - window.grecaptcha.ready(function () { - window.grecaptcha - .execute(rootStore.recaptchaSiteKey, { action: 'mobile_verification_code' }) - .then(async function (token) { - await userStore.updateUser({ - pk: userPk, - email: user.email, - unverified_phone_number: phone, + const onSubmitCallback = useCallback( + async (type) => { + let codeVerification = isCodeSent; + if (type === 'verification_call') { + codeVerification = isPhoneCallInitiated; + } + if (codeVerification) { + userStore.verifyPhone(userPk, code).then(() => { + userStore.loadUser(userPk); + }); + } else { + window.grecaptcha.ready(function () { + window.grecaptcha + .execute(rootStore.recaptchaSiteKey, { action: 'mobile_verification_code' }) + .then(async function (token) { + await userStore.updateUser({ + pk: userPk, + email: user.email, + unverified_phone_number: phone, + }); + + switch (type) { + case 'verification_call': + userStore.fetchVerificationCall(userPk, token).then(() => { + setState({ isPhoneCallInitiated: true }); + if (codeInputRef.current) { + codeInputRef.current.focus(); + } + }); + break; + case 'verification_sms': + userStore.fetchVerificationCode(userPk, token).then(() => { + setState({ isCodeSent: true }); + if (codeInputRef.current) { + codeInputRef.current.focus(); + } + }); + break; + } }); + }); + } + }, + [ + code, + isCodeSent, + phone, + user.email, + userPk, + userStore.verifyPhone, + userStore.updateUser, + userStore.fetchVerificationCode, + ] + ); - userStore.fetchVerificationCode(userPk, token).then(() => { - setState({ isCodeSent: true }); + const onVerifyCallback = useCallback(async () => { + userStore.verifyPhone(userPk, code).then(() => { + userStore.loadUser(userPk); + }); + }, [code, userPk, userStore.verifyPhone, userStore.loadUser]); + + const isPhoneProviderConfigured = teamStore.currentTeam?.env_status.phone_provider?.configured; + const providerConfiguration = teamStore.currentTeam?.env_status.phone_provider; - if (codeInputRef.current) { - codeInputRef.current.focus(); - } - }); - }); - }); - } - }, [ - code, - isCodeSent, - phone, - user.email, - userPk, - userStore.verifyPhone, - userStore.updateUser, - userStore.fetchVerificationCode, - ]); - - const isTwilioConfigured = teamStore.currentTeam?.env_status.twilio_configured; const phoneHasMinimumLength = phone?.length > 8; const isPhoneValid = phoneHasMinimumLength && PHONE_REGEX.test(phone); @@ -133,7 +168,9 @@ const PhoneVerification = observer((props: PhoneVerificationProps) => { const action = isCurrentUser ? UserActions.UserSettingsWrite : UserActions.UserSettingsAdmin; const isButtonDisabled = - phone === user.verified_phone_number || (!isCodeSent && !isPhoneValid) || !isTwilioConfigured; + phone === user.verified_phone_number || + (!isCodeSent && !isPhoneValid && !isPhoneCallInitiated) || + !isPhoneProviderConfigured; const isPhoneDisabled = !!user.verified_phone_number; const isCodeFieldDisabled = !isCodeSent || !isUserActionAllowed(action); @@ -158,15 +195,15 @@ const PhoneVerification = observer((props: PhoneVerificationProps) => { )} - {!isTwilioConfigured && store.hasFeature(AppFeature.LiveSettings) && ( + {!isPhoneProviderConfigured && store.hasFeature(AppFeature.LiveSettings) && ( <> - Can't verify phone. Check ENV variables{' '} - related to Twilio. + Can't verify phone. Check ENV variables to + configure your provider. } /> @@ -185,7 +222,7 @@ const PhoneVerification = observer((props: PhoneVerificationProps) => { autoFocus id="phone" required - disabled={!isTwilioConfigured || isPhoneDisabled} + disabled={!isPhoneProviderConfigured || isPhoneDisabled} placeholder="Please enter the phone number with country code, e.g. +12451111111" // @ts-ignore prefix={} @@ -233,11 +270,14 @@ const PhoneVerification = observer((props: PhoneVerificationProps) => { setState({ showForgetScreen: true })} user={user} /> @@ -273,12 +313,20 @@ interface PhoneVerificationButtonsGroupProps { action: UserAction; isCodeSent: boolean; + isPhoneCallInitiated: boolean; isButtonDisabled: boolean; isTestCallInProgress: boolean; - isTwilioConfigured: boolean; - - onSubmitCallback(): void; + providerConfiguration: { + configured: boolean; + test_call: boolean; + test_sms: boolean; + verification_call: boolean; + verification_sms: boolean; + }; + onSubmitCallback(type: string): void; + onVerifyCallback(): void; handleMakeTestCallClick(): void; + handleSendTestSmsClick(): void; onShowForgetScreen(): void; user: User; @@ -287,25 +335,60 @@ interface PhoneVerificationButtonsGroupProps { function PhoneVerificationButtonsGroup({ action, isCodeSent, + isPhoneCallInitiated, isButtonDisabled, isTestCallInProgress, - isTwilioConfigured, + providerConfiguration, onSubmitCallback, + onVerifyCallback, handleMakeTestCallClick, + handleSendTestSmsClick, onShowForgetScreen, user, }: PhoneVerificationButtonsGroupProps) { const showForgetNumber = !!user.verified_phone_number; const showVerifyOrSendCodeButton = !user.verified_phone_number; - + const verificationStarted = isCodeSent || isPhoneCallInitiated; return ( {showVerifyOrSendCodeButton && ( - - - + + {verificationStarted ? ( + <> + + + + + ) : ( + + {' '} + {providerConfiguration.verification_sms && ( + + + + )} + {providerConfiguration.verification_call && ( + + + + )} + + )} + )} {showForgetNumber && ( @@ -321,24 +404,33 @@ function PhoneVerificationButtonsGroup({ )} {user.verified_phone_number && ( - <> - - - - - - - + + {providerConfiguration.test_sms && ( + + + + )} + {providerConfiguration.test_call && ( + + + + + + + + + )} + )} ); diff --git a/grafana-plugin/src/models/team/team.types.ts b/grafana-plugin/src/models/team/team.types.ts index cf511c9673..9fdc1df625 100644 --- a/grafana-plugin/src/models/team/team.types.ts +++ b/grafana-plugin/src/models/team/team.types.ts @@ -66,5 +66,12 @@ export interface Team { env_status: { twilio_configured: boolean; telegram_configured: boolean; + phone_provider: { + configured: boolean; + test_call: boolean; + test_sms: boolean; + verification_call: boolean; + verification_sms: boolean; + }; }; } diff --git a/grafana-plugin/src/models/user/user.ts b/grafana-plugin/src/models/user/user.ts index 25d8f75022..437f392bd1 100644 --- a/grafana-plugin/src/models/user/user.ts +++ b/grafana-plugin/src/models/user/user.ts @@ -245,6 +245,14 @@ export class UserStore extends BaseStore { }).catch(throttlingError); } + @action + async fetchVerificationCall(userPk: User['pk'], recaptchaToken: string) { + await makeRequest(`/users/${userPk}/get_verification_call/`, { + method: 'GET', + headers: { 'X-OnCall-Recaptcha': recaptchaToken }, + }).catch(throttlingError); + } + @action async verifyPhone(userPk: User['pk'], token: string) { return await makeRequest(`/users/${userPk}/verify_number/?token=${token}`, { @@ -376,6 +384,18 @@ export class UserStore extends BaseStore { }); } + async sendTestSms(userPk: User['pk']) { + this.isTestCallInProgress = true; + + return await makeRequest(`/users/${userPk}/send_test_sms/`, { + method: 'POST', + }) + .catch(this.onApiError) + .finally(() => { + this.isTestCallInProgress = false; + }); + } + async getiCalLink(userPk: User['pk']) { return await makeRequest(`/users/${userPk}/export_token/`, { method: 'GET',