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:
parent
eefe7be56a
commit
1f786e8d2a
61 changed files with 2841 additions and 1470 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ class ActionSource:
|
|||
(
|
||||
SLACK,
|
||||
WEB,
|
||||
TWILIO,
|
||||
PHONE,
|
||||
TELEGRAM,
|
||||
) = range(4)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
0
engine/apps/phone_notifications/__init__.py
Normal file
0
engine/apps/phone_notifications/__init__.py
Normal file
34
engine/apps/phone_notifications/exceptions.py
Normal file
34
engine/apps/phone_notifications/exceptions.py
Normal 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
|
||||
60
engine/apps/phone_notifications/migrations/0001_initial.py
Normal file
60
engine/apps/phone_notifications/migrations/0001_initial.py
Normal 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)
|
||||
]
|
||||
|
||||
0
engine/apps/phone_notifications/migrations/__init__.py
Normal file
0
engine/apps/phone_notifications/migrations/__init__.py
Normal file
2
engine/apps/phone_notifications/models/__init__.py
Normal file
2
engine/apps/phone_notifications/models/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .phone_call import PhoneCallRecord, ProviderPhoneCall # noqa: F401
|
||||
from .sms import ProviderSMS, SMSRecord # noqa: F401
|
||||
81
engine/apps/phone_notifications/models/phone_call.py
Normal file
81
engine/apps/phone_notifications/models/phone_call.py
Normal 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()
|
||||
87
engine/apps/phone_notifications/models/sms.py
Normal file
87
engine/apps/phone_notifications/models/sms.py
Normal 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()
|
||||
399
engine/apps/phone_notifications/phone_backend.py
Normal file
399
engine/apps/phone_notifications/phone_backend.py
Normal 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")
|
||||
174
engine/apps/phone_notifications/phone_provider.py
Normal file
174
engine/apps/phone_notifications/phone_provider.py
Normal 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]
|
||||
43
engine/apps/phone_notifications/simple_phone_provider.py
Normal file
43
engine/apps/phone_notifications/simple_phone_provider.py
Normal 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,
|
||||
)
|
||||
0
engine/apps/phone_notifications/tests/__init__.py
Normal file
0
engine/apps/phone_notifications/tests/__init__.py
Normal file
13
engine/apps/phone_notifications/tests/factories.py
Normal file
13
engine/apps/phone_notifications/tests/factories.py
Normal 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
|
||||
38
engine/apps/phone_notifications/tests/mock_phone_provider.py
Normal file
38
engine/apps/phone_notifications/tests/mock_phone_provider.py
Normal 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,
|
||||
)
|
||||
227
engine/apps/phone_notifications/tests/test_phone_backend_call.py
Normal file
227
engine/apps/phone_notifications/tests/test_phone_backend_call.py
Normal 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
|
||||
)
|
||||
|
|
@ -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")
|
||||
|
|
@ -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)
|
||||
236
engine/apps/phone_notifications/tests/test_phone_backend_sms.py
Normal file
236
engine/apps/phone_notifications/tests/test_phone_backend_sms.py
Normal 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
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",)
|
||||
|
|
@ -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"
|
||||
)
|
||||
79
engine/apps/twilioapp/gather.py
Normal file
79
engine/apps/twilioapp/gather.py
Normal 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"
|
||||
22
engine/apps/twilioapp/migrations/0003_auto_20230408_0711.py
Normal file
22
engine/apps/twilioapp/migrations/0003_auto_20230408_0711.py
Normal 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
|
||||
)
|
||||
]
|
||||
|
|
@ -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,
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
72
engine/apps/twilioapp/models/twilio_phone_call.py
Normal file
72
engine/apps/twilioapp/models/twilio_phone_call.py
Normal 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()
|
||||
63
engine/apps/twilioapp/models/twilio_sms.py
Normal file
63
engine/apps/twilioapp/models/twilio_sms.py
Normal 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)
|
||||
|
|
@ -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}"
|
||||
)
|
||||
256
engine/apps/twilioapp/phone_provider.py
Normal file
256
engine/apps/twilioapp/phone_provider.py
Normal 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("&", "&")
|
||||
message = message.replace(">", ">")
|
||||
message = message.replace("<", "<")
|
||||
return message
|
||||
|
||||
def _parse_phone_number(self, raw_phone_number):
|
||||
return "+" + "".join(c for c in raw_phone_number if c in digits)
|
||||
|
||||
@property
|
||||
def flags(self) -> ProviderFlags:
|
||||
return ProviderFlags(
|
||||
configured=not LiveSetting.objects.filter(name__startswith="TWILIO", error__isnull=False).exists(),
|
||||
test_sms=True,
|
||||
test_call=True,
|
||||
verification_call=True,
|
||||
verification_sms=True,
|
||||
)
|
||||
142
engine/apps/twilioapp/status_callback.py
Normal file
142
engine/apps/twilioapp/status_callback.py
Normal 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"))
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
65
engine/apps/twilioapp/tests/test_twilio_provider.py
Normal file
65
engine/apps/twilioapp/tests/test_twilio_provider.py
Normal 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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -193,14 +193,6 @@ def clean_markup(text):
|
|||
return cleaned
|
||||
|
||||
|
||||
def escape_for_twilio_phone_call(text):
|
||||
# https://www.twilio.com/docs/api/errors/12100
|
||||
text = text.replace("&", "&")
|
||||
text = text.replace(">", ">")
|
||||
text = text.replace("<", "<")
|
||||
return text
|
||||
|
||||
|
||||
def escape_html(text):
|
||||
return html.escape(text)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
)
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue