Phone provider refactoring (#1713)

# What this PR does
This PR moves phone notification logic into separate object PhoneBackend
and introduces PhoneProvider interface to hide actual implementation of
external phone services provider. It should allow add new phone
providers just by implementing one class (See SimplePhoneProvider for
example).
# Why 
[Asterisk PR](https://github.com/grafana/oncall/pull/1282) showed that
our phone notification system is not flexible. However this is one of
the most frequent community questions - how to add "X" phone provider.
Also, this refactoring move us one step closer to unifying all
notification backends, since with PhoneBackend all phone notification
logic is collected in one place and independent from concrete
realisation.
# Highligts
1. PhoneBackend object - contains all phone notifications business
logic.
2. PhoneProvider - interface to  external phone services provider.
3. TwilioPhoneProvider and SimplePhoneProvider - two examples of
PhoneProvider implementation.
4. PhoneCallRecord and SMSRecord models. I introduced these models to
keep phone notification limits logic decoupled from external providers.
Existing TwilioPhoneCall and TwilioSMS objects will be migrated to the
new table to not to reset limits counter. To be able to receive status
callbacks and gather from Twilio TwilioPhoneCall and TwilioSMS still
exists, but they are linked to PhoneCallRecord and SMSRecord via fk, to
not to leat twilio logic into core code.

---------

Co-authored-by: Yulia Shanyrova <yulia.shanyrova@grafana.com>
This commit is contained in:
Innokentii Konstantinov 2023-05-24 14:27:48 +08:00 committed by GitHub
parent eefe7be56a
commit 1f786e8d2a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
61 changed files with 2841 additions and 1470 deletions

View file

@ -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))

View file

@ -2,7 +2,7 @@ class ActionSource:
(
SLACK,
WEB,
TWILIO,
PHONE,
TELEGRAM,
) = range(4)

View file

@ -1,5 +1,5 @@
from apps.alerts.incident_appearance.templaters.alert_templater import AlertTemplater
from common.utils import clean_markup, escape_for_twilio_phone_call
from common.utils import clean_markup
class AlertPhoneCallTemplater(AlertTemplater):
@ -14,7 +14,7 @@ class AlertPhoneCallTemplater(AlertTemplater):
return templated_alert
def _postformat_pipeline(self, text):
return self._escape(clean_markup(self._slack_format_for_phone_call(text))) if text is not None else text
return clean_markup(self._slack_format_for_phone_call(text)).replace('"', "") if text is not None else text
def _slack_format_for_phone_call(self, data):
sf = self.slack_formatter
@ -22,6 +22,3 @@ class AlertPhoneCallTemplater(AlertTemplater):
sf.channel_mention_format = "#{}"
sf.hyperlink_mention_format = "{title}"
return sf.format(data)
def _escape(self, data):
return escape_for_twilio_phone_call(data)

View file

@ -9,7 +9,7 @@ from kombu import uuid as celery_uuid
from apps.alerts.constants import NEXT_ESCALATION_DELAY
from apps.alerts.signals import user_notification_action_triggered_signal
from apps.base.messaging import get_messaging_backend_from_id
from apps.base.utils import live_settings
from apps.phone_notifications.phone_backend import PhoneBackend
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
from .task_logger import task_logger
@ -224,8 +224,6 @@ def notify_user_task(
autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else None
)
def perform_notification(log_record_pk):
SMSMessage = apps.get_model("twilioapp", "SMSMessage")
PhoneCall = apps.get_model("twilioapp", "PhoneCall")
UserNotificationPolicy = apps.get_model("base", "UserNotificationPolicy")
TelegramToUserConnector = apps.get_model("telegram", "TelegramToUserConnector")
UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord")
@ -259,20 +257,12 @@ def perform_notification(log_record_pk):
return
if notification_channel == UserNotificationPolicy.NotificationChannel.SMS:
SMSMessage.send_sms(
user,
alert_group,
notification_policy,
is_cloud_notification=live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED,
)
phone_backend = PhoneBackend()
phone_backend.notify_by_sms(user, alert_group, notification_policy)
elif notification_channel == UserNotificationPolicy.NotificationChannel.PHONE_CALL:
PhoneCall.make_call(
user,
alert_group,
notification_policy,
is_cloud_notification=live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED,
)
phone_backend = PhoneBackend()
phone_backend.notify_by_call(user, alert_group, notification_policy)
elif notification_channel == UserNotificationPolicy.NotificationChannel.TELEGRAM:
TelegramToUserConnector.notify_user(user, alert_group, notification_policy)

View file

@ -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()

View file

@ -1,3 +1,4 @@
from dataclasses import asdict
from datetime import timedelta
import humanize
@ -7,6 +8,7 @@ from django.utils import timezone
from rest_framework import fields, serializers
from apps.base.models import LiveSetting
from apps.phone_notifications.phone_provider import get_phone_provider
from apps.slack.models import SlackTeamIdentity
from apps.slack.tasks import resolve_archived_incidents_for_organization, unarchive_incidents_for_organization
from apps.user_management.models import Organization
@ -112,14 +114,16 @@ class CurrentOrganizationSerializer(OrganizationSerializer):
return obj.notifications_limit_web_report(user)
def get_env_status(self, obj):
# deprecated in favour of ConfigAPIView.
# All new env statuses should be added there
LiveSetting.populate_settings_if_needed()
telegram_configured = not LiveSetting.objects.filter(name__startswith="TELEGRAM", error__isnull=False).exists()
twilio_configured = not LiveSetting.objects.filter(name__startswith="TWILIO", error__isnull=False).exists()
phone_provider_config = get_phone_provider().flags
return {
"telegram_configured": telegram_configured,
"twilio_configured": twilio_configured,
"twilio_configured": phone_provider_config.configured, # keep for backward compatibility
"phone_provider": asdict(phone_provider_config),
}
def get_stats(self, obj):

View file

@ -11,11 +11,11 @@ from apps.base.messaging import get_messaging_backends
from apps.base.models import UserNotificationPolicy
from apps.base.utils import live_settings
from apps.oss_installation.utils import cloud_user_identity_status
from apps.twilioapp.utils import check_phone_number_is_valid
from apps.user_management.models import User
from apps.user_management.models.user import default_working_hours
from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField
from common.api_helpers.mixins import EagerLoadingMixin
from common.api_helpers.utils import check_phone_number_is_valid
from common.timezones import TimeZoneField
from .custom_serializers import DynamicFieldsModelSerializer

View file

@ -17,6 +17,7 @@ from apps.api.permissions import (
RBACPermission,
)
from apps.base.models import UserNotificationPolicy
from apps.phone_notifications.exceptions import FailedToFinishVerification
from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb
from apps.user_management.models.user import default_working_hours
@ -471,7 +472,7 @@ def test_user_get_other_verification_code(
client = APIClient()
url = reverse("api-internal:user-get-verification-code", kwargs={"pk": admin.public_primary_key})
with patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock()):
with patch("apps.phone_notifications.phone_backend.PhoneBackend.send_verification_sms", return_value=Mock()):
response = client.get(url, format="json", **make_user_auth_headers(tester, token))
assert response.status_code == expected_status
@ -486,7 +487,7 @@ def test_validation_of_verification_code(
client = APIClient()
url = reverse("api-internal:user-verify-number", kwargs={"pk": user.public_primary_key})
with patch(
"apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None)
"apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number", return_value=True
) as verify_phone_number:
url_with_token = f"{url}?token=some_token"
r = client.put(url_with_token, format="json", **make_user_auth_headers(user, token))
@ -504,6 +505,24 @@ def test_validation_of_verification_code(
assert verify_phone_number.call_count == 1
@pytest.mark.django_db
def test_verification_code_provider_exception(
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
):
organization, user, token = make_organization_and_user_with_plugin_token()
client = APIClient()
url = reverse("api-internal:user-verify-number", kwargs={"pk": user.public_primary_key})
with patch(
"apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number",
side_effect=FailedToFinishVerification,
) as verify_phone_number:
url_with_token = f"{url}?token=some_token"
r = client.put(url_with_token, format="json", **make_user_auth_headers(user, token))
assert r.status_code == 503
assert verify_phone_number.call_count == 1
@pytest.mark.django_db
@pytest.mark.parametrize(
"role,expected_status",
@ -561,7 +580,7 @@ def test_user_verify_another_phone(
client = APIClient()
url = reverse("api-internal:user-verify-number", kwargs={"pk": other_user.public_primary_key})
with patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None)):
with patch("apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number", return_value=True):
response = client.put(f"{url}?token=12345", format="json", **make_user_auth_headers(tester, token))
assert response.status_code == expected_status
@ -686,7 +705,7 @@ def test_admin_can_detail_users(
assert response.status_code == status.HTTP_200_OK
@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock())
@patch("apps.phone_notifications.phone_backend.PhoneBackend.send_verification_sms", return_value=Mock())
@pytest.mark.django_db
def test_admin_can_get_own_verification_code(
mock_verification_start,
@ -702,7 +721,7 @@ def test_admin_can_get_own_verification_code(
assert response.status_code == status.HTTP_200_OK
@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock())
@patch("apps.phone_notifications.phone_backend.PhoneBackend.send_verification_sms", return_value=Mock())
@pytest.mark.django_db
def test_admin_can_get_another_user_verification_code(
mock_verification_start,
@ -719,7 +738,7 @@ def test_admin_can_get_another_user_verification_code(
assert response.status_code == status.HTTP_200_OK
@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None))
@patch("apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number", return_value=True)
@pytest.mark.django_db
def test_admin_can_verify_own_phone(
mocked_verification_check,
@ -734,7 +753,7 @@ def test_admin_can_verify_own_phone(
assert response.status_code == status.HTTP_200_OK
@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None))
@patch("apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number", return_value=True)
@pytest.mark.django_db
def test_admin_can_verify_another_user_phone(
mocked_verification_check,
@ -912,7 +931,7 @@ def test_user_can_detail_users(
assert response.status_code == status.HTTP_403_FORBIDDEN
@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock())
@patch("apps.phone_notifications.phone_backend.PhoneBackend.send_verification_sms", return_value=Mock())
@pytest.mark.django_db
def test_user_can_get_own_verification_code(
mock_verification_start, make_organization_and_user_with_plugin_token, make_user_auth_headers
@ -926,7 +945,7 @@ def test_user_can_get_own_verification_code(
assert response.status_code == status.HTTP_200_OK
@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock())
@patch("apps.phone_notifications.phone_backend.PhoneBackend.send_verification_sms", return_value=Mock())
@pytest.mark.django_db
def test_user_cant_get_another_user_verification_code(
mock_verification_start,
@ -944,7 +963,7 @@ def test_user_cant_get_another_user_verification_code(
assert response.status_code == status.HTTP_403_FORBIDDEN
@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None))
@patch("apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number", return_value=True)
@pytest.mark.django_db
def test_user_can_verify_own_phone(
mocked_verification_check, make_organization_and_user_with_plugin_token, make_user_auth_headers
@ -958,7 +977,7 @@ def test_user_can_verify_own_phone(
assert response.status_code == status.HTTP_200_OK
@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None))
@patch("apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number", return_value=True)
@pytest.mark.django_db
def test_user_cant_verify_another_user_phone(
mocked_verification_check,
@ -1218,7 +1237,7 @@ def test_viewer_cant_detail_users(
assert response.status_code == status.HTTP_403_FORBIDDEN
@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock())
@patch("apps.phone_notifications.phone_backend.PhoneBackend.send_verification_sms", return_value=Mock())
@pytest.mark.django_db
def test_viewer_cant_get_own_verification_code(
mock_verification_start, make_organization_and_user_with_plugin_token, make_user_auth_headers
@ -1232,7 +1251,7 @@ def test_viewer_cant_get_own_verification_code(
assert response.status_code == status.HTTP_403_FORBIDDEN
@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock())
@patch("apps.phone_notifications.phone_backend.PhoneBackend.send_verification_sms", return_value=Mock())
@pytest.mark.django_db
def test_viewer_cant_get_another_user_verification_code(
mock_verification_start,
@ -1250,7 +1269,7 @@ def test_viewer_cant_get_another_user_verification_code(
assert response.status_code == status.HTTP_403_FORBIDDEN
@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None))
@patch("apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number", return_value=True)
@pytest.mark.django_db
def test_viewer_cant_verify_own_phone(
mocked_verification_check, make_organization_and_user_with_plugin_token, make_user_auth_headers
@ -1264,7 +1283,7 @@ def test_viewer_cant_verify_own_phone(
assert response.status_code == status.HTTP_403_FORBIDDEN
@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None))
@patch("apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number", return_value=True)
@pytest.mark.django_db
def test_viewer_cant_verify_another_user_phone(
mocked_verification_check,
@ -1340,9 +1359,7 @@ def test_forget_own_number(
client = APIClient()
url = reverse("api-internal:user-forget-number", kwargs={"pk": user.public_primary_key})
with patch(
"apps.twilioapp.phone_manager.PhoneManager.notify_about_changed_verified_phone_number", return_value=None
):
with patch("apps.phone_notifications.phone_backend.PhoneBackend._notify_disconnected_number", return_value=None):
response = client.put(url, None, format="json", **make_user_auth_headers(user, token))
assert response.status_code == expected_status
@ -1390,9 +1407,7 @@ def test_forget_other_number(
client = APIClient()
url = reverse("api-internal:user-forget-number", kwargs={"pk": admin_primary_key})
with patch(
"apps.twilioapp.phone_manager.PhoneManager.notify_about_changed_verified_phone_number", return_value=None
):
with patch("apps.phone_notifications.phone_backend.PhoneBackend._notify_disconnected_number", return_value=None):
response = client.put(url, None, format="json", **make_user_auth_headers(other_user, token))
assert response.status_code == expected_status
@ -1574,8 +1589,8 @@ def test_check_availability_other_user(make_organization_and_user_with_plugin_to
assert response.status_code == status.HTTP_200_OK
@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock())
@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None))
@patch("apps.phone_notifications.phone_backend.PhoneBackend.send_verification_sms", return_value=Mock())
@patch("apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number", return_value=True)
@patch(
"apps.api.throttlers.GetPhoneVerificationCodeThrottlerPerUser.get_throttle_limits",
return_value=(1, 10 * 60),
@ -1616,8 +1631,8 @@ def test_phone_number_verification_flow_ratelimit_per_user(
assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS
@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock())
@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None))
@patch("apps.phone_notifications.phone_backend.PhoneBackend.send_verification_sms", return_value=Mock())
@patch("apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number", return_value=True)
@patch(
"apps.api.throttlers.GetPhoneVerificationCodeThrottlerPerOrg.get_throttle_limits",
return_value=(1, 10 * 60),
@ -1659,7 +1674,7 @@ def test_phone_number_verification_flow_ratelimit_per_org(
assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS
@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=True)
@patch("apps.phone_notifications.phone_backend.PhoneBackend.send_verification_sms", return_value=Mock())
@pytest.mark.parametrize(
"recaptcha_testing_pass,expected_status",
[
@ -1686,7 +1701,7 @@ def test_phone_number_verification_recaptcha(
response = client.get(url, format="json", **request_headers)
assert response.status_code == expected_status
if expected_status == status.HTTP_200_OK:
mock_verification_start.assert_called_once_with()
mock_verification_start.assert_called_once_with(user)
else:
mock_verification_start.assert_not_called()

View file

@ -42,11 +42,18 @@ from apps.base.utils import live_settings
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication
from apps.mobile_app.demo_push import send_test_push
from apps.mobile_app.exceptions import DeviceNotSet
from apps.phone_notifications.exceptions import (
FailedToFinishVerification,
FailedToMakeCall,
FailedToStartVerification,
NumberAlreadyVerified,
NumberNotVerified,
ProviderNotSupports,
)
from apps.phone_notifications.phone_backend import PhoneBackend
from apps.schedules.models import OnCallSchedule
from apps.telegram.client import TelegramClient
from apps.telegram.models import TelegramVerificationCode
from apps.twilioapp.phone_manager import PhoneManager
from apps.twilioapp.twilio_client import twilio_client
from apps.user_management.models import Team, User
from common.api_helpers.exceptions import Conflict
from common.api_helpers.mixins import FilterSerializerMixin, PublicPrimaryKeyMixin
@ -153,6 +160,7 @@ class UserView(
"verify_number": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
"forget_number": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
"get_verification_code": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
"get_verification_call": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
"get_backend_verification_code": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
"get_telegram_verification_code": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
"unlink_slack": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
@ -160,6 +168,7 @@ class UserView(
"unlink_backend": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
"make_test_call": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
"send_test_push": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
"send_test_sms": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
"export_token": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
"upcoming_shifts": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
}
@ -175,12 +184,14 @@ class UserView(
"verify_number",
"forget_number",
"get_verification_code",
"get_verification_call",
"get_backend_verification_code",
"get_telegram_verification_code",
"unlink_slack",
"unlink_telegram",
"unlink_backend",
"make_test_call",
"send_test_sms",
"send_test_push",
"export_token",
"upcoming_shifts",
@ -316,9 +327,7 @@ class UserView(
throttle_classes=[GetPhoneVerificationCodeThrottlerPerUser, GetPhoneVerificationCodeThrottlerPerOrg],
)
def get_verification_code(self, request, pk):
logger.info("get_verification_code: validating reCAPTCHA code")
# valid = recaptcha.check_recaptcha_internal_api(request, "mobile_verification_code")
valid = check_recaptcha_internal_api(request, "mobile_verification_code")
if not valid:
logger.warning(f"get_verification_code: invalid reCAPTCHA validation")
@ -326,12 +335,44 @@ class UserView(
logger.info('get_verification_code: pass reCAPTCHA validation"')
user = self.get_object()
phone_manager = PhoneManager(user)
code_sent = phone_manager.send_verification_code()
phone_backend = PhoneBackend()
try:
phone_backend.send_verification_sms(user)
except NumberAlreadyVerified:
return Response("Phone number already verified", status=status.HTTP_400_BAD_REQUEST)
except FailedToStartVerification:
return Response("Something went wrong while sending code", status=status.HTTP_500_INTERNAL_SERVER_ERROR)
except ProviderNotSupports:
return Response(
"Phone provider not supports sms verification", status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
return Response(status=status.HTTP_200_OK)
if not code_sent:
logger.warning(f"Mobile app verification code was not successfully sent")
return Response(status=status.HTTP_400_BAD_REQUEST)
@action(
detail=True,
methods=["get"],
throttle_classes=[GetPhoneVerificationCodeThrottlerPerUser, GetPhoneVerificationCodeThrottlerPerOrg],
)
def get_verification_call(self, request, pk):
logger.info("get_verification_code_via_call: validating reCAPTCHA code")
valid = check_recaptcha_internal_api(request, "mobile_verification_code")
if not valid:
logger.warning(f"get_verification_code_via_call: invalid reCAPTCHA validation")
return Response("failed reCAPTCHA check", status=status.HTTP_400_BAD_REQUEST)
logger.info('get_verification_code_via_call: pass reCAPTCHA validation"')
user = self.get_object()
phone_backend = PhoneBackend()
try:
phone_backend.make_verification_call(user)
except NumberAlreadyVerified:
return Response("Phone number already verified", status=status.HTTP_400_BAD_REQUEST)
except FailedToStartVerification:
return Response("Something went wrong while calling", status=status.HTTP_500_INTERNAL_SERVER_ERROR)
except ProviderNotSupports:
return Response(
"Phone provider not supports call verification", status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
return Response(status=status.HTTP_200_OK)
@action(
@ -345,29 +386,34 @@ class UserView(
if not code:
return Response("Invalid verification code", status=status.HTTP_400_BAD_REQUEST)
prev_state = target_user.insight_logs_serialized
phone_manager = PhoneManager(target_user)
verified, error = phone_manager.verify_phone_number(code)
if not verified:
return Response(error, status=status.HTTP_400_BAD_REQUEST)
new_state = target_user.insight_logs_serialized
write_resource_insight_log(
instance=target_user,
author=self.request.user,
event=EntityEvent.UPDATED,
prev_state=prev_state,
new_state=new_state,
)
return Response(status=status.HTTP_200_OK)
phone_backend = PhoneBackend()
try:
verified = phone_backend.verify_phone_number(target_user, code)
except FailedToFinishVerification:
return Response("Something went wrong while verifying code", status=status.HTTP_503_SERVICE_UNAVAILABLE)
if verified:
new_state = target_user.insight_logs_serialized
write_resource_insight_log(
instance=target_user,
author=self.request.user,
event=EntityEvent.UPDATED,
prev_state=prev_state,
new_state=new_state,
)
return Response(status=status.HTTP_200_OK)
else:
return Response("Verification code is not correct", status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=["put"])
def forget_number(self, request, pk):
target_user = self.get_object()
prev_state = target_user.insight_logs_serialized
phone_manager = PhoneManager(target_user)
forget = phone_manager.forget_phone_number()
if forget:
phone_backend = PhoneBackend()
removed = phone_backend.forget_number(target_user)
if removed:
new_state = target_user.insight_logs_serialized
write_resource_insight_log(
instance=target_user,
@ -381,18 +427,34 @@ class UserView(
@action(detail=True, methods=["post"], throttle_classes=[TestCallThrottler])
def make_test_call(self, request, pk):
user = self.get_object()
phone_number = user.verified_phone_number
if phone_number is None:
return Response(status=status.HTTP_400_BAD_REQUEST)
try:
twilio_client.make_test_call(to=phone_number)
except Exception as e:
logger.error(f"Unable to make a test call due to {e}")
phone_backend = PhoneBackend()
phone_backend.make_test_call(user)
except NumberNotVerified:
return Response("Phone number is not verified", status=status.HTTP_400_BAD_REQUEST)
except FailedToMakeCall:
return Response(
data="Something went wrong while making a test call", status=status.HTTP_500_INTERNAL_SERVER_ERROR
"Something went wrong while making a test call", status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
except ProviderNotSupports:
return Response("Phone provider not supports phone calls", status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return Response(status=status.HTTP_200_OK)
@action(detail=True, methods=["post"], throttle_classes=[TestCallThrottler])
def send_test_sms(self, request, pk):
user = self.get_object()
try:
phone_backend = PhoneBackend()
phone_backend.send_test_sms(user)
except NumberNotVerified:
return Response("Phone number is not verified", status=status.HTTP_400_BAD_REQUEST)
except FailedToMakeCall:
return Response(
"Something went wrong while making a test call", status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
except ProviderNotSupports:
return Response("Phone provider not supports phone calls", status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return Response(status=status.HTTP_200_OK)

View file

@ -59,6 +59,7 @@ class LiveSetting(models.Model):
"GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED",
"GRAFANA_CLOUD_NOTIFICATIONS_ENABLED",
"DANGEROUS_WEBHOOKS_ENABLED",
"PHONE_PROVIDER",
)
DESCRIPTIONS = {
@ -146,6 +147,7 @@ class LiveSetting(models.Model):
"GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED": "Enable heartbeat integration with Grafana Cloud OnCall.",
"GRAFANA_CLOUD_NOTIFICATIONS_ENABLED": "Enable SMS/call notifications via Grafana Cloud OnCall",
"DANGEROUS_WEBHOOKS_ENABLED": "Enable outgoing webhooks to private networks",
"PHONE_PROVIDER": f"Phone provider name. Available options: {','.join(list(settings.PHONE_PROVIDERS.keys()))}",
}
SECRET_SETTING_NAMES = (
@ -217,6 +219,9 @@ class LiveSetting(models.Model):
return getattr(settings, setting_name)
def save(self, *args, **kwargs):
"""
Save validates LiveSettings values and save them in database
"""
if self.name not in self.AVAILABLE_NAMES:
raise ValueError(
f"Setting with name '{self.name}' is not in list of available names {self.AVAILABLE_NAMES}"

View file

@ -4,7 +4,7 @@ import pytest
from apps.base.models import LiveSetting
from apps.base.utils import live_settings
from apps.twilioapp.twilio_client import TwilioClient
from apps.twilioapp.phone_provider import TwilioPhoneProvider
@pytest.mark.django_db
@ -61,12 +61,12 @@ def test_twilio_respects_changed_credentials(settings):
settings.TWILIO_AUTH_TOKEN = "twilio_auth_token"
settings.TWILIO_NUMBER = "twilio_number"
twilio_client = TwilioClient()
twilio_client = TwilioPhoneProvider()
live_settings.TWILIO_ACCOUNT_SID = "new_twilio_account_sid"
live_settings.TWILIO_AUTH_TOKEN = "new_twilio_auth_token"
live_settings.TWILIO_NUMBER = "new_twilio_number"
assert twilio_client.twilio_api_client.username == "new_twilio_account_sid"
assert twilio_client.twilio_api_client.password == "new_twilio_auth_token"
assert twilio_client.twilio_number == "new_twilio_number"
assert twilio_client._twilio_api_client.username == "new_twilio_account_sid"
assert twilio_client._twilio_api_client.password == "new_twilio_auth_token"
assert twilio_client._twilio_number == "new_twilio_number"

View file

@ -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

View file

@ -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)
]

View file

@ -0,0 +1,2 @@
from .phone_call import PhoneCallRecord, ProviderPhoneCall # noqa: F401
from .sms import ProviderSMS, SMSRecord # noqa: F401

View file

@ -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()

View file

@ -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()

View file

@ -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")

View file

@ -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]

View file

@ -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,
)

View file

@ -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

View file

@ -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,
)

View file

@ -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
)

View file

@ -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")

View file

@ -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)

View file

@ -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
)

View file

@ -4,11 +4,17 @@ from rest_framework import serializers, status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from twilio.base.exceptions import TwilioRestException
from apps.auth_token.auth import ApiTokenAuthentication
from apps.phone_notifications.exceptions import (
CallsLimitExceeded,
FailedToMakeCall,
FailedToSendSMS,
NumberNotVerified,
SMSLimitExceeded,
)
from apps.phone_notifications.phone_backend import PhoneBackend
from apps.public_api.throttlers.phone_notification_throttler import PhoneNotificationThrottler
from apps.twilioapp.models import PhoneCall, SMSMessage
logger = logging.getLogger(__name__)
@ -33,21 +39,20 @@ class MakeCallView(APIView):
response_data = {}
organization = self.request.auth.organization
logger.info(f"Making cloud call. Email {serializer.validated_data['email']}")
user = organization.users.filter(
email=serializer.validated_data["email"], _verified_phone_number__isnull=False
).first()
user = organization.users.filter(email=serializer.validated_data["email"]).first()
if user is None:
response_data = {"error": "user-not-found"}
return Response(status=status.HTTP_404_NOT_FOUND, data=response_data)
phone_backend = PhoneBackend()
try:
PhoneCall.make_grafana_cloud_call(user, serializer.validated_data["message"])
except TwilioRestException as e:
logger.info(f"Making cloud call. Twilio exception {str(e)}")
return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE, data=response_data)
except PhoneCall.PhoneCallsLimitExceeded:
logger.info(f"Making cloud call. PhoneCallsLimitExceeded")
phone_backend.relay_oss_call(user, serializer.validated_data["message"])
except FailedToMakeCall:
return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE, data={"error": "failed"})
except CallsLimitExceeded:
return Response(status=status.HTTP_400_BAD_REQUEST, data={"error": "limit-exceeded"})
except NumberNotVerified:
return Response(status=status.HTTP_400_BAD_REQUEST, data={"error": "number-not-verified"})
return Response(status=status.HTTP_200_OK, data=response_data)
@ -74,13 +79,14 @@ class SendSMSView(APIView):
response_data = {"error": "user-not-found"}
return Response(status=status.HTTP_404_NOT_FOUND, data=response_data)
phone_backend = PhoneBackend()
try:
SMSMessage.send_grafana_cloud_sms(user, serializer.validated_data["message"])
except TwilioRestException as e:
logger.info(f"Sending cloud sms. Twilio exception {str(e)}")
return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE, data=response_data)
except SMSMessage.SMSLimitExceeded:
logger.info(f"Sending cloud sms. PhoneCallsLimitExceeded")
phone_backend.relay_oss_sms(user, serializer.validated_data["message"])
except FailedToSendSMS:
return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE, data={"error": "failed"})
except SMSLimitExceeded:
return Response(status=status.HTTP_400_BAD_REQUEST, data={"error": "limit-exceeded"})
except NumberNotVerified:
return Response(status=status.HTTP_400_BAD_REQUEST, data={"error": "number-not-verified"})
return Response(status=status.HTTP_200_OK, data=response_data)

View file

@ -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",)

View file

@ -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"
)

View file

@ -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"

View file

@ -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
)
]

View file

@ -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,
},
),
]

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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()

View file

@ -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)

View file

@ -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}"
)

View file

@ -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"<Response><Say>{message}</Say></Response>"
if with_gather:
gather_subquery = f'<Gather numDigits="1" action="{get_gather_url()}" method="POST"><Say>{get_gather_message()}</Say></Gather>'
q = f"<Response><Say>{message}</Say>{gather_subquery}</Response>"
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("&", "&amp;")
message = message.replace(">", "&gt;")
message = message.replace("<", "&lt;")
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,
)

View file

@ -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"))

View file

@ -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

View file

@ -1,81 +1,46 @@
import urllib
from unittest import mock
import pytest
from bs4 import BeautifulSoup
from django.urls import reverse
from django.utils import timezone
from django.utils.datastructures import MultiValueDict
from django.utils.http import urlencode
from rest_framework.test import APIClient
from apps.base.models import UserNotificationPolicy
from apps.twilioapp.constants import TwilioCallStatuses
from apps.twilioapp.models import PhoneCall
from apps.twilioapp.utils import get_gather_message
class FakeTwilioCall:
def __init__(self):
self.sid = "123"
self.status = TwilioCallStatuses.COMPLETED
from apps.twilioapp.models import TwilioCallStatuses, TwilioPhoneCall
@pytest.fixture
def phone_call_setup(
def make_twilio_phone_call(
make_organization_and_user,
make_alert_receive_channel,
make_user_notification_policy,
make_alert_group,
make_phone_call_record,
make_alert,
make_phone_call,
):
organization, user = make_organization_and_user()
alert_receive_channel = make_alert_receive_channel(organization)
alert_group = make_alert_group(alert_receive_channel)
make_alert(
alert_group,
raw_request_data={
"status": "firing",
"labels": {
"alertname": "TestAlert",
"region": "eu-1",
},
"annotations": {},
"startsAt": "2018-12-25T15:47:47.377363608Z",
"endsAt": "0001-01-01T00:00:00Z",
"generatorURL": "",
},
)
make_alert(alert_group, raw_request_data="{}")
notification_policy = make_user_notification_policy(
user=user,
step=UserNotificationPolicy.Step.NOTIFY,
notify_by=UserNotificationPolicy.NotificationChannel.PHONE_CALL,
)
phone_call = make_phone_call(
phone_call_record = make_phone_call_record(
receiver=user,
status=TwilioCallStatuses.QUEUED,
represents_alert_group=alert_group,
sid="SMa12312312a123a123123c6dd2f1aee77",
notification_policy=notification_policy,
)
return phone_call, alert_group
return TwilioPhoneCall.objects.create(sid="SMa12312312a123a123123c6dd2f1aee77", phone_call_record=phone_call_record)
@pytest.mark.django_db
def test_phone_call_creation(phone_call_setup):
phone_call, _ = phone_call_setup
assert PhoneCall.objects.count() == 1
assert phone_call == PhoneCall.objects.first()
@pytest.mark.django_db
def test_forbidden_requests(phone_call_setup):
def test_forbidden_requests(make_twilio_phone_call):
"""Tests check inaccessibility of twilio urls for unauthorized requests"""
phone_call, _ = phone_call_setup
twilio_phone_call = make_twilio_phone_call
# empty data case
data = {}
@ -91,7 +56,7 @@ def test_forbidden_requests(phone_call_setup):
assert response.data["detail"] == "You do not have permission to perform this action."
# wrong AccountSid data
data = {"CallSid": phone_call.sid, "CallStatus": "completed", "AccountSid": "TopSecretAccountSid"}
data = {"CallSid": twilio_phone_call.sid, "CallStatus": "completed", "AccountSid": "TopSecretAccountSid"}
client = APIClient()
response = client.post(
@ -118,19 +83,16 @@ def test_forbidden_requests(phone_call_setup):
@mock.patch("apps.twilioapp.views.AllowOnlyTwilio.has_permission")
@mock.patch("apps.slack.slack_client.SlackClientWithErrorHandling.api_call")
@pytest.mark.django_db
def test_update_status(mock_has_permission, mock_slack_api_call, phone_call_setup):
def test_update_status(mock_has_permission, make_twilio_phone_call):
"""The test for PhoneCall status update via api"""
phone_call, _ = phone_call_setup
twilio_phone_call = make_twilio_phone_call
mock_has_permission.return_value = True
for status in ["in-progress", "completed", "busy", "failed", "no-answer", "canceled"]:
mock_slack_api_call.return_value = {"ok": True, "ts": timezone.now().timestamp()}
data = {
"CallSid": phone_call.sid,
"CallSid": twilio_phone_call.sid,
"CallStatus": status,
"AccountSid": "Because of mock_has_permission there are may be any value",
}
@ -145,21 +107,21 @@ def test_update_status(mock_has_permission, mock_slack_api_call, phone_call_setu
assert response.status_code == 204
assert response.data == ""
phone_call.refresh_from_db()
assert phone_call.status == TwilioCallStatuses.DETERMINANT[status]
twilio_phone_call.refresh_from_db()
assert twilio_phone_call.status == TwilioCallStatuses.DETERMINANT[status]
@mock.patch("apps.twilioapp.views.AllowOnlyTwilio.has_permission")
@mock.patch("apps.twilioapp.utils.get_gather_url")
@mock.patch("apps.twilioapp.gather.get_gather_url")
@pytest.mark.django_db
def test_acknowledge_by_phone(mock_has_permission, mock_get_gather_url, phone_call_setup):
phone_call, alert_group = phone_call_setup
def test_acknowledge_by_phone(mock_has_permission, mock_get_gather_url, make_twilio_phone_call):
twilio_phone_call = make_twilio_phone_call
alert_group = twilio_phone_call.phone_call_record.represents_alert_group
mock_has_permission.return_value = True
mock_get_gather_url.return_value = reverse("twilioapp:gather")
data = {
"CallSid": phone_call.sid,
"CallSid": twilio_phone_call.sid,
"Digits": "1",
"AccountSid": "Because of mock_has_permission there are may be any value",
}
@ -183,20 +145,21 @@ def test_acknowledge_by_phone(mock_has_permission, mock_get_gather_url, phone_ca
@mock.patch("apps.twilioapp.views.AllowOnlyTwilio.has_permission")
@mock.patch("apps.twilioapp.utils.get_gather_url")
@mock.patch("apps.twilioapp.gather.get_gather_url")
@pytest.mark.django_db
def test_resolve_by_phone(mock_has_permission, mock_get_gather_url, phone_call_setup):
phone_call, alert_group = phone_call_setup
def test_resolve_by_phone(mock_has_permission, mock_get_gather_url, make_twilio_phone_call):
twilio_phone_call = make_twilio_phone_call
mock_has_permission.return_value = True
mock_get_gather_url.return_value = reverse("twilioapp:gather")
data = {
"CallSid": phone_call.sid,
"CallSid": twilio_phone_call.sid,
"Digits": "2",
"AccountSid": "Because of mock_has_permission there are may be any value",
}
alert_group = twilio_phone_call.phone_call_record.represents_alert_group
assert alert_group.resolved is False
client = APIClient()
@ -217,21 +180,22 @@ def test_resolve_by_phone(mock_has_permission, mock_get_gather_url, phone_call_s
@mock.patch("apps.twilioapp.views.AllowOnlyTwilio.has_permission")
@mock.patch("apps.twilioapp.utils.get_gather_url")
@mock.patch("apps.twilioapp.gather.get_gather_url")
@pytest.mark.django_db
def test_silence_by_phone(mock_has_permission, mock_get_gather_url, phone_call_setup):
phone_call, alert_group = phone_call_setup
def test_silence_by_phone(mock_has_permission, mock_get_gather_url, make_twilio_phone_call):
twilio_phone_call = make_twilio_phone_call
mock_has_permission.return_value = True
mock_get_gather_url.return_value = reverse("twilioapp:gather")
data = {
"CallSid": phone_call.sid,
"CallSid": twilio_phone_call.sid,
"Digits": "3",
"AccountSid": "Because of mock_has_permission there are may be any value",
}
assert alert_group.silenced_until is None
alert_group = twilio_phone_call.phone_call_record.represents_alert_group
assert alert_group.resolved is False
client = APIClient()
response = client.post(
@ -250,16 +214,16 @@ def test_silence_by_phone(mock_has_permission, mock_get_gather_url, phone_call_s
@mock.patch("apps.twilioapp.views.AllowOnlyTwilio.has_permission")
@mock.patch("apps.twilioapp.utils.get_gather_url")
@mock.patch("apps.twilioapp.gather.get_gather_url")
@pytest.mark.django_db
def test_wrong_pressed_digit(mock_has_permission, mock_get_gather_url, phone_call_setup):
phone_call, _ = phone_call_setup
def test_wrong_pressed_digit(mock_has_permission, mock_get_gather_url, make_twilio_phone_call):
twilio_phone_call = make_twilio_phone_call
mock_has_permission.return_value = True
mock_get_gather_url.return_value = reverse("twilioapp:gather")
data = {
"CallSid": phone_call.sid,
"CallSid": twilio_phone_call.sid,
"Digits": "0",
"AccountSid": "Because of mock_has_permission there are may be any value",
}
@ -276,58 +240,3 @@ def test_wrong_pressed_digit(mock_has_permission, mock_get_gather_url, phone_cal
assert response.status_code == 200
assert "Wrong digit" in content
@mock.patch("apps.twilioapp.twilio_client.Client")
@pytest.mark.django_db
def test_make_cloud_phone_call_not_gathering_digit(mock_twilio_client, make_organization, make_user):
organization = make_organization()
user = make_user(organization=organization, _verified_phone_number="9999555")
mock_twilio_client.return_value.calls.create.return_value = FakeTwilioCall()
PhoneCall.make_grafana_cloud_call(user, "the message")
gather_message = urllib.parse.quote(get_gather_message())
assert gather_message not in mock_twilio_client.return_value.calls.create.call_args.kwargs["url"]
@mock.patch("apps.twilioapp.twilio_client.Client")
@pytest.mark.django_db
def test_make_phone_call_gathering_digit(
mock_twilio_client,
make_organization,
make_user,
make_user_notification_policy,
make_alert_receive_channel,
make_alert_group,
make_alert,
):
organization = make_organization()
user = make_user(organization=organization, _verified_phone_number="9999555")
alert_receive_channel = make_alert_receive_channel(organization)
alert_group = make_alert_group(alert_receive_channel)
notification_policy = make_user_notification_policy(
user=user,
step=UserNotificationPolicy.Step.NOTIFY,
notify_by=UserNotificationPolicy.NotificationChannel.PHONE_CALL,
)
make_alert(
alert_group,
raw_request_data={
"status": "firing",
"labels": {
"alertname": "TestAlert",
"region": "eu-1",
},
"annotations": {},
"startsAt": "2018-12-25T15:47:47.377363608Z",
"endsAt": "0001-01-01T00:00:00Z",
"generatorURL": "",
},
)
mock_twilio_client.return_value.calls.create.return_value = FakeTwilioCall()
PhoneCall.make_call(user, alert_group, notification_policy)
gather_message = urllib.parse.quote(get_gather_message())
assert gather_message in mock_twilio_client.return_value.calls.create.call_args.kwargs["url"]

View file

@ -2,72 +2,44 @@ from unittest import mock
import pytest
from django.urls import reverse
from django.utils import timezone
from django.utils.datastructures import MultiValueDict
from django.utils.http import urlencode
from rest_framework.test import APIClient
from apps.base.models import UserNotificationPolicy
from apps.twilioapp.constants import TwilioMessageStatuses
from apps.twilioapp.models import SMSMessage
from apps.twilioapp.models import TwilioSMS, TwilioSMSstatuses
@pytest.fixture
def sms_message_setup(
def make_twilio_sms(
make_organization_and_user,
make_alert_receive_channel,
make_user_notification_policy,
make_alert_group,
make_alert,
make_phone_call,
make_sms_record,
):
organization, user = make_organization_and_user()
alert_receive_channel = make_alert_receive_channel(organization)
alert_group = make_alert_group(alert_receive_channel)
make_alert(
alert_group,
raw_request_data={
"status": "firing",
"labels": {
"alertname": "TestAlert",
"region": "eu-1",
},
"annotations": {},
"startsAt": "2018-12-25T15:47:47.377363608Z",
"endsAt": "0001-01-01T00:00:00Z",
"generatorURL": "",
},
)
make_alert(alert_group, raw_request_data="{}")
notification_policy = make_user_notification_policy(
user=user,
step=UserNotificationPolicy.Step.NOTIFY,
notify_by=UserNotificationPolicy.NotificationChannel.SMS,
notify_by=UserNotificationPolicy.NotificationChannel.PHONE_CALL,
)
sms_message = SMSMessage.objects.create(
represents_alert_group=alert_group,
sms_record = make_sms_record(
receiver=user,
sid="SMa12312312a123a123123c6dd2f1aee77",
status=TwilioMessageStatuses.QUEUED,
represents_alert_group=alert_group,
notification_policy=notification_policy,
)
return sms_message, alert_group
return TwilioSMS.objects.create(sid="SMa12312312a123a123123c6dd2f1aee77", sms_record=sms_record)
@pytest.mark.django_db
def test_sms_message_creation(sms_message_setup):
sms_message, _ = sms_message_setup
assert SMSMessage.objects.count() == 1
assert sms_message == SMSMessage.objects.first()
@pytest.mark.django_db
def test_forbidden_requests(sms_message_setup):
def test_forbidden_requests(make_twilio_sms):
"""Tests check inaccessibility of twilio urls for unauthorized requests"""
sms_message, _ = sms_message_setup
twilio_sms = make_twilio_sms
# empty data case
data = {}
@ -83,7 +55,7 @@ def test_forbidden_requests(sms_message_setup):
assert response.data["detail"] == "You do not have permission to perform this action."
# wrong AccountSid data
data = {"MessageSid": sms_message.sid, "MessageStatus": "delivered", "AccountSid": "TopSecretAccountSid"}
data = {"MessageSid": twilio_sms.sid, "MessageStatus": "delivered", "AccountSid": "TopSecretAccountSid"}
response = client.post(
path=reverse("twilioapp:sms_status_events"),
@ -108,35 +80,24 @@ def test_forbidden_requests(sms_message_setup):
@mock.patch("apps.twilioapp.views.AllowOnlyTwilio.has_permission")
@mock.patch("apps.slack.slack_client.SlackClientWithErrorHandling.api_call")
@pytest.mark.django_db
def test_update_status(mock_has_permission, mock_slack_api_call, sms_message_setup):
def test_update_status(mock_has_permission, mock_slack_api_call, make_twilio_sms):
"""The test for SMSMessage status update via api"""
sms_message, _ = sms_message_setup
# https://stackoverflow.com/questions/50157543/unittest-django-mock-external-api-what-is-proper-way
# Define response for the fake SlackClientWithErrorHandling.api_call
twilio_sms = make_twilio_sms
mock_has_permission.return_value = True
for status in ["delivered", "failed", "undelivered"]:
mock_slack_api_call.return_value = {"ok": True, "ts": timezone.now().timestamp()}
data = {
"MessageSid": sms_message.sid,
"MessageSid": twilio_sms.sid,
"MessageStatus": status,
"AccountSid": "Because of mock_has_permission there are may be any value",
}
# https://stackoverflow.com/questions/11571474/djangos-test-client-with-multiple-values-for-data-keys
client = APIClient()
response = client.post(
path=reverse("twilioapp:sms_status_events"),
data=urlencode(MultiValueDict(data), doseq=True),
content_type="application/x-www-form-urlencoded",
)
assert response.status_code == 204
assert response.data == ""
sms_message.refresh_from_db()
assert sms_message.status == TwilioMessageStatuses.DETERMINANT[status]
twilio_sms.refresh_from_db()
assert twilio_sms.status == TwilioSMSstatuses.DETERMINANT[status]

View file

@ -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)

View file

@ -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'<Gather numDigits="1" action="{get_gather_url()}" method="POST">'
f"<Say>{get_gather_message()}</Say>"
f"</Gather>"
)
if not grafana_cloud
else ""
)
twiml_query = urllib.parse.quote(
f"<Response><Say>{start_message}</Say>{gather_message}</Response>",
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()

View file

@ -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)

View file

@ -1,6 +1,5 @@
import logging
from django.apps import apps
from django.http import HttpResponse
from rest_framework import status
from rest_framework.permissions import BasePermission
@ -9,9 +8,11 @@ from rest_framework.views import APIView
from twilio.request_validator import RequestValidator
from apps.base.utils import live_settings
from apps.twilioapp.utils import process_call_data
from common.api_helpers.utils import create_engine_url
from .gather import process_gather_data
from .status_callback import update_twilio_call_status, update_twilio_sms_status
logger = logging.getLogger(__name__)
@ -41,13 +42,9 @@ class GatherView(APIView):
permission_classes = [AllowOnlyTwilio]
def post(self, request):
digit = request.POST.get("Digits")
call_sid = request.POST.get("CallSid")
logging.info(f"For CallSid: {call_sid} pressed digit: {digit}")
response = process_call_data(call_sid=call_sid, digit=digit)
digit = request.POST.get("Digits")
response = process_gather_data(call_sid, digit)
return HttpResponse(str(response), content_type="application/xml; charset=utf-8")
@ -58,10 +55,8 @@ class SMSStatusCallback(APIView):
def post(self, request):
message_sid = request.POST.get("MessageSid")
message_status = request.POST.get("MessageStatus")
logging.info(f"SID: {message_sid}, Status: {message_status}")
SMSMessage = apps.get_model("twilioapp", "SMSMessage")
SMSMessage.objects.update_status(message_sid=message_sid, message_status=message_status)
update_twilio_sms_status(message_sid=message_sid, message_status=message_status)
return Response(data="", status=status.HTTP_204_NO_CONTENT)
@ -73,9 +68,8 @@ class CallStatusCallback(APIView):
call_sid = request.POST.get("CallSid")
call_status = request.POST.get("CallStatus")
logging.info(f"SID: {call_sid}, Status: {call_status}")
logging.info(f"CallStatusCallback: SID: {call_sid}, Status: {call_status}")
PhoneCall = apps.get_model("twilioapp", "PhoneCall")
PhoneCall.objects.update_status(call_sid=call_sid, call_status=call_status)
update_twilio_call_status(call_sid=call_sid, call_status=call_status)
return Response(data="", status=status.HTTP_204_NO_CONTENT)

View file

@ -55,11 +55,11 @@ class FreePublicBetaSubscriptionStrategy(BaseSubscriptionStrategy):
Count sms and calls together and they have common limit.
For FreePublicBetaSubscriptionStrategy notifications are counted per day
"""
PhoneCall = apps.get_model("twilioapp", "PhoneCall")
SMSMessage = apps.get_model("twilioapp", "SMSMessage")
PhoneCallRecord = apps.get_model("phone_notifications", "PhoneCallRecord")
SMSMessage = apps.get_model("phone_notifications", "SMSRecord")
now = timezone.now()
day_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
calls_today = PhoneCall.objects.filter(
calls_today = PhoneCallRecord.objects.filter(
created_at__gte=day_start,
represents_alert_group__channel__organization=self.organization,
receiver=user,

View file

@ -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

View file

@ -8,7 +8,6 @@ from apps.alerts.models import AlertGroupLogRecord, AlertReceiveChannel, Escalat
from apps.base.models import UserNotificationPolicy, UserNotificationPolicyLogRecord
from apps.schedules.models import OnCallScheduleICal, OnCallScheduleWeb
from apps.telegram.models import TelegramMessage
from apps.twilioapp.constants import TwilioCallStatuses, TwilioMessageStatuses
from apps.user_management.models import Organization
@ -68,8 +67,8 @@ def test_organization_hard_delete(
make_alert_group,
make_alert_group_log_record,
make_user_notification_policy_log_record,
make_sms,
make_phone_call,
make_sms_record,
make_phone_call_record,
make_token_for_organization,
make_public_api_token,
make_invitation,
@ -130,12 +129,10 @@ def test_organization_hard_delete(
alert_group=alert_group,
)
sms = make_sms(
receiver=user_1, status=TwilioMessageStatuses.SENT, represents_alert=alert, represents_alert_group=alert_group
)
sms_record = make_sms_record(receiver=user_1, represents_alert=alert, represents_alert_group=alert_group)
phone_call = make_phone_call(
receiver=user_1, status=TwilioCallStatuses.COMPLETED, represents_alert=alert, represents_alert_group=alert_group
phone_call_record = make_phone_call_record(
receiver=user_1, represents_alert=alert, represents_alert_group=alert_group
)
telegram_user_connector = make_telegram_user_connector(user=user_1)
@ -181,8 +178,8 @@ def test_organization_hard_delete(
alert,
alert_group_log_record,
user_notification_policy_log_record,
phone_call,
sms,
phone_call_record,
sms_record,
telegram_message,
telegram_user_connector,
telegram_channel,

View file

@ -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

View file

@ -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("&", "&amp;")
text = text.replace(">", "&gt;")
text = text.replace("<", "&lt;")
return text
def escape_html(text):
return html.escape(text)

View file

@ -6,6 +6,7 @@ import uuid
from importlib import import_module, reload
import pytest
from celery import Task
from django.db.models.signals import post_save
from django.urls import clear_url_caches
from pytest_factoryboy import register
@ -56,6 +57,9 @@ from apps.base.tests.factories import (
from apps.email.tests.factories import EmailMessageFactory
from apps.heartbeat.tests.factories import IntegrationHeartBeatFactory
from apps.mobile_app.models import MobileAppAuthToken, MobileAppVerificationToken
from apps.phone_notifications.phone_backend import PhoneBackend
from apps.phone_notifications.tests.factories import PhoneCallRecordFactory, SMSRecordFactory
from apps.phone_notifications.tests.mock_phone_provider import MockPhoneProvider
from apps.schedules.tests.factories import (
CustomOnCallShiftFactory,
OnCallScheduleCalendarFactory,
@ -78,7 +82,6 @@ from apps.telegram.tests.factories import (
TelegramToUserConnectorFactory,
TelegramVerificationCodeFactory,
)
from apps.twilioapp.tests.factories import PhoneCallFactory, SMSFactory
from apps.user_management.models.user import User, listen_for_user_model_save
from apps.user_management.tests.factories import OrganizationFactory, RegionFactory, TeamFactory, UserFactory
from apps.webhooks.tests.factories import CustomWebhookFactory, WebhookResponseFactory
@ -114,8 +117,8 @@ register(TelegramMessageFactory)
register(ResolutionNoteSlackMessageFactory)
register(PhoneCallFactory)
register(SMSFactory)
register(PhoneCallRecordFactory)
register(SMSRecordFactory)
register(EmailMessageFactory)
register(IntegrationHeartBeatFactory)
@ -150,6 +153,22 @@ def mock_telegram_bot_username(monkeypatch):
monkeypatch.setattr(Bot, "username", mock_username)
@pytest.fixture(autouse=True)
def mock_phone_provider(monkeypatch):
def mock_get_provider(*args, **kwargs):
return MockPhoneProvider()
monkeypatch.setattr(PhoneBackend, "_get_phone_provider", mock_get_provider)
@pytest.fixture(autouse=True)
def mock_apply_async(monkeypatch):
def mock_apply_async(*args, **kwargs):
return uuid.uuid4()
monkeypatch.setattr(Task, "apply_async", mock_apply_async)
@pytest.fixture
def make_organization():
def _make_organization(**kwargs):
@ -757,19 +776,19 @@ def make_telegram_message():
@pytest.fixture()
def make_phone_call():
def _make_phone_call(receiver, status, **kwargs):
return PhoneCallFactory(receiver=receiver, status=status, **kwargs)
def make_phone_call_record():
def _make_phone_call_record(receiver, **kwargs):
return PhoneCallRecordFactory(receiver=receiver, **kwargs)
return _make_phone_call
return _make_phone_call_record
@pytest.fixture()
def make_sms():
def _make_sms(receiver, status, **kwargs):
return SMSFactory(receiver=receiver, status=status, **kwargs)
def make_sms_record():
def _make_sms_record(receiver, **kwargs):
return SMSRecordFactory(receiver=receiver, **kwargs)
return _make_sms
return _make_sms_record
@pytest.fixture()

View file

@ -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))
)

View file

@ -227,6 +227,7 @@ INSTALLED_APPS = [
"django_migration_linter",
"fcm_django",
"django_dbconn_retry",
"apps.phone_notifications",
]
REST_FRAMEWORK = {
@ -704,3 +705,11 @@ PYROSCOPE_PROFILER_ENABLED = getenv_boolean("PYROSCOPE_PROFILER_ENABLED", defaul
PYROSCOPE_APPLICATION_NAME = os.getenv("PYROSCOPE_APPLICATION_NAME", "oncall")
PYROSCOPE_SERVER_ADDRESS = os.getenv("PYROSCOPE_SERVER_ADDRESS", "http://pyroscope:4040")
PYROSCOPE_AUTH_TOKEN = os.getenv("PYROSCOPE_AUTH_TOKEN", "")
# map of phone provider alias to importpath.
# Used in get_phone_provider function to dynamically load current provider.
PHONE_PROVIDERS = {
"twilio": "apps.twilioapp.phone_provider.TwilioPhoneProvider",
# "simple": "apps.phone_notifications.simple_phone_provider.SimplePhoneProvider",
}
PHONE_PROVIDER = os.environ.get("PHONE_PROVIDER", default="twilio")

View file

@ -24,7 +24,8 @@ interface PhoneVerificationProps extends HTMLAttributes<HTMLElement> {
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<PhoneVerificationState>) => ({
...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<HTMLInputElement>) => {
setState({ isCodeSent: false, phone: event.target.value });
setState({ isCodeSent: false, isPhoneCallInitiated: false, phone: event.target.value });
}, []);
const onChangeCodeCallback = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
@ -81,51 +86,81 @@ const PhoneVerification = observer((props: PhoneVerificationProps) => {
userStore.makeTestCall(userPk);
}, [userPk, userStore.makeTestCall]);
const handleSendTestSmsClick = useCallback(() => {
userStore.sendTestSms(userPk);
}, [userPk, userStore.sendTestSms]);
const handleForgetNumberClick = useCallback(() => {
userStore.forgetPhone(userPk).then(async () => {
await userStore.loadUser(userPk);
setState({ phone: '', showForgetScreen: false, isCodeSent: false });
setState({ phone: '', showForgetScreen: false, isCodeSent: false, isPhoneCallInitiated: false });
});
}, [userPk, userStore.forgetPhone, userStore.loadUser]);
const onSubmitCallback = useCallback(async () => {
if (isCodeSent) {
userStore.verifyPhone(userPk, code).then(() => {
userStore.loadUser(userPk);
});
} else {
window.grecaptcha.ready(function () {
window.grecaptcha
.execute(rootStore.recaptchaSiteKey, { action: 'mobile_verification_code' })
.then(async function (token) {
await userStore.updateUser({
pk: userPk,
email: user.email,
unverified_phone_number: phone,
});
const onSubmitCallback = useCallback(
async (type) => {
let codeVerification = isCodeSent;
if (type === 'verification_call') {
codeVerification = isPhoneCallInitiated;
}
if (codeVerification) {
userStore.verifyPhone(userPk, code).then(() => {
userStore.loadUser(userPk);
});
} else {
window.grecaptcha.ready(function () {
window.grecaptcha
.execute(rootStore.recaptchaSiteKey, { action: 'mobile_verification_code' })
.then(async function (token) {
await userStore.updateUser({
pk: userPk,
email: user.email,
unverified_phone_number: phone,
});
userStore.fetchVerificationCode(userPk, token).then(() => {
setState({ isCodeSent: true });
if (codeInputRef.current) {
codeInputRef.current.focus();
switch (type) {
case 'verification_call':
userStore.fetchVerificationCall(userPk, token).then(() => {
setState({ isPhoneCallInitiated: true });
if (codeInputRef.current) {
codeInputRef.current.focus();
}
});
break;
case 'verification_sms':
userStore.fetchVerificationCode(userPk, token).then(() => {
setState({ isCodeSent: true });
if (codeInputRef.current) {
codeInputRef.current.focus();
}
});
break;
}
});
});
});
}
}, [
code,
isCodeSent,
phone,
user.email,
userPk,
userStore.verifyPhone,
userStore.updateUser,
userStore.fetchVerificationCode,
]);
});
}
},
[
code,
isCodeSent,
phone,
user.email,
userPk,
userStore.verifyPhone,
userStore.updateUser,
userStore.fetchVerificationCode,
]
);
const onVerifyCallback = useCallback(async () => {
userStore.verifyPhone(userPk, code).then(() => {
userStore.loadUser(userPk);
});
}, [code, userPk, userStore.verifyPhone, userStore.loadUser]);
const isPhoneProviderConfigured = teamStore.currentTeam?.env_status.phone_provider?.configured;
const providerConfiguration = teamStore.currentTeam?.env_status.phone_provider;
const isTwilioConfigured = teamStore.currentTeam?.env_status.twilio_configured;
const phoneHasMinimumLength = phone?.length > 8;
const isPhoneValid = phoneHasMinimumLength && PHONE_REGEX.test(phone);
@ -133,7 +168,9 @@ const PhoneVerification = observer((props: PhoneVerificationProps) => {
const action = isCurrentUser ? UserActions.UserSettingsWrite : UserActions.UserSettingsAdmin;
const isButtonDisabled =
phone === user.verified_phone_number || (!isCodeSent && !isPhoneValid) || !isTwilioConfigured;
phone === user.verified_phone_number ||
(!isCodeSent && !isPhoneValid && !isPhoneCallInitiated) ||
!isPhoneProviderConfigured;
const isPhoneDisabled = !!user.verified_phone_number;
const isCodeFieldDisabled = !isCodeSent || !isUserActionAllowed(action);
@ -158,15 +195,15 @@ const PhoneVerification = observer((props: PhoneVerificationProps) => {
</>
)}
{!isTwilioConfigured && store.hasFeature(AppFeature.LiveSettings) && (
{!isPhoneProviderConfigured && store.hasFeature(AppFeature.LiveSettings) && (
<>
<Alert
severity="warning"
// @ts-ignore
title={
<>
Can't verify phone. <PluginLink query={{ page: 'live-settings' }}> Check ENV variables</PluginLink>{' '}
related to Twilio.
Can't verify phone. <PluginLink query={{ page: 'live-settings' }}> Check ENV variables</PluginLink> 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={<Icon name="phone" />}
@ -233,11 +270,14 @@ const PhoneVerification = observer((props: PhoneVerificationProps) => {
<PhoneVerificationButtonsGroup
action={action}
isCodeSent={isCodeSent}
isPhoneCallInitiated={isPhoneCallInitiated}
isButtonDisabled={isButtonDisabled}
isTestCallInProgress={userStore.isTestCallInProgress}
isTwilioConfigured={isTwilioConfigured}
providerConfiguration={providerConfiguration}
onSubmitCallback={onSubmitCallback}
onVerifyCallback={onVerifyCallback}
handleMakeTestCallClick={handleMakeTestCallClick}
handleSendTestSmsClick={handleSendTestSmsClick}
onShowForgetScreen={() => 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 (
<HorizontalGroup>
{showVerifyOrSendCodeButton && (
<WithPermissionControlTooltip userAction={action}>
<Button variant="primary" onClick={onSubmitCallback} disabled={isButtonDisabled}>
{isCodeSent ? 'Verify' : 'Send Code'}
</Button>
</WithPermissionControlTooltip>
<HorizontalGroup>
{verificationStarted ? (
<>
<WithPermissionControlTooltip userAction={action}>
<Button variant="primary" onClick={onVerifyCallback}>
Verify
</Button>
</WithPermissionControlTooltip>
</>
) : (
<HorizontalGroup>
{' '}
{providerConfiguration.verification_sms && (
<WithPermissionControlTooltip userAction={action}>
<Button
variant="primary"
onClick={() => onSubmitCallback('verification_sms')}
disabled={isButtonDisabled}
>
Send Code
</Button>
</WithPermissionControlTooltip>
)}
{providerConfiguration.verification_call && (
<WithPermissionControlTooltip userAction={action}>
<Button
variant="primary"
onClick={() => onSubmitCallback('verification_call')}
disabled={isButtonDisabled}
>
Call to get the code
</Button>
</WithPermissionControlTooltip>
)}
</HorizontalGroup>
)}
</HorizontalGroup>
)}
{showForgetNumber && (
@ -321,24 +404,33 @@ function PhoneVerificationButtonsGroup({
)}
{user.verified_phone_number && (
<>
<WithPermissionControlTooltip userAction={action}>
<Button
disabled={!user?.verified_phone_number || !isTwilioConfigured || isTestCallInProgress}
onClick={handleMakeTestCallClick}
>
{isTestCallInProgress ? 'Making Test Call...' : 'Make Test Call'}
</Button>
</WithPermissionControlTooltip>
<Tooltip content={'Click "Make Test Call" to save a phone number and add it to DnD exceptions.'}>
<Icon
name="info-circle"
style={{
marginLeft: '10px',
}}
/>
</Tooltip>
</>
<HorizontalGroup>
{providerConfiguration.test_sms && (
<WithPermissionControlTooltip userAction={action}>
<Button
disabled={!user?.verified_phone_number || !providerConfiguration.configured || isTestCallInProgress}
onClick={handleSendTestSmsClick}
>
Send test sms
</Button>
</WithPermissionControlTooltip>
)}
{providerConfiguration.test_call && (
<HorizontalGroup spacing="xs">
<WithPermissionControlTooltip userAction={action}>
<Button
disabled={!user?.verified_phone_number || !providerConfiguration.configured || isTestCallInProgress}
onClick={handleMakeTestCallClick}
>
{isTestCallInProgress ? 'Making Test Call...' : 'Make Test Call'}
</Button>
</WithPermissionControlTooltip>
<Tooltip content={'Click "Make Test Call" to save a phone number and add it to DnD exceptions.'}>
<Icon name="info-circle" />
</Tooltip>
</HorizontalGroup>
)}
</HorizontalGroup>
)}
</HorizontalGroup>
);

View file

@ -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;
};
};
}

View file

@ -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',