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