Skip to content

Commit 1f786e8

Browse files
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](#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 <[email protected]>
1 parent eefe7be commit 1f786e8

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

61 files changed

+2841
-1470
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
### Changed
11+
12+
- Phone provider refactoring
13+
1014
### Fixed
1115

1216
- Improve plugin authentication by @vadimkerr ([#1995](https://github.com/grafana/oncall/pull/1995))

engine/apps/alerts/constants.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ class ActionSource:
22
(
33
SLACK,
44
WEB,
5-
TWILIO,
5+
PHONE,
66
TELEGRAM,
77
) = range(4)
88

engine/apps/alerts/incident_appearance/templaters/phone_call_templater.py

+2-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from apps.alerts.incident_appearance.templaters.alert_templater import AlertTemplater
2-
from common.utils import clean_markup, escape_for_twilio_phone_call
2+
from common.utils import clean_markup
33

44

55
class AlertPhoneCallTemplater(AlertTemplater):
@@ -14,14 +14,11 @@ def _postformat(self, templated_alert):
1414
return templated_alert
1515

1616
def _postformat_pipeline(self, text):
17-
return self._escape(clean_markup(self._slack_format_for_phone_call(text))) if text is not None else text
17+
return clean_markup(self._slack_format_for_phone_call(text)).replace('"', "") if text is not None else text
1818

1919
def _slack_format_for_phone_call(self, data):
2020
sf = self.slack_formatter
2121
sf.user_mention_format = "{}"
2222
sf.channel_mention_format = "#{}"
2323
sf.hyperlink_mention_format = "{title}"
2424
return sf.format(data)
25-
26-
def _escape(self, data):
27-
return escape_for_twilio_phone_call(data)

engine/apps/alerts/tasks/notify_user.py

+5-15
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from apps.alerts.constants import NEXT_ESCALATION_DELAY
1010
from apps.alerts.signals import user_notification_action_triggered_signal
1111
from apps.base.messaging import get_messaging_backend_from_id
12-
from apps.base.utils import live_settings
12+
from apps.phone_notifications.phone_backend import PhoneBackend
1313
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
1414

1515
from .task_logger import task_logger
@@ -224,8 +224,6 @@ def notify_user_task(
224224
autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else None
225225
)
226226
def perform_notification(log_record_pk):
227-
SMSMessage = apps.get_model("twilioapp", "SMSMessage")
228-
PhoneCall = apps.get_model("twilioapp", "PhoneCall")
229227
UserNotificationPolicy = apps.get_model("base", "UserNotificationPolicy")
230228
TelegramToUserConnector = apps.get_model("telegram", "TelegramToUserConnector")
231229
UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord")
@@ -259,20 +257,12 @@ def perform_notification(log_record_pk):
259257
return
260258

261259
if notification_channel == UserNotificationPolicy.NotificationChannel.SMS:
262-
SMSMessage.send_sms(
263-
user,
264-
alert_group,
265-
notification_policy,
266-
is_cloud_notification=live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED,
267-
)
260+
phone_backend = PhoneBackend()
261+
phone_backend.notify_by_sms(user, alert_group, notification_policy)
268262

269263
elif notification_channel == UserNotificationPolicy.NotificationChannel.PHONE_CALL:
270-
PhoneCall.make_call(
271-
user,
272-
alert_group,
273-
notification_policy,
274-
is_cloud_notification=live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED,
275-
)
264+
phone_backend = PhoneBackend()
265+
phone_backend.notify_by_call(user, alert_group, notification_policy)
276266

277267
elif notification_channel == UserNotificationPolicy.NotificationChannel.TELEGRAM:
278268
TelegramToUserConnector.notify_user(user, alert_group, notification_policy)

engine/apps/alerts/tests/test_alert_group.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def test_render_for_phone_call(
3838
)
3939

4040
expected_verbose_name = (
41-
f"You are invited to check an incident from Grafana OnCall. "
41+
f"to check an incident from Grafana OnCall. "
4242
f"Alert via {alert_receive_channel.verbal_name} - Grafana with title TestAlert triggered 1 times"
4343
)
4444
rendered_text = AlertGroupPhoneCallRenderer(alert_group).render()

engine/apps/api/serializers/organization.py

+7-3
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from dataclasses import asdict
12
from datetime import timedelta
23

34
import humanize
@@ -7,6 +8,7 @@
78
from rest_framework import fields, serializers
89

910
from apps.base.models import LiveSetting
11+
from apps.phone_notifications.phone_provider import get_phone_provider
1012
from apps.slack.models import SlackTeamIdentity
1113
from apps.slack.tasks import resolve_archived_incidents_for_organization, unarchive_incidents_for_organization
1214
from apps.user_management.models import Organization
@@ -112,14 +114,16 @@ def get_limits(self, obj):
112114
return obj.notifications_limit_web_report(user)
113115

114116
def get_env_status(self, obj):
117+
# deprecated in favour of ConfigAPIView.
118+
# All new env statuses should be added there
115119
LiveSetting.populate_settings_if_needed()
116120

117121
telegram_configured = not LiveSetting.objects.filter(name__startswith="TELEGRAM", error__isnull=False).exists()
118-
twilio_configured = not LiveSetting.objects.filter(name__startswith="TWILIO", error__isnull=False).exists()
119-
122+
phone_provider_config = get_phone_provider().flags
120123
return {
121124
"telegram_configured": telegram_configured,
122-
"twilio_configured": twilio_configured,
125+
"twilio_configured": phone_provider_config.configured, # keep for backward compatibility
126+
"phone_provider": asdict(phone_provider_config),
123127
}
124128

125129
def get_stats(self, obj):

engine/apps/api/serializers/user.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@
1111
from apps.base.models import UserNotificationPolicy
1212
from apps.base.utils import live_settings
1313
from apps.oss_installation.utils import cloud_user_identity_status
14-
from apps.twilioapp.utils import check_phone_number_is_valid
1514
from apps.user_management.models import User
1615
from apps.user_management.models.user import default_working_hours
1716
from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField
1817
from common.api_helpers.mixins import EagerLoadingMixin
18+
from common.api_helpers.utils import check_phone_number_is_valid
1919
from common.timezones import TimeZoneField
2020

2121
from .custom_serializers import DynamicFieldsModelSerializer

engine/apps/api/tests/test_user.py

+42-27
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
RBACPermission,
1818
)
1919
from apps.base.models import UserNotificationPolicy
20+
from apps.phone_notifications.exceptions import FailedToFinishVerification
2021
from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb
2122
from apps.user_management.models.user import default_working_hours
2223

@@ -471,7 +472,7 @@ def test_user_get_other_verification_code(
471472

472473
client = APIClient()
473474
url = reverse("api-internal:user-get-verification-code", kwargs={"pk": admin.public_primary_key})
474-
with patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock()):
475+
with patch("apps.phone_notifications.phone_backend.PhoneBackend.send_verification_sms", return_value=Mock()):
475476
response = client.get(url, format="json", **make_user_auth_headers(tester, token))
476477

477478
assert response.status_code == expected_status
@@ -486,7 +487,7 @@ def test_validation_of_verification_code(
486487
client = APIClient()
487488
url = reverse("api-internal:user-verify-number", kwargs={"pk": user.public_primary_key})
488489
with patch(
489-
"apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None)
490+
"apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number", return_value=True
490491
) as verify_phone_number:
491492
url_with_token = f"{url}?token=some_token"
492493
r = client.put(url_with_token, format="json", **make_user_auth_headers(user, token))
@@ -504,6 +505,24 @@ def test_validation_of_verification_code(
504505
assert verify_phone_number.call_count == 1
505506

506507

508+
@pytest.mark.django_db
509+
def test_verification_code_provider_exception(
510+
make_organization_and_user_with_plugin_token,
511+
make_user_auth_headers,
512+
):
513+
organization, user, token = make_organization_and_user_with_plugin_token()
514+
client = APIClient()
515+
url = reverse("api-internal:user-verify-number", kwargs={"pk": user.public_primary_key})
516+
with patch(
517+
"apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number",
518+
side_effect=FailedToFinishVerification,
519+
) as verify_phone_number:
520+
url_with_token = f"{url}?token=some_token"
521+
r = client.put(url_with_token, format="json", **make_user_auth_headers(user, token))
522+
assert r.status_code == 503
523+
assert verify_phone_number.call_count == 1
524+
525+
507526
@pytest.mark.django_db
508527
@pytest.mark.parametrize(
509528
"role,expected_status",
@@ -561,7 +580,7 @@ def test_user_verify_another_phone(
561580
client = APIClient()
562581
url = reverse("api-internal:user-verify-number", kwargs={"pk": other_user.public_primary_key})
563582

564-
with patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None)):
583+
with patch("apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number", return_value=True):
565584
response = client.put(f"{url}?token=12345", format="json", **make_user_auth_headers(tester, token))
566585

567586
assert response.status_code == expected_status
@@ -686,7 +705,7 @@ def test_admin_can_detail_users(
686705
assert response.status_code == status.HTTP_200_OK
687706

688707

689-
@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock())
708+
@patch("apps.phone_notifications.phone_backend.PhoneBackend.send_verification_sms", return_value=Mock())
690709
@pytest.mark.django_db
691710
def test_admin_can_get_own_verification_code(
692711
mock_verification_start,
@@ -702,7 +721,7 @@ def test_admin_can_get_own_verification_code(
702721
assert response.status_code == status.HTTP_200_OK
703722

704723

705-
@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock())
724+
@patch("apps.phone_notifications.phone_backend.PhoneBackend.send_verification_sms", return_value=Mock())
706725
@pytest.mark.django_db
707726
def test_admin_can_get_another_user_verification_code(
708727
mock_verification_start,
@@ -719,7 +738,7 @@ def test_admin_can_get_another_user_verification_code(
719738
assert response.status_code == status.HTTP_200_OK
720739

721740

722-
@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None))
741+
@patch("apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number", return_value=True)
723742
@pytest.mark.django_db
724743
def test_admin_can_verify_own_phone(
725744
mocked_verification_check,
@@ -734,7 +753,7 @@ def test_admin_can_verify_own_phone(
734753
assert response.status_code == status.HTTP_200_OK
735754

736755

737-
@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None))
756+
@patch("apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number", return_value=True)
738757
@pytest.mark.django_db
739758
def test_admin_can_verify_another_user_phone(
740759
mocked_verification_check,
@@ -912,7 +931,7 @@ def test_user_can_detail_users(
912931
assert response.status_code == status.HTTP_403_FORBIDDEN
913932

914933

915-
@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock())
934+
@patch("apps.phone_notifications.phone_backend.PhoneBackend.send_verification_sms", return_value=Mock())
916935
@pytest.mark.django_db
917936
def test_user_can_get_own_verification_code(
918937
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(
926945
assert response.status_code == status.HTTP_200_OK
927946

928947

929-
@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock())
948+
@patch("apps.phone_notifications.phone_backend.PhoneBackend.send_verification_sms", return_value=Mock())
930949
@pytest.mark.django_db
931950
def test_user_cant_get_another_user_verification_code(
932951
mock_verification_start,
@@ -944,7 +963,7 @@ def test_user_cant_get_another_user_verification_code(
944963
assert response.status_code == status.HTTP_403_FORBIDDEN
945964

946965

947-
@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None))
966+
@patch("apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number", return_value=True)
948967
@pytest.mark.django_db
949968
def test_user_can_verify_own_phone(
950969
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(
958977
assert response.status_code == status.HTTP_200_OK
959978

960979

961-
@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None))
980+
@patch("apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number", return_value=True)
962981
@pytest.mark.django_db
963982
def test_user_cant_verify_another_user_phone(
964983
mocked_verification_check,
@@ -1218,7 +1237,7 @@ def test_viewer_cant_detail_users(
12181237
assert response.status_code == status.HTTP_403_FORBIDDEN
12191238

12201239

1221-
@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock())
1240+
@patch("apps.phone_notifications.phone_backend.PhoneBackend.send_verification_sms", return_value=Mock())
12221241
@pytest.mark.django_db
12231242
def test_viewer_cant_get_own_verification_code(
12241243
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(
12321251
assert response.status_code == status.HTTP_403_FORBIDDEN
12331252

12341253

1235-
@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock())
1254+
@patch("apps.phone_notifications.phone_backend.PhoneBackend.send_verification_sms", return_value=Mock())
12361255
@pytest.mark.django_db
12371256
def test_viewer_cant_get_another_user_verification_code(
12381257
mock_verification_start,
@@ -1250,7 +1269,7 @@ def test_viewer_cant_get_another_user_verification_code(
12501269
assert response.status_code == status.HTTP_403_FORBIDDEN
12511270

12521271

1253-
@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None))
1272+
@patch("apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number", return_value=True)
12541273
@pytest.mark.django_db
12551274
def test_viewer_cant_verify_own_phone(
12561275
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(
12641283
assert response.status_code == status.HTTP_403_FORBIDDEN
12651284

12661285

1267-
@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None))
1286+
@patch("apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number", return_value=True)
12681287
@pytest.mark.django_db
12691288
def test_viewer_cant_verify_another_user_phone(
12701289
mocked_verification_check,
@@ -1340,9 +1359,7 @@ def test_forget_own_number(
13401359

13411360
client = APIClient()
13421361
url = reverse("api-internal:user-forget-number", kwargs={"pk": user.public_primary_key})
1343-
with patch(
1344-
"apps.twilioapp.phone_manager.PhoneManager.notify_about_changed_verified_phone_number", return_value=None
1345-
):
1362+
with patch("apps.phone_notifications.phone_backend.PhoneBackend._notify_disconnected_number", return_value=None):
13461363
response = client.put(url, None, format="json", **make_user_auth_headers(user, token))
13471364
assert response.status_code == expected_status
13481365

@@ -1390,9 +1407,7 @@ def test_forget_other_number(
13901407

13911408
client = APIClient()
13921409
url = reverse("api-internal:user-forget-number", kwargs={"pk": admin_primary_key})
1393-
with patch(
1394-
"apps.twilioapp.phone_manager.PhoneManager.notify_about_changed_verified_phone_number", return_value=None
1395-
):
1410+
with patch("apps.phone_notifications.phone_backend.PhoneBackend._notify_disconnected_number", return_value=None):
13961411
response = client.put(url, None, format="json", **make_user_auth_headers(other_user, token))
13971412
assert response.status_code == expected_status
13981413

@@ -1574,8 +1589,8 @@ def test_check_availability_other_user(make_organization_and_user_with_plugin_to
15741589
assert response.status_code == status.HTTP_200_OK
15751590

15761591

1577-
@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock())
1578-
@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None))
1592+
@patch("apps.phone_notifications.phone_backend.PhoneBackend.send_verification_sms", return_value=Mock())
1593+
@patch("apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number", return_value=True)
15791594
@patch(
15801595
"apps.api.throttlers.GetPhoneVerificationCodeThrottlerPerUser.get_throttle_limits",
15811596
return_value=(1, 10 * 60),
@@ -1616,8 +1631,8 @@ def test_phone_number_verification_flow_ratelimit_per_user(
16161631
assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS
16171632

16181633

1619-
@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock())
1620-
@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None))
1634+
@patch("apps.phone_notifications.phone_backend.PhoneBackend.send_verification_sms", return_value=Mock())
1635+
@patch("apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number", return_value=True)
16211636
@patch(
16221637
"apps.api.throttlers.GetPhoneVerificationCodeThrottlerPerOrg.get_throttle_limits",
16231638
return_value=(1, 10 * 60),
@@ -1659,7 +1674,7 @@ def test_phone_number_verification_flow_ratelimit_per_org(
16591674
assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS
16601675

16611676

1662-
@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=True)
1677+
@patch("apps.phone_notifications.phone_backend.PhoneBackend.send_verification_sms", return_value=Mock())
16631678
@pytest.mark.parametrize(
16641679
"recaptcha_testing_pass,expected_status",
16651680
[
@@ -1686,7 +1701,7 @@ def test_phone_number_verification_recaptcha(
16861701
response = client.get(url, format="json", **request_headers)
16871702
assert response.status_code == expected_status
16881703
if expected_status == status.HTTP_200_OK:
1689-
mock_verification_start.assert_called_once_with()
1704+
mock_verification_start.assert_called_once_with(user)
16901705
else:
16911706
mock_verification_start.assert_not_called()
16921707

0 commit comments

Comments
 (0)