v1.2.28 (#2000)
# What this PR does ## Which issue(s) this PR fixes ## Checklist - [ ] Unit, integration, and e2e (if applicable) tests updated - [ ] Documentation added (or `pr:no public docs` PR label added if not required) - [ ] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required) --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Rares Mardare <rares.mardare@grafana.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Joey Orlando <joey.orlando@grafana.com> Co-authored-by: Vadim Stepanov <vadimkerr@gmail.com> Co-authored-by: Yulia Shanyrova <yulia.shanyrova@grafana.com> Co-authored-by: Ildar Iskhakov <Ildar.iskhakov@grafana.com>
This commit is contained in:
parent
5317f6f361
commit
cf3d1baf41
88 changed files with 3155 additions and 1630 deletions
80
.github/workflows/linting-and-tests.yml
vendored
80
.github/workflows/linting-and-tests.yml
vendored
|
|
@ -262,15 +262,24 @@ jobs:
|
|||
pytest -x
|
||||
|
||||
end-to-end-tests:
|
||||
# TODO: reenable this job once https://github.com/grafana/oncall/issues/1692 is fixed
|
||||
if: ${{ false }}
|
||||
runs-on: ubuntu-latest
|
||||
# default "ubuntu-latest" runners only provide 2 CPU cores + 7GB of RAM. this seems to lead to HTTP 504s from
|
||||
# the oncall backend, and hence, flaky tests. Let's use CI runners w/ more resources to avoid this (plus
|
||||
# this will allow us to run more backend containers and parralelize the tests)
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
name: "End to end tests - Grafana: ${{ matrix.grafana-image-tag }}"
|
||||
strategy:
|
||||
matrix:
|
||||
grafana-image-tag:
|
||||
- 8.5.22
|
||||
- 9.2.6
|
||||
# OnCall doesn't work on the following versions of Grafana
|
||||
# - 8.5.22
|
||||
# - 9.0.0
|
||||
# - 9.1.0
|
||||
|
||||
# 9.2.0 is the earliest version where things work
|
||||
- 9.2.13
|
||||
- 9.3.14
|
||||
- 9.4.10
|
||||
- 9.5.2
|
||||
- main
|
||||
- latest
|
||||
fail-fast: false
|
||||
|
|
@ -278,6 +287,13 @@ jobs:
|
|||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Collect Workflow Telemetry
|
||||
uses: runforesight/workflow-telemetry-action@v1
|
||||
with:
|
||||
comment_on_pr: false
|
||||
proc_trace_chart_show: false
|
||||
proc_trace_table_show: false
|
||||
|
||||
- name: Create k8s Kind Cluster
|
||||
uses: helm/kind-action@v1.3.0
|
||||
with:
|
||||
|
|
@ -331,10 +347,12 @@ jobs:
|
|||
- name: Load engine Docker image on the nodes of the cluster
|
||||
run: kind load image-archive --name=chart-testing /tmp/oncall-engine.tar
|
||||
|
||||
# spin up 2 engine, 2 celery, and 2 grafana pods, this will allow us to parralelize the integration tests
|
||||
# spin up 3 engine and 3 celery pods, this will allow us to parralelize the integration tests,
|
||||
# and complete them much faster by using multiple test processes
|
||||
# With just 1 engine/celery/grafana pod, the backend crawls to a halt when there is > 1 parallelized integration
|
||||
# test process
|
||||
# With just 1 engine/celery/grafana pod, the backend crawls to a halt when there is > 1 parallelized integration test process
|
||||
# NOTE: it appears that using > 1 grafana container w/ SQLite as the database sometimes leads to failed
|
||||
# grafana database migrations (this is documented in this GitHub issue
|
||||
# https://github.com/bitnami/charts/issues/10905)
|
||||
#
|
||||
# by settings grafana.plugins to [] and configuring grafana.extraVolumeMounts we are using the locally built
|
||||
# OnCall plugin rather than the latest published version
|
||||
|
|
@ -346,8 +364,8 @@ jobs:
|
|||
--values ./helm/simple.yml \
|
||||
--values ./helm/values-local-image.yml \
|
||||
--set-json 'env=[{"name":"GRAFANA_CLOUD_NOTIFICATIONS_ENABLED","value":"False"}]' \
|
||||
--set engine.replicaCount=1 \
|
||||
--set celery.replicaCount=1 \
|
||||
--set engine.replicaCount=3 \
|
||||
--set celery.replicaCount=3 \
|
||||
--set celery.worker_beat_enabled="False" \
|
||||
--set oncall.twilio.accountSid="${{ secrets.TWILIO_ACCOUNT_SID }}" \
|
||||
--set oncall.twilio.authToken="${{ secrets.TWILIO_AUTH_TOKEN }}" \
|
||||
|
|
@ -378,20 +396,29 @@ jobs:
|
|||
path: "~/.cache/ms-playwright"
|
||||
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}-chromium-firefox-webkit
|
||||
|
||||
- name: Install Playwright binaries/dependencies
|
||||
# For the next two steps, use the binary directly from node_modules/.bin as opposed to npx playwright
|
||||
# due to this bug (https://github.com/microsoft/playwright/issues/13188)
|
||||
- name: Install Playwright Browsers
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
# https://stackoverflow.com/questions/65900299/install-single-dependency-from-package-json-with-yarn
|
||||
run: |
|
||||
yarn add "@playwright/test@${{ env.PLAYWRIGHT_VERSION }}"
|
||||
npx playwright install --with-deps chromium firefox webkit
|
||||
working-directory: grafana-plugin
|
||||
run: ./node_modules/.bin/playwright install --with-deps chromium firefox webkit
|
||||
|
||||
- name: Await k8s pods and other resources up
|
||||
uses: jupyterhub/action-k8s-await-workloads@v1
|
||||
with:
|
||||
workloads: "" # all
|
||||
namespace: "" # default
|
||||
timeout: 300
|
||||
max-restarts: -1
|
||||
# use the cached browsers, but we still need to install the necessary system dependencies
|
||||
# (system deps are installed in the cache-miss step above by the --with-deps flag)
|
||||
- name: Install Playwright System Dependencies
|
||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||
working-directory: grafana-plugin
|
||||
run: ./node_modules/.bin/playwright install-deps chromium firefox webkit
|
||||
|
||||
# we could instead use the --wait flag for the helm install command above
|
||||
# but there's no reason to block on that step
|
||||
# instead we can let the k8s resources start up behind the scenes and do other
|
||||
# setup tasks (ex. install playwright + its dependencies)
|
||||
- name: Wait until k8s resources are ready
|
||||
run: |
|
||||
kubectl rollout status deployment/helm-testing-grafana --timeout=300s
|
||||
kubectl rollout status deployment/helm-testing-oncall-engine --timeout=300s
|
||||
kubectl rollout status deployment/helm-testing-oncall-celery --timeout=300s
|
||||
|
||||
- name: Run Integration Tests
|
||||
env:
|
||||
|
|
@ -408,17 +435,16 @@ jobs:
|
|||
GRAFANA_PASSWORD: oncall
|
||||
MAILSLURP_API_KEY: ${{ secrets.MAILSLURP_API_KEY }}
|
||||
working-directory: ./grafana-plugin
|
||||
# -x = exit command after first failing test
|
||||
run: yarn test:integration -x
|
||||
run: yarn test:integration
|
||||
|
||||
# always spit out the engine and celery logs, AFTER the e2e tests have completed
|
||||
# can be helpful for debugging failing/flaky tests
|
||||
# spit out the engine, celery, and grafana logs, if the the e2e tests have failed
|
||||
# can be helpful for debugging failing tests
|
||||
# GitHub Action reference: https://github.com/jupyterhub/action-k8s-namespace-report
|
||||
- name: Kubernetes namespace report
|
||||
uses: jupyterhub/action-k8s-namespace-report@v1
|
||||
if: failure()
|
||||
with:
|
||||
important-workloads: "deploy/helm-testing-oncall-engine deploy/helm-testing-oncall-celery"
|
||||
important-workloads: "deploy/helm-testing-oncall-engine deploy/helm-testing-oncall-celery deploy/helm-testing-grafana"
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: failure()
|
||||
|
|
|
|||
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## v1.2.28 (2023-05-24)
|
||||
|
||||
### Changed
|
||||
|
||||
- Phone provider refactoring [#1713](https://github.com/grafana/oncall/pull/1713)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improve plugin authentication by @vadimkerr ([#1995](https://github.com/grafana/oncall/pull/1995))
|
||||
- Fix MultipleObjectsReturned error on webhook endpoints by @vadimkerr ([#1996](https://github.com/grafana/oncall/pull/1996))
|
||||
|
||||
## v1.2.27 (2023-05-23)
|
||||
|
||||
### Added
|
||||
|
|
@ -18,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
### Fixed
|
||||
|
||||
- Fix SQLite permission issue by @vadimkerr ([#1984](https://github.com/grafana/oncall/pull/1984))
|
||||
- Remove user defined time period from "you're going oncall" mobile push ([2001](https://github.com/grafana/oncall/pull/2001))
|
||||
|
||||
## v1.2.26 (2023-05-18)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -64,7 +64,11 @@ class CustomButtonView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet):
|
|||
pk = self.kwargs["pk"]
|
||||
organization = self.request.auth.organization
|
||||
try:
|
||||
obj = organization.custom_buttons.filter(*self.available_teams_lookup_args).get(public_primary_key=pk)
|
||||
obj = (
|
||||
organization.custom_buttons.filter(*self.available_teams_lookup_args)
|
||||
.distinct()
|
||||
.get(public_primary_key=pk)
|
||||
)
|
||||
except ObjectDoesNotExist:
|
||||
raise NotFound
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet):
|
|||
pk = self.kwargs["pk"]
|
||||
organization = self.request.auth.organization
|
||||
try:
|
||||
obj = organization.webhooks.filter(*self.available_teams_lookup_args).get(public_primary_key=pk)
|
||||
obj = organization.webhooks.filter(*self.available_teams_lookup_args).distinct().get(public_primary_key=pk)
|
||||
except ObjectDoesNotExist:
|
||||
raise NotFound
|
||||
|
||||
|
|
|
|||
|
|
@ -72,7 +72,14 @@ class PluginAuthentication(BaseAuthentication):
|
|||
if not context_string:
|
||||
raise exceptions.AuthenticationFailed("No instance context provided.")
|
||||
|
||||
context = json.loads(context_string)
|
||||
try:
|
||||
context = dict(json.loads(context_string))
|
||||
except (ValueError, TypeError):
|
||||
raise exceptions.AuthenticationFailed("Instance context must be JSON dict.")
|
||||
|
||||
if "stack_id" not in context or "org_id" not in context:
|
||||
raise exceptions.AuthenticationFailed("Invalid instance context.")
|
||||
|
||||
try:
|
||||
auth_token = check_token(token_string, context=context)
|
||||
if not auth_token.organization:
|
||||
|
|
@ -85,11 +92,19 @@ class PluginAuthentication(BaseAuthentication):
|
|||
|
||||
@staticmethod
|
||||
def _get_user(request: Request, organization: Organization) -> User:
|
||||
context = json.loads(request.headers.get("X-Grafana-Context"))
|
||||
try:
|
||||
context = dict(json.loads(request.headers.get("X-Grafana-Context")))
|
||||
except (ValueError, TypeError):
|
||||
raise exceptions.AuthenticationFailed("Grafana context must be JSON dict.")
|
||||
|
||||
if "UserId" not in context and "UserID" not in context:
|
||||
raise exceptions.AuthenticationFailed("Invalid Grafana context.")
|
||||
|
||||
try:
|
||||
user_id = context["UserId"]
|
||||
except KeyError:
|
||||
user_id = context["UserID"]
|
||||
|
||||
try:
|
||||
return organization.users.get(user_id=user_id)
|
||||
except User.DoesNotExist:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import binascii
|
||||
from hmac import compare_digest
|
||||
from typing import Optional, Tuple
|
||||
from typing import Tuple
|
||||
|
||||
from django.db import models
|
||||
|
||||
|
|
@ -38,7 +38,7 @@ class PluginAuthToken(BaseAuthToken):
|
|||
return auth_token, token_string
|
||||
|
||||
@classmethod
|
||||
def validate_token_string(cls, token: str, *args, **kwargs) -> Optional["PluginAuthToken"]:
|
||||
def validate_token_string(cls, token: str, *args, **kwargs) -> "PluginAuthToken":
|
||||
context = kwargs["context"]
|
||||
for auth_token in cls.objects.filter(token_key=token[: constants.TOKEN_KEY_LENGTH]):
|
||||
try:
|
||||
|
|
@ -51,3 +51,5 @@ class PluginAuthToken(BaseAuthToken):
|
|||
raise InvalidToken
|
||||
if compare_digest(digest, auth_token.digest) and token == recreated_token:
|
||||
return auth_token
|
||||
|
||||
raise InvalidToken
|
||||
|
|
|
|||
77
engine/apps/auth_token/tests/test_plugin_auth.py
Normal file
77
engine/apps/auth_token/tests/test_plugin_auth.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import pytest
|
||||
from django.utils import timezone
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
from rest_framework.test import APIRequestFactory
|
||||
|
||||
from apps.auth_token.auth import PluginAuthentication
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_plugin_authentication_self_hosted_success(make_organization, make_user, make_token_for_organization):
|
||||
organization = make_organization(stack_id=42, org_id=24)
|
||||
user = make_user(organization=organization, user_id=12)
|
||||
token, token_string = make_token_for_organization(organization)
|
||||
|
||||
headers = {
|
||||
"HTTP_AUTHORIZATION": token_string,
|
||||
"HTTP_X-Instance-Context": '{"stack_id": 42, "org_id": 24}',
|
||||
"HTTP_X-Grafana-Context": '{"UserId": 12}',
|
||||
}
|
||||
request = APIRequestFactory().get("/", **headers)
|
||||
|
||||
assert PluginAuthentication().authenticate(request) == (user, token)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_plugin_authentication_gcom_success(make_organization, make_user, make_token_for_organization):
|
||||
# Setting gcom_token_org_last_time_synced to now, so it doesn't try to sync with gcom
|
||||
organization = make_organization(
|
||||
stack_id=42, org_id=24, gcom_token="123", gcom_token_org_last_time_synced=timezone.now()
|
||||
)
|
||||
user = make_user(organization=organization, user_id=12)
|
||||
|
||||
headers = {
|
||||
"HTTP_AUTHORIZATION": "gcom:123",
|
||||
"HTTP_X-Instance-Context": '{"stack_id": 42, "org_id": 24}',
|
||||
"HTTP_X-Grafana-Context": '{"UserId": 12}',
|
||||
}
|
||||
request = APIRequestFactory().get("/", **headers)
|
||||
|
||||
ret_user, ret_token = PluginAuthentication().authenticate(request)
|
||||
assert ret_user == user
|
||||
assert ret_token.organization == organization
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("grafana_context", [None, "", "non-json", '"string"', "{}", '{"UserId": 1}'])
|
||||
def test_plugin_authentication_fail_grafana_context(
|
||||
make_organization, make_user, make_token_for_organization, grafana_context
|
||||
):
|
||||
organization = make_organization(stack_id=42, org_id=24)
|
||||
token, token_string = make_token_for_organization(organization)
|
||||
|
||||
headers = {"HTTP_AUTHORIZATION": token_string, "HTTP_X-Instance-Context": '{"stack_id": 42, "org_id": 24}'}
|
||||
if grafana_context is not None:
|
||||
headers["HTTP_X-Grafana-Context"] = grafana_context
|
||||
|
||||
request = APIRequestFactory().get("/", **headers)
|
||||
with pytest.raises(AuthenticationFailed):
|
||||
PluginAuthentication().authenticate(request)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("authorization", [None, "", "123", "gcom:123"])
|
||||
@pytest.mark.parametrize("instance_context", [None, "", "non-json", '"string"', "{}", '{"stack_id": 1, "org_id": 1}'])
|
||||
def test_plugin_authentication_fail(authorization, instance_context):
|
||||
headers = {}
|
||||
|
||||
if authorization is not None:
|
||||
headers["HTTP_AUTHORIZATION"] = authorization
|
||||
|
||||
if instance_context is not None:
|
||||
headers["HTTP_X-Instance-Context"] = instance_context
|
||||
|
||||
request = APIRequestFactory().get("/", **headers)
|
||||
|
||||
with pytest.raises(AuthenticationFailed):
|
||||
PluginAuthentication().authenticate(request)
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ class GcomToken:
|
|||
self.organization = organization
|
||||
|
||||
|
||||
def check_gcom_permission(token_string: str, context) -> Optional["GcomToken"]:
|
||||
def check_gcom_permission(token_string: str, context) -> GcomToken:
|
||||
"""
|
||||
Verify that request from plugin is valid. Check it and synchronize the organization details
|
||||
with gcom every GCOM_TOKEN_CHECK_PERIOD.
|
||||
|
|
@ -87,7 +87,7 @@ def check_gcom_permission(token_string: str, context) -> Optional["GcomToken"]:
|
|||
return GcomToken(organization)
|
||||
|
||||
|
||||
def check_token(token_string: str, context: dict):
|
||||
def check_token(token_string: str, context: dict) -> GcomToken | PluginAuthToken:
|
||||
token_parts = token_string.split(":")
|
||||
if len(token_parts) > 1 and token_parts[0] == "gcom":
|
||||
return check_gcom_permission(token_parts[1], context)
|
||||
|
|
|
|||
|
|
@ -383,7 +383,9 @@ def should_we_send_going_oncall_push_notification(
|
|||
f"shift_starts_within_fifteen_minutes: {shift_starts_within_fifteen_minutes}"
|
||||
)
|
||||
|
||||
if shift_starts_within_users_notification_timing_preference or shift_starts_within_fifteen_minutes:
|
||||
# Temporary remove `shift_starts_within_users_notification_timing_preference` from condition to send notification only 15 minutes before the shift starts
|
||||
# TODO: Return it once mobile app ready and default value is changed (https://github.com/grafana/oncall/issues/1999)
|
||||
if shift_starts_within_fifteen_minutes:
|
||||
logger.info(f"timing is right to send going oncall push notification\n{timing_logging_msg}")
|
||||
return seconds_until_shift_starts
|
||||
logger.info(f"timing is not right to send going oncall push notification\n{timing_logging_msg}")
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ def test_shift_starts_within_range(timing_window_lower, timing_window_upper, sec
|
|||
timezone.datetime(2022, 5, 2, 12, 5, 0),
|
||||
ONE_HOUR_IN_SECONDS,
|
||||
timezone.datetime(2022, 5, 2, 13, 12, 0),
|
||||
67 * 60,
|
||||
None,
|
||||
),
|
||||
(
|
||||
False,
|
||||
|
|
@ -79,7 +79,7 @@ def test_shift_starts_within_range(timing_window_lower, timing_window_upper, sec
|
|||
timezone.datetime(2022, 5, 2, 12, 5, 0),
|
||||
ONE_HOUR_IN_SECONDS,
|
||||
timezone.datetime(2022, 5, 2, 12, 58, 0),
|
||||
53 * 60,
|
||||
None,
|
||||
),
|
||||
(
|
||||
False,
|
||||
|
|
|
|||
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"
|
||||
)
|
||||
90
engine/apps/twilioapp/gather.py
Normal file
90
engine/apps/twilioapp/gather.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import logging
|
||||
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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:
|
||||
logger.info(f"twilioapp.process_digit: processing sid={call_sid} digit={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:
|
||||
logger.info(f"twilioapp.process_digit: found legacy twilio_phone_call sid={call_sid} digit={digit}")
|
||||
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
|
||||
|
||||
logger.info(
|
||||
f"twilioapp.process_digit: processing using phone_call_record id={phone_call_record.id} "
|
||||
f"twilio_phone_call sid={call_sid} digit={digit} alert_group_id={alert_group.id}"
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
164
engine/apps/twilioapp/status_callback.py
Normal file
164
engine/apps/twilioapp/status_callback.py
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import logging
|
||||
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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:
|
||||
logger.info(f"twilioapp.update_twilio_call_status: processing sid={call_sid} status={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:
|
||||
logger.info(
|
||||
f"twilioapp.update_twilio_call_status: found legacy twilio_phone_call sid={call_sid}"
|
||||
f" status={call_status}"
|
||||
)
|
||||
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:
|
||||
logger.info(
|
||||
f"twilioapp.update_twilio_call_status: processing using phone_call_record id={phone_call_record.id} "
|
||||
f"sid={call_sid} status={call_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:
|
||||
logger.info(f"twilioapp.update_twilio_message_status: processing sid={message_sid} status={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:
|
||||
logger.info(
|
||||
f"twilioapp.update_twilio_sms_status: found legacy twilio_phone_call sid={message_sid}"
|
||||
f" status={message_sid}"
|
||||
)
|
||||
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:
|
||||
logger.info(
|
||||
f"twilioapp.update_twilio_sms_status: processing using sms_record id={sms_record.id} "
|
||||
f"sid={message_sid} status={message_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,5 @@ class CallStatusCallback(APIView):
|
|||
call_sid = request.POST.get("CallSid")
|
||||
call_status = request.POST.get("CallStatus")
|
||||
|
||||
logging.info(f"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,
|
||||
|
|
|
|||
|
|
@ -197,6 +197,10 @@ class TeamFilteringMixin:
|
|||
|
||||
@property
|
||||
def available_teams_lookup_args(self):
|
||||
"""
|
||||
This property returns a list of Q objects that are used to filter instances by teams available to the user.
|
||||
NOTE: use .distinct() after filtering by available teams as it may return duplicate instances.
|
||||
"""
|
||||
available_teams_lookup_args = []
|
||||
if not self.request.user.role == LegacyAccessControlRole.ADMIN:
|
||||
available_teams_lookup_args = [
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
)
|
||||
|
|
@ -53,5 +53,5 @@ django-ipware==4.0.2
|
|||
django-anymail==8.6
|
||||
django-deprecate-fields==0.1.1
|
||||
pymdown-extensions==10.0
|
||||
requests==2.29.0
|
||||
requests==2.31.0
|
||||
urllib3==1.26.15
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import { BASE_URL, GRAFANA_PASSWORD, GRAFANA_USERNAME, IS_OPEN_SOURCE, ONCALL_AP
|
|||
import { clickButton, getInputByName } from './utils/forms';
|
||||
import { goToGrafanaPage } from './utils/navigation';
|
||||
|
||||
const GLOBAL_SETUP_RETRIES = 3;
|
||||
|
||||
/**
|
||||
* go to config page and wait for plugin icon to be available on left-hand navigation
|
||||
*/
|
||||
export const configureOnCallPlugin = async (page: Page): Promise<void> => {
|
||||
const configureOnCallPlugin = async (page: Page): Promise<void> => {
|
||||
// plugin configuration can safely be skipped for non open-source environments
|
||||
if (!IS_OPEN_SOURCE) {
|
||||
return;
|
||||
|
|
@ -31,8 +33,14 @@ export const configureOnCallPlugin = async (page: Page): Promise<void> => {
|
|||
await clickButton({ page, buttonText: 'Connect' });
|
||||
}
|
||||
|
||||
// wait for the "Connected to OnCall" message to know that everything is properly configured
|
||||
await expect(page.getByTestId('status-message-block')).toHaveText(/Connected to OnCall.*/);
|
||||
/**
|
||||
* wait for the "Connected to OnCall" message to know that everything is properly configured
|
||||
*
|
||||
* Regarding increasing the timeout for the "plugin configured" assertion:
|
||||
* This is because it can sometimes take a bit longer for the backend sync to finish. The default assertion
|
||||
* timeout is 5s, which is sometimes not enough if the backend is under load
|
||||
*/
|
||||
await expect(page.getByTestId('status-message-block')).toHaveText(/Connected to OnCall.*/, { timeout: 25_000 });
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -61,4 +69,23 @@ const globalSetup = async (config: FullConfig): Promise<void> => {
|
|||
await browserContext.close();
|
||||
};
|
||||
|
||||
export default globalSetup;
|
||||
/**
|
||||
* Let's retry global setup, in the event that it fails due to an oncall-engine/oncall-celery backend error.
|
||||
* Sometimes the sync endpoint will randomly return HTTP 500.
|
||||
* See here for an example CI job which failed global setup
|
||||
* https://github.com/grafana/oncall/actions/runs/5062712137/jobs/9088529416#step:19:2536
|
||||
*
|
||||
* References on retrying playwright global setup
|
||||
* https://github.com/microsoft/playwright/discussions/11371
|
||||
*/
|
||||
const globalSetupWithRetries = async (config: FullConfig): Promise<void> => {
|
||||
for (let i = 0; i < GLOBAL_SETUP_RETRIES - 1; i++) {
|
||||
try {
|
||||
return await globalSetup(config);
|
||||
} catch (e) {}
|
||||
}
|
||||
// One last time, throwing an error if it fails.
|
||||
await globalSetup(config);
|
||||
};
|
||||
|
||||
export default globalSetupWithRetries;
|
||||
|
|
|
|||
|
|
@ -6,13 +6,24 @@ test('check schedule quality for simple 1-user schedule', async ({ page }) => {
|
|||
const onCallScheduleName = generateRandomValue();
|
||||
await createOnCallSchedule(page, onCallScheduleName);
|
||||
|
||||
await expect(page.locator('div[class*="ScheduleQuality"]')).toHaveText('Quality: Great');
|
||||
/**
|
||||
* this page.reload() call is a hack to temporarily get around this issue
|
||||
* https://github.com/grafana/oncall/issues/1968
|
||||
*/
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
|
||||
await page.hover('div[class*="ScheduleQuality"]');
|
||||
await expect(page.locator('div[class*="ScheduleQualityDetails"] >> span[class*="Text"] >> nth=2 ')).toHaveText(
|
||||
const scheduleQualityElement = page.getByTestId('schedule-quality');
|
||||
|
||||
await expect(scheduleQualityElement).toHaveText('Quality: Great', { timeout: 15_000 });
|
||||
|
||||
await scheduleQualityElement.hover();
|
||||
|
||||
const scheduleQualityDetailsElement = page.getByTestId('schedule-quality-details');
|
||||
|
||||
await expect(scheduleQualityDetailsElement.locator('span[class*="Text"] >> nth=2 ')).toHaveText(
|
||||
'Schedule has no gaps'
|
||||
);
|
||||
await expect(page.locator('div[class*="ScheduleQualityDetails"] >> span[class*="Text"] >> nth=3 ')).toHaveText(
|
||||
await expect(scheduleQualityDetailsElement.locator('span[class*="Text"] >> nth=3 ')).toHaveText(
|
||||
'Schedule is perfectly balanced'
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ const config: PlaywrightTestConfig = {
|
|||
testDir: './integration-tests',
|
||||
globalSetup: './integration-tests/globalSetup.ts',
|
||||
/* Maximum time one test can run for. */
|
||||
timeout: 90 * 1000,
|
||||
timeout: 60 * 1000,
|
||||
expect: {
|
||||
/**
|
||||
* Maximum time expect() should wait for the condition to be met.
|
||||
|
|
@ -26,9 +26,14 @@ const config: PlaywrightTestConfig = {
|
|||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 3 : 0,
|
||||
workers: 1,
|
||||
/**
|
||||
* Retry on CI only
|
||||
*
|
||||
* NOTE: until we fix this issue (https://github.com/grafana/oncall/issues/1692) which occasionally leads
|
||||
* to flaky tests.. let's just retry failed tests. If the same test fails 3 times, you know something must be up
|
||||
*/
|
||||
retries: !!process.env.CI ? 3 : 0,
|
||||
workers: !!process.env.CI ? 2 : 1,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
.root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
color: var(--secondary-text-color);
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import React, { ChangeEvent, FC, useCallback } from 'react';
|
||||
|
||||
import { Icon, Input, IconButton } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
|
||||
import styles from './EscalationsFilters.module.css';
|
||||
|
||||
export interface Filters {
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
interface EscalationsFiltersProps {
|
||||
value: Filters;
|
||||
onChange: (filters: Filters) => void;
|
||||
}
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
const EscalationsFilters: FC<EscalationsFiltersProps> = (props) => {
|
||||
const { value, onChange } = props;
|
||||
|
||||
const onSearchTermChangeCallback = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const filters = {
|
||||
...value,
|
||||
searchTerm: e.currentTarget.value,
|
||||
};
|
||||
|
||||
onChange(filters);
|
||||
},
|
||||
[onChange, value]
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
onChange({ searchTerm: '' });
|
||||
}, [onChange]);
|
||||
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
<Input
|
||||
autoFocus
|
||||
data-testid="escalation-chain-search-input"
|
||||
className={cx('search')}
|
||||
prefix={<Icon name="search" />}
|
||||
placeholder="Search escalations..."
|
||||
value={value.searchTerm}
|
||||
onChange={onSearchTermChangeCallback}
|
||||
/>
|
||||
<IconButton name="times" onClick={handleClear} className={cx('icon-button')} tooltip="Clear search input" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EscalationsFilters;
|
||||
|
|
@ -83,11 +83,6 @@ const MonacoEditor: FC<MonacoEditorProps> = (props) => {
|
|||
return <LoadingPlaceholder text="Loading..." />;
|
||||
}
|
||||
|
||||
const otherProps: any = {};
|
||||
if (useAutoCompleteList) {
|
||||
otherProps.getSuggestions = { autoCompleteList };
|
||||
}
|
||||
|
||||
return (
|
||||
<CodeEditor
|
||||
monacoOptions={monacoOptions}
|
||||
|
|
@ -99,7 +94,7 @@ const MonacoEditor: FC<MonacoEditorProps> = (props) => {
|
|||
width="100%"
|
||||
height={height}
|
||||
onEditorDidMount={handleMount}
|
||||
{...otherProps}
|
||||
getSuggestions={useAutoCompleteList ? autoCompleteList : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ const ScheduleQuality: FC<ScheduleQualityProps> = ({ schedule, lastUpdated }) =>
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('root')} data-testid="schedule-quality">
|
||||
{relatedEscalationChains?.length > 0 && schedule?.number_of_escalation_chains > 0 && (
|
||||
<TooltipBadge
|
||||
borderType="link"
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export const ScheduleQualityDetails: FC<ScheduleQualityDetailsProps> = ({ qualit
|
|||
const warningComments = comments.filter((c) => c.type === 'warning');
|
||||
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('root')} data-testid="schedule-quality-details">
|
||||
<div className={cx('container')}>
|
||||
<div className={cx('container', 'container--withLateralPadding')}>
|
||||
<Text type={cx('secondary', 'header')}>
|
||||
|
|
|
|||
|
|
@ -27,3 +27,7 @@
|
|||
word-break: break-word;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.content--noMargin {
|
||||
margin: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ const TimelineItem: React.FC<TimelineItemProps> = ({
|
|||
{number}
|
||||
</div>
|
||||
)}
|
||||
<div className={cx('content', contentClassName)}>{children}</div>
|
||||
<div className={cx('content', contentClassName, { 'content--noMargin': isDisabled })}>{children}</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,10 +12,11 @@ import { getVar } from 'utils/DOM';
|
|||
|
||||
interface ChatOpsConnectorsProps {
|
||||
channelFilterId: ChannelFilter['id'];
|
||||
showLineNumber?: boolean;
|
||||
}
|
||||
|
||||
export const ChatOpsConnectors = (props: ChatOpsConnectorsProps) => {
|
||||
const { channelFilterId } = props;
|
||||
const { channelFilterId, showLineNumber = true } = props;
|
||||
|
||||
const store = useStore();
|
||||
const { telegramChannelStore } = store;
|
||||
|
|
@ -29,7 +30,7 @@ export const ChatOpsConnectors = (props: ChatOpsConnectorsProps) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<Timeline.Item number={0} backgroundColor={getVar('--tag-secondary')}>
|
||||
<Timeline.Item number={0} backgroundColor={getVar('--tag-secondary')} isDisabled={!showLineNumber}>
|
||||
<VerticalGroup>
|
||||
{isSlackInstalled && <SlackConnector channelFilterId={channelFilterId} />}
|
||||
{isTelegramInstalled && <TelegramConnector channelFilterId={channelFilterId} />}
|
||||
|
|
|
|||
|
|
@ -146,7 +146,6 @@ const GSelect = observer((props: GSelectProps) => {
|
|||
|
||||
return (
|
||||
<div className={cx('root', className)}>
|
||||
{/*@ts-ignore*/}
|
||||
<Tag
|
||||
autoFocus={autoFocus}
|
||||
isSearchable={showSearch}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useReducer } from 'react';
|
||||
import React, { useEffect, useReducer } from 'react';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { Button, HorizontalGroup, InlineLabel, VerticalGroup, Icon, Tooltip, ConfirmModal } from '@grafana/ui';
|
||||
import { Button, HorizontalGroup, InlineLabel, VerticalGroup, Icon, Tooltip, ConfirmModal, Select } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
|
|
@ -13,13 +13,13 @@ import Text from 'components/Text/Text';
|
|||
import TooltipBadge from 'components/TooltipBadge/TooltipBadge';
|
||||
import { ChatOpsConnectors } from 'containers/AlertRules/parts';
|
||||
import EscalationChainSteps from 'containers/EscalationChainSteps/EscalationChainSteps';
|
||||
import GSelect from 'containers/GSelect/GSelect';
|
||||
import styles from 'containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.module.scss';
|
||||
import TeamName from 'containers/TeamName/TeamName';
|
||||
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
|
||||
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
|
||||
import { AlertTemplatesDTO } from 'models/alert_templates';
|
||||
import { ChannelFilter } from 'models/channel_filter/channel_filter.types';
|
||||
import { EscalationChain } from 'models/escalation_chain/escalation_chain.types';
|
||||
import { MONACO_INPUT_HEIGHT_SMALL, MONACO_OPTIONS } from 'pages/integration_2/Integration2.config';
|
||||
import IntegrationHelper from 'pages/integration_2/Integration2.helper';
|
||||
import { AppFeature } from 'state/features';
|
||||
|
|
@ -71,6 +71,10 @@ const ExpandedIntegrationRouteDisplay: React.FC<ExpandedIntegrationRouteDisplayP
|
|||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
escalationChainStore.updateItems();
|
||||
}, []);
|
||||
|
||||
const channelFilter = alertReceiveChannelStore.channelFilters[channelFilterId];
|
||||
const channelFiltersTotal = Object.keys(alertReceiveChannelStore.channelFilters);
|
||||
if (!channelFilter) {
|
||||
|
|
@ -152,7 +156,7 @@ const ExpandedIntegrationRouteDisplay: React.FC<ExpandedIntegrationRouteDisplayP
|
|||
<IntegrationBlockItem>
|
||||
<VerticalGroup spacing="md">
|
||||
<Text type="primary">Publish to ChatOps</Text>
|
||||
<ChatOpsConnectors channelFilterId={channelFilterId} />
|
||||
<ChatOpsConnectors channelFilterId={channelFilterId} showLineNumber={false} />
|
||||
</VerticalGroup>
|
||||
</IntegrationBlockItem>
|
||||
)}
|
||||
|
|
@ -162,18 +166,21 @@ const ExpandedIntegrationRouteDisplay: React.FC<ExpandedIntegrationRouteDisplayP
|
|||
<HorizontalGroup spacing={'xs'}>
|
||||
<InlineLabel width={20}>Escalation chain</InlineLabel>
|
||||
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
|
||||
<GSelect
|
||||
showSearch
|
||||
modelName="escalationChainStore"
|
||||
isLoading={isRefreshingEscalationChains}
|
||||
displayField="name"
|
||||
placeholder="Select Escalation Chain"
|
||||
className={cx('select', 'control')}
|
||||
value={channelFilter.escalation_chain}
|
||||
onChange={onEscalationChainChange}
|
||||
showWarningIfEmptyValue={true}
|
||||
<Select
|
||||
isSearchable
|
||||
width={'auto'}
|
||||
icon={'list-ul'}
|
||||
menuShouldPortal
|
||||
className={cx('select', 'control')}
|
||||
placeholder="Select escalation chain"
|
||||
isLoading={isRefreshingEscalationChains}
|
||||
onChange={onEscalationChainChange}
|
||||
options={Object.keys(escalationChainStore.items).map(
|
||||
(eschalationChainId: EscalationChain['id']) => ({
|
||||
value: escalationChainStore.items[eschalationChainId].id,
|
||||
label: escalationChainStore.items[eschalationChainId].name,
|
||||
})
|
||||
)}
|
||||
value={channelFilter.escalation_chain}
|
||||
getOptionLabel={(item: SelectableValue) => {
|
||||
return (
|
||||
<>
|
||||
|
|
@ -185,18 +192,20 @@ const ExpandedIntegrationRouteDisplay: React.FC<ExpandedIntegrationRouteDisplayP
|
|||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
></Select>
|
||||
</WithPermissionControlTooltip>
|
||||
|
||||
<Button variant={'secondary'} icon={'sync'} size={'md'} onClick={onEscalationChainsRefresh} />
|
||||
<Tooltip content={'Reload escalation chains list'} placement={'top'}>
|
||||
<Button variant={'secondary'} icon={'sync'} size={'md'} onClick={onEscalationChainsRefresh} />
|
||||
</Tooltip>
|
||||
|
||||
<PluginLink className={cx('hover-button')} target="_blank" query={escalationChainRedirectObj}>
|
||||
<Button
|
||||
variant={'secondary'}
|
||||
tooltip={channelFilter.escalation_chain ? 'Edit escalation chain' : 'Add escalation chain'}
|
||||
icon={'external-link-alt'}
|
||||
size={'md'}
|
||||
/>
|
||||
<Tooltip
|
||||
placement={'top'}
|
||||
content={channelFilter.escalation_chain ? 'Edit escalation chain' : 'Add an escalation chain'}
|
||||
>
|
||||
<Button variant={'secondary'} icon={'external-link-alt'} size={'md'} />
|
||||
</Tooltip>
|
||||
</PluginLink>
|
||||
|
||||
{channelFilter.escalation_chain && (
|
||||
|
|
@ -240,7 +249,7 @@ const ExpandedIntegrationRouteDisplay: React.FC<ExpandedIntegrationRouteDisplayP
|
|||
await alertReceiveChannelStore.deleteChannelFilter(routeIdForDeletion);
|
||||
}
|
||||
|
||||
function onEscalationChainChange(value: string) {
|
||||
function onEscalationChainChange({ value }) {
|
||||
alertReceiveChannelStore
|
||||
.saveChannelFilter(channelFilterId, {
|
||||
escalation_chain: value,
|
||||
|
|
|
|||
|
|
@ -126,7 +126,10 @@ const TemplatesAlertGroupsList = (props: TemplatesAlertGroupsListProps) => {
|
|||
showLineNumbers
|
||||
useAutoCompleteList={false}
|
||||
language={MONACO_LANGUAGE.json}
|
||||
monacoOptions={MONACO_PAYLOAD_OPTIONS}
|
||||
monacoOptions={{
|
||||
...MONACO_PAYLOAD_OPTIONS,
|
||||
readOnly: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</VerticalGroup>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -620,8 +620,7 @@
|
|||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"grafanaDependency": ">=8.3.2",
|
||||
"grafanaVersion": "8.3",
|
||||
"grafanaDependency": ">=9.2.0",
|
||||
"plugins": []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
requests==2.27.1
|
||||
requests==2.31.0
|
||||
pdpyras==4.5.0
|
||||
pytest==7.1.2
|
||||
pytest-env==0.6.2
|
||||
Loading…
Add table
Reference in a new issue