From 1f786e8d2aa50f28c75df65407e2f70d84a028da Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Wed, 24 May 2023 14:27:48 +0800 Subject: [PATCH] Phone provider refactoring (#1713) # What this PR does This PR moves phone notification logic into separate object PhoneBackend and introduces PhoneProvider interface to hide actual implementation of external phone services provider. It should allow add new phone providers just by implementing one class (See SimplePhoneProvider for example). # Why [Asterisk PR](https://github.com/grafana/oncall/pull/1282) showed that our phone notification system is not flexible. However this is one of the most frequent community questions - how to add "X" phone provider. Also, this refactoring move us one step closer to unifying all notification backends, since with PhoneBackend all phone notification logic is collected in one place and independent from concrete realisation. # Highligts 1. PhoneBackend object - contains all phone notifications business logic. 2. PhoneProvider - interface to external phone services provider. 3. TwilioPhoneProvider and SimplePhoneProvider - two examples of PhoneProvider implementation. 4. PhoneCallRecord and SMSRecord models. I introduced these models to keep phone notification limits logic decoupled from external providers. Existing TwilioPhoneCall and TwilioSMS objects will be migrated to the new table to not to reset limits counter. To be able to receive status callbacks and gather from Twilio TwilioPhoneCall and TwilioSMS still exists, but they are linked to PhoneCallRecord and SMSRecord via fk, to not to leat twilio logic into core code. --------- Co-authored-by: Yulia Shanyrova --- CHANGELOG.md | 4 + engine/apps/alerts/constants.py | 2 +- .../templaters/phone_call_templater.py | 7 +- engine/apps/alerts/tasks/notify_user.py | 20 +- engine/apps/alerts/tests/test_alert_group.py | 2 +- engine/apps/api/serializers/organization.py | 10 +- engine/apps/api/serializers/user.py | 2 +- engine/apps/api/tests/test_user.py | 69 +-- engine/apps/api/views/user.py | 130 ++++-- engine/apps/base/models/live_setting.py | 5 + engine/apps/base/tests/test_live_settings.py | 10 +- engine/apps/phone_notifications/__init__.py | 0 engine/apps/phone_notifications/exceptions.py | 34 ++ .../migrations/0001_initial.py | 60 +++ .../migrations/__init__.py | 0 .../phone_notifications/models/__init__.py | 2 + .../phone_notifications/models/phone_call.py | 81 ++++ engine/apps/phone_notifications/models/sms.py | 87 ++++ .../apps/phone_notifications/phone_backend.py | 399 ++++++++++++++++++ .../phone_notifications/phone_provider.py | 174 ++++++++ .../simple_phone_provider.py | 43 ++ .../phone_notifications/tests/__init__.py | 0 .../phone_notifications/tests/factories.py | 13 + .../tests/mock_phone_provider.py | 38 ++ .../tests/test_phone_backend_call.py | 227 ++++++++++ .../tests/test_phone_backend_oss_relay.py | 111 +++++ .../test_phone_backend_phone_verification.py | 69 +++ .../tests/test_phone_backend_sms.py | 236 +++++++++++ .../public_api/views/phone_notifications.py | 40 +- engine/apps/twilioapp/admin.py | 17 - engine/apps/twilioapp/constants.py | 108 ----- engine/apps/twilioapp/gather.py | 79 ++++ .../migrations/0003_auto_20230408_0711.py | 22 + .../0004_twiliophonecall_twiliosms.py | 41 ++ engine/apps/twilioapp/models/__init__.py | 4 +- engine/apps/twilioapp/models/phone_call.py | 272 ------------ engine/apps/twilioapp/models/sms_message.py | 240 ----------- .../twilioapp/models/twilio_log_record.py | 24 +- .../twilioapp/models/twilio_phone_call.py | 72 ++++ engine/apps/twilioapp/models/twilio_sms.py | 63 +++ engine/apps/twilioapp/phone_manager.py | 75 ---- engine/apps/twilioapp/phone_provider.py | 256 +++++++++++ engine/apps/twilioapp/status_callback.py | 142 +++++++ engine/apps/twilioapp/tests/factories.py | 13 - .../apps/twilioapp/tests/test_phone_calls.py | 159 ++----- .../apps/twilioapp/tests/test_sms_message.py | 71 +--- .../twilioapp/tests/test_twilio_provider.py | 65 +++ engine/apps/twilioapp/twilio_client.py | 206 --------- engine/apps/twilioapp/utils.py | 68 --- engine/apps/twilioapp/views.py | 22 +- .../free_public_beta_subscription_strategy.py | 6 +- ...t_free_public_beta_subcription_strategy.py | 17 +- .../tests/test_organization.py | 17 +- engine/common/api_helpers/utils.py | 4 + engine/common/utils.py | 8 - engine/conftest.py | 41 +- .../management/commands/verify_phone.py | 52 --- engine/settings/base.py | 9 + .../PhoneVerification/PhoneVerification.tsx | 236 +++++++---- grafana-plugin/src/models/team/team.types.ts | 7 + grafana-plugin/src/models/user/user.ts | 20 + 61 files changed, 2841 insertions(+), 1470 deletions(-) create mode 100644 engine/apps/phone_notifications/__init__.py create mode 100644 engine/apps/phone_notifications/exceptions.py create mode 100644 engine/apps/phone_notifications/migrations/0001_initial.py create mode 100644 engine/apps/phone_notifications/migrations/__init__.py create mode 100644 engine/apps/phone_notifications/models/__init__.py create mode 100644 engine/apps/phone_notifications/models/phone_call.py create mode 100644 engine/apps/phone_notifications/models/sms.py create mode 100644 engine/apps/phone_notifications/phone_backend.py create mode 100644 engine/apps/phone_notifications/phone_provider.py create mode 100644 engine/apps/phone_notifications/simple_phone_provider.py create mode 100644 engine/apps/phone_notifications/tests/__init__.py create mode 100644 engine/apps/phone_notifications/tests/factories.py create mode 100644 engine/apps/phone_notifications/tests/mock_phone_provider.py create mode 100644 engine/apps/phone_notifications/tests/test_phone_backend_call.py create mode 100644 engine/apps/phone_notifications/tests/test_phone_backend_oss_relay.py create mode 100644 engine/apps/phone_notifications/tests/test_phone_backend_phone_verification.py create mode 100644 engine/apps/phone_notifications/tests/test_phone_backend_sms.py delete mode 100644 engine/apps/twilioapp/admin.py delete mode 100644 engine/apps/twilioapp/constants.py create mode 100644 engine/apps/twilioapp/gather.py create mode 100644 engine/apps/twilioapp/migrations/0003_auto_20230408_0711.py create mode 100644 engine/apps/twilioapp/migrations/0004_twiliophonecall_twiliosms.py delete mode 100644 engine/apps/twilioapp/models/phone_call.py delete mode 100644 engine/apps/twilioapp/models/sms_message.py create mode 100644 engine/apps/twilioapp/models/twilio_phone_call.py create mode 100644 engine/apps/twilioapp/models/twilio_sms.py delete mode 100644 engine/apps/twilioapp/phone_manager.py create mode 100644 engine/apps/twilioapp/phone_provider.py create mode 100644 engine/apps/twilioapp/status_callback.py delete mode 100644 engine/apps/twilioapp/tests/factories.py create mode 100644 engine/apps/twilioapp/tests/test_twilio_provider.py delete mode 100644 engine/apps/twilioapp/twilio_client.py delete mode 100644 engine/apps/twilioapp/utils.py delete mode 100644 engine/engine/management/commands/verify_phone.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c6cff975..39bc4452 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 6d5dd0b8..38508e52 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 3d0127ca..eb13d86b 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 @@ class AlertPhoneCallTemplater(AlertTemplater): 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 @@ class AlertPhoneCallTemplater(AlertTemplater): 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 3fbca1af..e41a4bcf 100644 --- a/engine/apps/alerts/tasks/notify_user.py +++ b/engine/apps/alerts/tasks/notify_user.py @@ -9,7 +9,7 @@ from kombu import uuid as celery_uuid 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 272ef0b1..0e4d5f68 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 79c6a90a..6f7c0257 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 django.utils import timezone 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 @@ class CurrentOrganizationSerializer(OrganizationSerializer): 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 98627d4e..77d8cb9e 100644 --- a/engine/apps/api/serializers/user.py +++ b/engine/apps/api/serializers/user.py @@ -11,11 +11,11 @@ from apps.base.messaging import get_messaging_backends 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 1d4aa1b7..f64cf916 100644 --- a/engine/apps/api/tests/test_user.py +++ b/engine/apps/api/tests/test_user.py @@ -17,6 +17,7 @@ from apps.api.permissions import ( 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 0a8a64ed..d1c8092c 100644 --- a/engine/apps/api/views/user.py +++ b/engine/apps/api/views/user.py @@ -42,11 +42,18 @@ from apps.base.utils import live_settings 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 @@ class UserView( 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 @@ class UserView( 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 @@ class UserView( 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 @@ class UserView( @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 - - if phone_number is None: - return Response(status=status.HTTP_400_BAD_REQUEST) - 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.make_test_call(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) + + @action(detail=True, methods=["post"], throttle_classes=[TestCallThrottler]) + def send_test_sms(self, request, pk): + user = self.get_object() + try: + 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( + "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 dc3ef1fe..e6d1e708 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 @@ class LiveSetting(models.Model): 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 498be849..6b6eca6b 100644 --- a/engine/apps/base/tests/test_live_settings.py +++ b/engine/apps/base/tests/test_live_settings.py @@ -4,7 +4,7 @@ import pytest 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 00000000..e69de29b diff --git a/engine/apps/phone_notifications/exceptions.py b/engine/apps/phone_notifications/exceptions.py new file mode 100644 index 00000000..97b9348b --- /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 00000000..64fa4760 --- /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 00000000..e69de29b diff --git a/engine/apps/phone_notifications/models/__init__.py b/engine/apps/phone_notifications/models/__init__.py new file mode 100644 index 00000000..c3d30b71 --- /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 00000000..b4a9182b --- /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 00000000..4bad9eb4 --- /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 00000000..db070e61 --- /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 00000000..68f47721 --- /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 00000000..f6d0df03 --- /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 00000000..e69de29b diff --git a/engine/apps/phone_notifications/tests/factories.py b/engine/apps/phone_notifications/tests/factories.py new file mode 100644 index 00000000..d2904eb1 --- /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 00000000..b964fb6d --- /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 00000000..cab9e406 --- /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 00000000..4bacca06 --- /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 00000000..cfb8996c --- /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 00000000..ec414ad5 --- /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 f9f96f74..d52558f2 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 import serializers, status 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 @@ class MakeCallView(APIView): 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 @@ class SendSMSView(APIView): 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 c769ff5c..00000000 --- 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 5785077e..00000000 --- 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 00000000..fffce9aa --- /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 00000000..8329119b --- /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 00000000..8be33dd1 --- /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 b3d32d81..75450a50 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 b0db9f91..00000000 --- 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 55aea7e8..00000000 --- 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 f4530b5d..bec915c2 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 00000000..4b4423eb --- /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 00000000..8050d50b --- /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 e26c64e8..00000000 --- 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 00000000..09b2480d --- /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 00000000..067884ae --- /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 e1b49940..00000000 --- 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 17ec3556..4fa2aaed 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 + return TwilioPhoneCall.objects.create(sid="SMa12312312a123a123123c6dd2f1aee77", phone_call_record=phone_call_record) @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() - - -@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 86ab2390..bba035b5 100644 --- a/engine/apps/twilioapp/tests/test_sms_message.py +++ b/engine/apps/twilioapp/tests/test_sms_message.py @@ -2,72 +2,44 @@ from unittest import mock 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 + return TwilioSMS.objects.create(sid="SMa12312312a123a123123c6dd2f1aee77", sms_record=sms_record) @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() - - -@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 00000000..20892109 --- /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 75d06403..00000000 --- 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 7b14b9bd..00000000 --- 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 76404bc5..7754bdd0 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 rest_framework.views import APIView 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 @@ class CallStatusCallback(APIView): 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 db92ce37..3dd9498f 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 @@ class FreePublicBetaSubscriptionStrategy(BaseSubscriptionStrategy): 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 b3b26e4f..c25e82dc 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 ecab91df..6630f6ca 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.alerts.models import AlertGroupLogRecord, AlertReceiveChannel, Escalat 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 cc805ccc..d81942bd 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 9477a91a..4dc313e5 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 af9559c4..c12fbec5 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -6,6 +6,7 @@ import uuid 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.base.tests.factories import ( 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 @@ from apps.telegram.tests.factories import ( 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(TelegramMessageFactory) register(ResolutionNoteSlackMessageFactory) -register(PhoneCallFactory) -register(SMSFactory) +register(PhoneCallRecordFactory) +register(SMSRecordFactory) register(EmailMessageFactory) register(IntegrationHeartBeatFactory) @@ -150,6 +153,22 @@ def mock_telegram_bot_username(monkeypatch): 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(): @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 80e6a7d7..00000000 --- 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 12c7ee66..aa4e6d67 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -227,6 +227,7 @@ INSTALLED_APPS = [ "django_migration_linter", "fcm_django", "django_dbconn_retry", + "apps.phone_notifications", ] REST_FRAMEWORK = { @@ -704,3 +705,11 @@ PYROSCOPE_PROFILER_ENABLED = getenv_boolean("PYROSCOPE_PROFILER_ENABLED", defaul 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 8393a2c8..578b093f 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, + }); - userStore.fetchVerificationCode(userPk, token).then(() => { - setState({ isCodeSent: true }); - - if (codeInputRef.current) { - codeInputRef.current.focus(); + 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, - ]); + }); + } + }, + [ + code, + isCodeSent, + phone, + user.email, + userPk, + userStore.verifyPhone, + userStore.updateUser, + userStore.fetchVerificationCode, + ] + ); + + 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; - 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 cf511c96..9fdc1df6 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 25d8f750..437f392b 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',