From 71e92376118a4fe8da73b5da37f383c2dfe34889 Mon Sep 17 00:00:00 2001 From: Rares Mardare Date: Tue, 23 May 2023 15:33:19 +0300 Subject: [PATCH 01/24] Templates tweaks (#1993) # What this PR does - Hide timeline numbering when `isDisabled` is being sent - Fixed tooltips within expanded route for escalation chains actions - Replaced GSelect with Select to allow refreshing list manually --- .../components/Timeline/Timeline.module.css | 4 ++ .../src/components/Timeline/TimelineItem.tsx | 2 +- .../src/containers/AlertRules/parts/index.tsx | 5 +- .../src/containers/GSelect/GSelect.tsx | 1 - .../ExpandedIntegrationRouteDisplay.tsx | 57 +++++++++++-------- 5 files changed, 41 insertions(+), 28 deletions(-) diff --git a/grafana-plugin/src/components/Timeline/Timeline.module.css b/grafana-plugin/src/components/Timeline/Timeline.module.css index b4ee44c7..c2f4e970 100644 --- a/grafana-plugin/src/components/Timeline/Timeline.module.css +++ b/grafana-plugin/src/components/Timeline/Timeline.module.css @@ -27,3 +27,7 @@ word-break: break-word; flex-grow: 1; } + +.content--noMargin { + margin: 0; +} diff --git a/grafana-plugin/src/components/Timeline/TimelineItem.tsx b/grafana-plugin/src/components/Timeline/TimelineItem.tsx index 1807cf28..077d1943 100644 --- a/grafana-plugin/src/components/Timeline/TimelineItem.tsx +++ b/grafana-plugin/src/components/Timeline/TimelineItem.tsx @@ -33,7 +33,7 @@ const TimelineItem: React.FC = ({ {number} )} -
{children}
+
{children}
); }; diff --git a/grafana-plugin/src/containers/AlertRules/parts/index.tsx b/grafana-plugin/src/containers/AlertRules/parts/index.tsx index 8a49b500..13268e44 100644 --- a/grafana-plugin/src/containers/AlertRules/parts/index.tsx +++ b/grafana-plugin/src/containers/AlertRules/parts/index.tsx @@ -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 ( - + {isSlackInstalled && } {isTelegramInstalled && } diff --git a/grafana-plugin/src/containers/GSelect/GSelect.tsx b/grafana-plugin/src/containers/GSelect/GSelect.tsx index 1570c443..16fb6db1 100644 --- a/grafana-plugin/src/containers/GSelect/GSelect.tsx +++ b/grafana-plugin/src/containers/GSelect/GSelect.tsx @@ -146,7 +146,6 @@ const GSelect = observer((props: GSelectProps) => { return (
- {/*@ts-ignore*/} { + escalationChainStore.updateItems(); + }, []); + const channelFilter = alertReceiveChannelStore.channelFilters[channelFilterId]; const channelFiltersTotal = Object.keys(alertReceiveChannelStore.channelFilters); if (!channelFilter) { @@ -152,7 +156,7 @@ const ExpandedIntegrationRouteDisplay: React.FC Publish to ChatOps - + )} @@ -162,18 +166,21 @@ const ExpandedIntegrationRouteDisplay: React.FC Escalation chain - ({ + 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 ); }} - /> + > -
From c9216744718ecd4fef1c87c84e710b9ea76a082f Mon Sep 17 00:00:00 2001 From: Vadim Stepanov Date: Tue, 23 May 2023 17:13:25 +0100 Subject: [PATCH 06/24] Improve plugin authentication (#1995) # What this PR does Handle different failing authentication scenarios (e.g. when token is invalid or instance context is not a valid JSON) so endpoints return appropriate response code (401 instead of 500). ## Which issue(s) this PR fixes Related to https://github.com/grafana/oncall-private/issues/1633 ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required) --- CHANGELOG.md | 6 ++ engine/apps/auth_token/auth.py | 19 ++++- .../auth_token/models/plugin_auth_token.py | 6 +- .../apps/auth_token/tests/test_plugin_auth.py | 77 +++++++++++++++++++ engine/apps/grafana_plugin/helpers/gcom.py | 4 +- 5 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 engine/apps/auth_token/tests/test_plugin_auth.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 78bfcd28..0aefb580 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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). +## Unreleased + +### Fixed + +- Improve plugin authentication by @vadimkerr ([#1995](https://github.com/grafana/oncall/pull/1995)) + ## v1.2.27 (2023-05-23) ### Added diff --git a/engine/apps/auth_token/auth.py b/engine/apps/auth_token/auth.py index 90162ab6..462315a5 100644 --- a/engine/apps/auth_token/auth.py +++ b/engine/apps/auth_token/auth.py @@ -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: diff --git a/engine/apps/auth_token/models/plugin_auth_token.py b/engine/apps/auth_token/models/plugin_auth_token.py index cd33c25b..56a33068 100644 --- a/engine/apps/auth_token/models/plugin_auth_token.py +++ b/engine/apps/auth_token/models/plugin_auth_token.py @@ -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 diff --git a/engine/apps/auth_token/tests/test_plugin_auth.py b/engine/apps/auth_token/tests/test_plugin_auth.py new file mode 100644 index 00000000..664ff9d3 --- /dev/null +++ b/engine/apps/auth_token/tests/test_plugin_auth.py @@ -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) diff --git a/engine/apps/grafana_plugin/helpers/gcom.py b/engine/apps/grafana_plugin/helpers/gcom.py index b2f510e9..009ac853 100644 --- a/engine/apps/grafana_plugin/helpers/gcom.py +++ b/engine/apps/grafana_plugin/helpers/gcom.py @@ -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) From 06bd0454f670bab09170e648533b5c037710a14b Mon Sep 17 00:00:00 2001 From: Vadim Stepanov Date: Tue, 23 May 2023 17:23:06 +0100 Subject: [PATCH 07/24] Fix MultipleObjectsReturned error on webhook endpoints (#1996) # What this PR does Sometimes `CustomButtonView` returns HTTP 500 with the following error: ``` apps.alerts.models.custom_button.CustomButton.MultipleObjectsReturned: get() returned more than one CustomButton -- it returned 3! ``` This PR fixes it by adding `.distinct()` to the `CustomButton` queryset when retrieving an instance + does the same for `WebhooksView`. ## Which issue(s) this PR fixes Related to https://github.com/grafana/oncall-private/issues/1828 ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required) --- CHANGELOG.md | 1 + engine/apps/api/views/custom_button.py | 6 +++++- engine/apps/api/views/webhooks.py | 2 +- engine/common/api_helpers/mixins.py | 4 ++++ 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0aefb580..c6cff975 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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) diff --git a/engine/apps/api/views/custom_button.py b/engine/apps/api/views/custom_button.py index 9b8653d6..079d6f7f 100644 --- a/engine/apps/api/views/custom_button.py +++ b/engine/apps/api/views/custom_button.py @@ -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 diff --git a/engine/apps/api/views/webhooks.py b/engine/apps/api/views/webhooks.py index 8eaf3126..3b76b99a 100644 --- a/engine/apps/api/views/webhooks.py +++ b/engine/apps/api/views/webhooks.py @@ -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 diff --git a/engine/common/api_helpers/mixins.py b/engine/common/api_helpers/mixins.py index 53b29c77..cba754db 100644 --- a/engine/common/api_helpers/mixins.py +++ b/engine/common/api_helpers/mixins.py @@ -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 = [ From c793e550c623f3b2f68f492250a6db5518dfd69d Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Tue, 23 May 2023 17:26:12 -0400 Subject: [PATCH 08/24] re-enable e2e UI tests on CI (#1961) #1692 is still open. This PR is not an ideal approach, but it's a quick win while we wait for that issue to be resolved. By retrying failing tests up to 3 times, we _should_ be fine to re-enable these on CI. If a test is failing > 3 times, there's likely a legitimate issue occuring. --- .github/workflows/linting-and-tests.yml | 50 ++++++++++------- .../integration-tests/globalSetup.ts | 12 +++-- .../schedules/quality.test.ts | 19 +++++-- grafana-plugin/playwright.config.ts | 11 ++-- .../EscalationsFilters.module.css | 13 ----- .../EscalationsFilters/EscalationsFilters.tsx | 54 ------------------- .../ScheduleQuality/ScheduleQuality.tsx | 2 +- .../ScheduleQualityDetails.tsx | 2 +- grafana-plugin/src/plugin.json | 3 +- 9 files changed, 67 insertions(+), 99 deletions(-) delete mode 100644 grafana-plugin/src/components/EscalationsFilters/EscalationsFilters.module.css delete mode 100644 grafana-plugin/src/components/EscalationsFilters/EscalationsFilters.tsx diff --git a/.github/workflows/linting-and-tests.yml b/.github/workflows/linting-and-tests.yml index 633a528d..d85932ce 100644 --- a/.github/workflows/linting-and-tests.yml +++ b/.github/workflows/linting-and-tests.yml @@ -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 @@ -331,10 +340,9 @@ 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, 3 celery, and 3 grafana 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 # # by settings grafana.plugins to [] and configuring grafana.extraVolumeMounts we are using the locally built # OnCall plugin rather than the latest published version @@ -346,14 +354,14 @@ 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 }}" \ --set oncall.twilio.phoneNumber="\"${{ secrets.TWILIO_PHONE_NUMBER }}"\" \ --set oncall.twilio.verifySid="${{ secrets.TWILIO_VERIFY_SID }}" \ - --set grafana.replicas=1 \ + --set grafana.replicas=3 \ --set grafana.image.tag=${{ matrix.grafana-image-tag }} \ --set grafana.env.GF_SECURITY_ADMIN_USER=oncall \ --set grafana.env.GF_SECURITY_ADMIN_PASSWORD=oncall \ @@ -378,12 +386,19 @@ 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 + + # 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 - name: Await k8s pods and other resources up uses: jupyterhub/action-k8s-await-workloads@v1 @@ -408,8 +423,7 @@ 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 diff --git a/grafana-plugin/integration-tests/globalSetup.ts b/grafana-plugin/integration-tests/globalSetup.ts index 244158d5..04af1cfc 100644 --- a/grafana-plugin/integration-tests/globalSetup.ts +++ b/grafana-plugin/integration-tests/globalSetup.ts @@ -7,7 +7,7 @@ import { goToGrafanaPage } from './utils/navigation'; /** * go to config page and wait for plugin icon to be available on left-hand navigation */ -export const configureOnCallPlugin = async (page: Page): Promise => { +const configureOnCallPlugin = async (page: Page): Promise => { // plugin configuration can safely be skipped for non open-source environments if (!IS_OPEN_SOURCE) { return; @@ -31,8 +31,14 @@ export const configureOnCallPlugin = async (page: Page): Promise => { 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 }); }; /** diff --git a/grafana-plugin/integration-tests/schedules/quality.test.ts b/grafana-plugin/integration-tests/schedules/quality.test.ts index 94b8e2a0..4e010162 100644 --- a/grafana-plugin/integration-tests/schedules/quality.test.ts +++ b/grafana-plugin/integration-tests/schedules/quality.test.ts @@ -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' ); }); diff --git a/grafana-plugin/playwright.config.ts b/grafana-plugin/playwright.config.ts index 9084e46a..2ca27677 100644 --- a/grafana-plugin/playwright.config.ts +++ b/grafana-plugin/playwright.config.ts @@ -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,8 +26,13 @@ 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, + /** + * 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: 1, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: 'html', diff --git a/grafana-plugin/src/components/EscalationsFilters/EscalationsFilters.module.css b/grafana-plugin/src/components/EscalationsFilters/EscalationsFilters.module.css deleted file mode 100644 index 0ed200a7..00000000 --- a/grafana-plugin/src/components/EscalationsFilters/EscalationsFilters.module.css +++ /dev/null @@ -1,13 +0,0 @@ -.root { - display: flex; - align-items: center; -} - -.search { - max-width: 400px; -} - -.icon-button { - color: var(--secondary-text-color); - margin-left: 8px; -} diff --git a/grafana-plugin/src/components/EscalationsFilters/EscalationsFilters.tsx b/grafana-plugin/src/components/EscalationsFilters/EscalationsFilters.tsx deleted file mode 100644 index f2387261..00000000 --- a/grafana-plugin/src/components/EscalationsFilters/EscalationsFilters.tsx +++ /dev/null @@ -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 = (props) => { - const { value, onChange } = props; - - const onSearchTermChangeCallback = useCallback( - (e: ChangeEvent) => { - const filters = { - ...value, - searchTerm: e.currentTarget.value, - }; - - onChange(filters); - }, - [onChange, value] - ); - - const handleClear = useCallback(() => { - onChange({ searchTerm: '' }); - }, [onChange]); - - return ( -
- } - placeholder="Search escalations..." - value={value.searchTerm} - onChange={onSearchTermChangeCallback} - /> - -
- ); -}; - -export default EscalationsFilters; diff --git a/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.tsx b/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.tsx index 0b97dd7c..afbb3444 100644 --- a/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.tsx +++ b/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.tsx @@ -38,7 +38,7 @@ const ScheduleQuality: FC = ({ schedule, lastUpdated }) => return ( <> -
+
{relatedEscalationChains?.length > 0 && schedule?.number_of_escalation_chains > 0 && ( = ({ qualit const warningComments = comments.filter((c) => c.type === 'warning'); return ( -
+
diff --git a/grafana-plugin/src/plugin.json b/grafana-plugin/src/plugin.json index cdbfef28..6250bbe8 100644 --- a/grafana-plugin/src/plugin.json +++ b/grafana-plugin/src/plugin.json @@ -620,8 +620,7 @@ } ], "dependencies": { - "grafanaDependency": ">=8.3.2", - "grafanaVersion": "8.3", + "grafanaDependency": ">=9.2.0", "plugins": [] } } From eefe7be56a648705f4d48f605ca539d4db956d85 Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Tue, 23 May 2023 20:20:46 -0400 Subject: [PATCH 09/24] e2e tests on CI - actually await k8s resources to be ready before starting tests (#1997) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Occasionally, the Playwright global setup step (which authenticates w/ the Grafana API + configures the plugin) would fail, leading to the CI job to instantly fail (playwright doesn't retry global setup if it fails). My current hypothesis as to why this is happening is because the `oncall-engine` and `oncall-celery` pods aren't _actually_ ready in these cases based on the way the `jupyterhub/action-k8s-await-workloads` action await k8s workloads: Screenshot 2023-05-23 at 18 24 36 By using the `kubectl rollout status deployment/ --timeout=300s` instead, we can be sure that these pods are _actually_ ready to receive traffic before we start the tests. ```bash ❯ kubectl rollout status --help Show the status of the rollout. By default 'rollout status' will watch the status of the latest rollout until it's done. If you don't want to wait for the rollout to finish then you can use --watch=false. Note that if a new rollout starts in-between, then 'rollout status' will continue watching the latest revision. If you want to pin to a specific revision and abort if it is rolled over by another revision, use --revision=N where N is the revision you need to watch for. ``` Lastly, even despite this, sometimes the `POST /api/internal/v1/plugin/sync` endpoint will return HTTP 500 ([example logs](https://github.com/grafana/oncall/actions/runs/5062712137/jobs/9088529416#step:19:2536) from failed CI job). In this case, let's setup the Playwright global setup to retry 3 times. --- .github/workflows/linting-and-tests.yml | 36 ++++++++++++------- .../integration-tests/globalSetup.ts | 23 +++++++++++- grafana-plugin/playwright.config.ts | 2 +- 3 files changed, 47 insertions(+), 14 deletions(-) diff --git a/.github/workflows/linting-and-tests.yml b/.github/workflows/linting-and-tests.yml index d85932ce..456b8d34 100644 --- a/.github/workflows/linting-and-tests.yml +++ b/.github/workflows/linting-and-tests.yml @@ -287,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: @@ -340,9 +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 3 engine, 3 celery, and 3 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 + # 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 @@ -361,7 +371,7 @@ jobs: --set oncall.twilio.authToken="${{ secrets.TWILIO_AUTH_TOKEN }}" \ --set oncall.twilio.phoneNumber="\"${{ secrets.TWILIO_PHONE_NUMBER }}"\" \ --set oncall.twilio.verifySid="${{ secrets.TWILIO_VERIFY_SID }}" \ - --set grafana.replicas=3 \ + --set grafana.replicas=1 \ --set grafana.image.tag=${{ matrix.grafana-image-tag }} \ --set grafana.env.GF_SECURITY_ADMIN_USER=oncall \ --set grafana.env.GF_SECURITY_ADMIN_PASSWORD=oncall \ @@ -400,13 +410,15 @@ jobs: working-directory: grafana-plugin run: ./node_modules/.bin/playwright install-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 + # 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: @@ -425,14 +437,14 @@ jobs: working-directory: ./grafana-plugin 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() diff --git a/grafana-plugin/integration-tests/globalSetup.ts b/grafana-plugin/integration-tests/globalSetup.ts index 04af1cfc..79fb4b0c 100644 --- a/grafana-plugin/integration-tests/globalSetup.ts +++ b/grafana-plugin/integration-tests/globalSetup.ts @@ -4,6 +4,8 @@ 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 */ @@ -67,4 +69,23 @@ const globalSetup = async (config: FullConfig): Promise => { 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 => { + 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; diff --git a/grafana-plugin/playwright.config.ts b/grafana-plugin/playwright.config.ts index 2ca27677..5cae7ef8 100644 --- a/grafana-plugin/playwright.config.ts +++ b/grafana-plugin/playwright.config.ts @@ -33,7 +33,7 @@ const config: PlaywrightTestConfig = { * 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: 1, + 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. */ From 1f786e8d2aa50f28c75df65407e2f70d84a028da Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Wed, 24 May 2023 14:27:48 +0800 Subject: [PATCH 10/24] Phone provider refactoring (#1713) # What this PR does This PR moves phone notification logic into separate object PhoneBackend and introduces PhoneProvider interface to hide actual implementation of external phone services provider. It should allow add new phone providers just by implementing one class (See SimplePhoneProvider for example). # Why [Asterisk PR](https://github.com/grafana/oncall/pull/1282) showed that our phone notification system is not flexible. However this is one of the most frequent community questions - how to add "X" phone provider. Also, this refactoring move us one step closer to unifying all notification backends, since with PhoneBackend all phone notification logic is collected in one place and independent from concrete realisation. # Highligts 1. PhoneBackend object - contains all phone notifications business logic. 2. PhoneProvider - interface to external phone services provider. 3. TwilioPhoneProvider and SimplePhoneProvider - two examples of PhoneProvider implementation. 4. PhoneCallRecord and SMSRecord models. I introduced these models to keep phone notification limits logic decoupled from external providers. Existing TwilioPhoneCall and TwilioSMS objects will be migrated to the new table to not to reset limits counter. To be able to receive status callbacks and gather from Twilio TwilioPhoneCall and TwilioSMS still exists, but they are linked to PhoneCallRecord and SMSRecord via fk, to not to leat twilio logic into core code. --------- Co-authored-by: Yulia Shanyrova --- CHANGELOG.md | 4 + engine/apps/alerts/constants.py | 2 +- .../templaters/phone_call_templater.py | 7 +- engine/apps/alerts/tasks/notify_user.py | 20 +- engine/apps/alerts/tests/test_alert_group.py | 2 +- engine/apps/api/serializers/organization.py | 10 +- engine/apps/api/serializers/user.py | 2 +- engine/apps/api/tests/test_user.py | 69 +-- engine/apps/api/views/user.py | 130 ++++-- engine/apps/base/models/live_setting.py | 5 + engine/apps/base/tests/test_live_settings.py | 10 +- engine/apps/phone_notifications/__init__.py | 0 engine/apps/phone_notifications/exceptions.py | 34 ++ .../migrations/0001_initial.py | 60 +++ .../migrations/__init__.py | 0 .../phone_notifications/models/__init__.py | 2 + .../phone_notifications/models/phone_call.py | 81 ++++ engine/apps/phone_notifications/models/sms.py | 87 ++++ .../apps/phone_notifications/phone_backend.py | 399 ++++++++++++++++++ .../phone_notifications/phone_provider.py | 174 ++++++++ .../simple_phone_provider.py | 43 ++ .../phone_notifications/tests/__init__.py | 0 .../phone_notifications/tests/factories.py | 13 + .../tests/mock_phone_provider.py | 38 ++ .../tests/test_phone_backend_call.py | 227 ++++++++++ .../tests/test_phone_backend_oss_relay.py | 111 +++++ .../test_phone_backend_phone_verification.py | 69 +++ .../tests/test_phone_backend_sms.py | 236 +++++++++++ .../public_api/views/phone_notifications.py | 40 +- engine/apps/twilioapp/admin.py | 17 - engine/apps/twilioapp/constants.py | 108 ----- engine/apps/twilioapp/gather.py | 79 ++++ .../migrations/0003_auto_20230408_0711.py | 22 + .../0004_twiliophonecall_twiliosms.py | 41 ++ engine/apps/twilioapp/models/__init__.py | 4 +- engine/apps/twilioapp/models/phone_call.py | 272 ------------ engine/apps/twilioapp/models/sms_message.py | 240 ----------- .../twilioapp/models/twilio_log_record.py | 24 +- .../twilioapp/models/twilio_phone_call.py | 72 ++++ engine/apps/twilioapp/models/twilio_sms.py | 63 +++ engine/apps/twilioapp/phone_manager.py | 75 ---- engine/apps/twilioapp/phone_provider.py | 256 +++++++++++ engine/apps/twilioapp/status_callback.py | 142 +++++++ engine/apps/twilioapp/tests/factories.py | 13 - .../apps/twilioapp/tests/test_phone_calls.py | 159 ++----- .../apps/twilioapp/tests/test_sms_message.py | 71 +--- .../twilioapp/tests/test_twilio_provider.py | 65 +++ engine/apps/twilioapp/twilio_client.py | 206 --------- engine/apps/twilioapp/utils.py | 68 --- engine/apps/twilioapp/views.py | 22 +- .../free_public_beta_subscription_strategy.py | 6 +- ...t_free_public_beta_subcription_strategy.py | 17 +- .../tests/test_organization.py | 17 +- engine/common/api_helpers/utils.py | 4 + engine/common/utils.py | 8 - engine/conftest.py | 41 +- .../management/commands/verify_phone.py | 52 --- engine/settings/base.py | 9 + .../PhoneVerification/PhoneVerification.tsx | 236 +++++++---- grafana-plugin/src/models/team/team.types.ts | 7 + grafana-plugin/src/models/user/user.ts | 20 + 61 files changed, 2841 insertions(+), 1470 deletions(-) create mode 100644 engine/apps/phone_notifications/__init__.py create mode 100644 engine/apps/phone_notifications/exceptions.py create mode 100644 engine/apps/phone_notifications/migrations/0001_initial.py create mode 100644 engine/apps/phone_notifications/migrations/__init__.py create mode 100644 engine/apps/phone_notifications/models/__init__.py create mode 100644 engine/apps/phone_notifications/models/phone_call.py create mode 100644 engine/apps/phone_notifications/models/sms.py create mode 100644 engine/apps/phone_notifications/phone_backend.py create mode 100644 engine/apps/phone_notifications/phone_provider.py create mode 100644 engine/apps/phone_notifications/simple_phone_provider.py create mode 100644 engine/apps/phone_notifications/tests/__init__.py create mode 100644 engine/apps/phone_notifications/tests/factories.py create mode 100644 engine/apps/phone_notifications/tests/mock_phone_provider.py create mode 100644 engine/apps/phone_notifications/tests/test_phone_backend_call.py create mode 100644 engine/apps/phone_notifications/tests/test_phone_backend_oss_relay.py create mode 100644 engine/apps/phone_notifications/tests/test_phone_backend_phone_verification.py create mode 100644 engine/apps/phone_notifications/tests/test_phone_backend_sms.py delete mode 100644 engine/apps/twilioapp/admin.py delete mode 100644 engine/apps/twilioapp/constants.py create mode 100644 engine/apps/twilioapp/gather.py create mode 100644 engine/apps/twilioapp/migrations/0003_auto_20230408_0711.py create mode 100644 engine/apps/twilioapp/migrations/0004_twiliophonecall_twiliosms.py delete mode 100644 engine/apps/twilioapp/models/phone_call.py delete mode 100644 engine/apps/twilioapp/models/sms_message.py create mode 100644 engine/apps/twilioapp/models/twilio_phone_call.py create mode 100644 engine/apps/twilioapp/models/twilio_sms.py delete mode 100644 engine/apps/twilioapp/phone_manager.py create mode 100644 engine/apps/twilioapp/phone_provider.py create mode 100644 engine/apps/twilioapp/status_callback.py delete mode 100644 engine/apps/twilioapp/tests/factories.py create mode 100644 engine/apps/twilioapp/tests/test_twilio_provider.py delete mode 100644 engine/apps/twilioapp/twilio_client.py delete mode 100644 engine/apps/twilioapp/utils.py delete mode 100644 engine/engine/management/commands/verify_phone.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c6cff975..39bc4452 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Changed + +- Phone provider refactoring + ### Fixed - Improve plugin authentication by @vadimkerr ([#1995](https://github.com/grafana/oncall/pull/1995)) diff --git a/engine/apps/alerts/constants.py b/engine/apps/alerts/constants.py index 6d5dd0b8..38508e52 100644 --- a/engine/apps/alerts/constants.py +++ b/engine/apps/alerts/constants.py @@ -2,7 +2,7 @@ class ActionSource: ( SLACK, WEB, - TWILIO, + PHONE, TELEGRAM, ) = range(4) diff --git a/engine/apps/alerts/incident_appearance/templaters/phone_call_templater.py b/engine/apps/alerts/incident_appearance/templaters/phone_call_templater.py index 3d0127ca..eb13d86b 100644 --- a/engine/apps/alerts/incident_appearance/templaters/phone_call_templater.py +++ b/engine/apps/alerts/incident_appearance/templaters/phone_call_templater.py @@ -1,5 +1,5 @@ from apps.alerts.incident_appearance.templaters.alert_templater import AlertTemplater -from common.utils import clean_markup, escape_for_twilio_phone_call +from common.utils import clean_markup class AlertPhoneCallTemplater(AlertTemplater): @@ -14,7 +14,7 @@ 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) diff --git a/engine/apps/alerts/tasks/notify_user.py b/engine/apps/alerts/tasks/notify_user.py index 3fbca1af..e41a4bcf 100644 --- a/engine/apps/alerts/tasks/notify_user.py +++ b/engine/apps/alerts/tasks/notify_user.py @@ -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) diff --git a/engine/apps/alerts/tests/test_alert_group.py b/engine/apps/alerts/tests/test_alert_group.py index 272ef0b1..0e4d5f68 100644 --- a/engine/apps/alerts/tests/test_alert_group.py +++ b/engine/apps/alerts/tests/test_alert_group.py @@ -38,7 +38,7 @@ def test_render_for_phone_call( ) expected_verbose_name = ( - f"You are invited to check an incident from Grafana OnCall. " + f"to check an incident from Grafana OnCall. " f"Alert via {alert_receive_channel.verbal_name} - Grafana with title TestAlert triggered 1 times" ) rendered_text = AlertGroupPhoneCallRenderer(alert_group).render() diff --git a/engine/apps/api/serializers/organization.py b/engine/apps/api/serializers/organization.py index 79c6a90a..6f7c0257 100644 --- a/engine/apps/api/serializers/organization.py +++ b/engine/apps/api/serializers/organization.py @@ -1,3 +1,4 @@ +from dataclasses import asdict from datetime import timedelta import humanize @@ -7,6 +8,7 @@ from 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): diff --git a/engine/apps/api/serializers/user.py b/engine/apps/api/serializers/user.py index 98627d4e..77d8cb9e 100644 --- a/engine/apps/api/serializers/user.py +++ b/engine/apps/api/serializers/user.py @@ -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 diff --git a/engine/apps/api/tests/test_user.py b/engine/apps/api/tests/test_user.py index 1d4aa1b7..f64cf916 100644 --- a/engine/apps/api/tests/test_user.py +++ b/engine/apps/api/tests/test_user.py @@ -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() diff --git a/engine/apps/api/views/user.py b/engine/apps/api/views/user.py index 0a8a64ed..d1c8092c 100644 --- a/engine/apps/api/views/user.py +++ b/engine/apps/api/views/user.py @@ -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) diff --git a/engine/apps/base/models/live_setting.py b/engine/apps/base/models/live_setting.py index dc3ef1fe..e6d1e708 100644 --- a/engine/apps/base/models/live_setting.py +++ b/engine/apps/base/models/live_setting.py @@ -59,6 +59,7 @@ class LiveSetting(models.Model): "GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED", "GRAFANA_CLOUD_NOTIFICATIONS_ENABLED", "DANGEROUS_WEBHOOKS_ENABLED", + "PHONE_PROVIDER", ) DESCRIPTIONS = { @@ -146,6 +147,7 @@ class LiveSetting(models.Model): "GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED": "Enable heartbeat integration with Grafana Cloud OnCall.", "GRAFANA_CLOUD_NOTIFICATIONS_ENABLED": "Enable SMS/call notifications via Grafana Cloud OnCall", "DANGEROUS_WEBHOOKS_ENABLED": "Enable outgoing webhooks to private networks", + "PHONE_PROVIDER": f"Phone provider name. Available options: {','.join(list(settings.PHONE_PROVIDERS.keys()))}", } SECRET_SETTING_NAMES = ( @@ -217,6 +219,9 @@ 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}" diff --git a/engine/apps/base/tests/test_live_settings.py b/engine/apps/base/tests/test_live_settings.py index 498be849..6b6eca6b 100644 --- a/engine/apps/base/tests/test_live_settings.py +++ b/engine/apps/base/tests/test_live_settings.py @@ -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" diff --git a/engine/apps/phone_notifications/__init__.py b/engine/apps/phone_notifications/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/engine/apps/phone_notifications/exceptions.py b/engine/apps/phone_notifications/exceptions.py new file mode 100644 index 00000000..97b9348b --- /dev/null +++ b/engine/apps/phone_notifications/exceptions.py @@ -0,0 +1,34 @@ +class FailedToMakeCall(Exception): + pass + + +class FailedToSendSMS(Exception): + pass + + +class NumberNotVerified(Exception): + pass + + +class NumberAlreadyVerified(Exception): + pass + + +class FailedToStartVerification(Exception): + pass + + +class FailedToFinishVerification(Exception): + pass + + +class ProviderNotSupports(Exception): + pass + + +class CallsLimitExceeded(Exception): + pass + + +class SMSLimitExceeded(Exception): + pass diff --git a/engine/apps/phone_notifications/migrations/0001_initial.py b/engine/apps/phone_notifications/migrations/0001_initial.py new file mode 100644 index 00000000..64fa4760 --- /dev/null +++ b/engine/apps/phone_notifications/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 3.2.18 on 2023-05-24 03:54 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('user_management', '0011_auto_20230411_1358'), + ('alerts', '0015_auto_20230508_1641'), + ('base', '0003_delete_organizationlogrecord'), + ('twilioapp', '0003_auto_20230408_0711'), + ] + + state_operations = [ + migrations.CreateModel( + name='SMSRecord', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('exceeded_limit', models.BooleanField(default=None, null=True)), + ('grafana_cloud_notification', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('status', models.PositiveSmallIntegerField(blank=True, choices=[(10, 'accepted'), (20, 'queued'), (30, 'sending'), (40, 'sent'), (50, 'failed'), (60, 'delivered'), (70, 'undelivered'), (80, 'receiving'), (90, 'received'), (100, 'read')], null=True)), + ('sid', models.CharField(blank=True, max_length=50)), + ('notification_policy', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='base.usernotificationpolicy')), + ('receiver', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='user_management.user')), + ('represents_alert', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='alerts.alert')), + ('represents_alert_group', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='alerts.alertgroup')), + ], + options={ + 'db_table': 'twilioapp_smsmessage', + }, + ), + migrations.CreateModel( + name='PhoneCallRecord', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('exceeded_limit', models.BooleanField(default=None, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('grafana_cloud_notification', models.BooleanField(default=False)), + ('status', models.PositiveSmallIntegerField(blank=True, choices=[(10, 'queued'), (20, 'ringing'), (30, 'in-progress'), (40, 'completed'), (50, 'busy'), (60, 'failed'), (70, 'no-answer'), (80, 'canceled')], null=True)), + ('sid', models.CharField(blank=True, max_length=50)), + ('notification_policy', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='base.usernotificationpolicy')), + ('receiver', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='user_management.user')), + ('represents_alert', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='alerts.alert')), + ('represents_alert_group', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='alerts.alertgroup')), + ], + options={ + 'db_table': 'twilioapp_phonecall', + }, + ), + ] + + operations = [ + migrations.SeparateDatabaseAndState(state_operations=state_operations) + ] + diff --git a/engine/apps/phone_notifications/migrations/__init__.py b/engine/apps/phone_notifications/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/engine/apps/phone_notifications/models/__init__.py b/engine/apps/phone_notifications/models/__init__.py new file mode 100644 index 00000000..c3d30b71 --- /dev/null +++ b/engine/apps/phone_notifications/models/__init__.py @@ -0,0 +1,2 @@ +from .phone_call import PhoneCallRecord, ProviderPhoneCall # noqa: F401 +from .sms import ProviderSMS, SMSRecord # noqa: F401 diff --git a/engine/apps/phone_notifications/models/phone_call.py b/engine/apps/phone_notifications/models/phone_call.py new file mode 100644 index 00000000..b4a9182b --- /dev/null +++ b/engine/apps/phone_notifications/models/phone_call.py @@ -0,0 +1,81 @@ +from django.db import models + + +# Duplicate to avoid circular import to provide values for status field +class TwilioCallStatuses: + QUEUED = 10 + RINGING = 20 + IN_PROGRESS = 30 + COMPLETED = 40 + BUSY = 50 + FAILED = 60 + NO_ANSWER = 70 + CANCELED = 80 + + CHOICES = ( + (QUEUED, "queued"), + (RINGING, "ringing"), + (IN_PROGRESS, "in-progress"), + (COMPLETED, "completed"), + (BUSY, "busy"), + (FAILED, "failed"), + (NO_ANSWER, "no-answer"), + (CANCELED, "canceled"), + ) + + +class PhoneCallRecord(models.Model): + class Meta: + db_table = "twilioapp_phonecall" + + exceeded_limit = models.BooleanField(null=True, default=None) + represents_alert = models.ForeignKey( + "alerts.Alert", on_delete=models.SET_NULL, null=True, default=None + ) # deprecateed + represents_alert_group = models.ForeignKey("alerts.AlertGroup", on_delete=models.SET_NULL, null=True, default=None) + notification_policy = models.ForeignKey( + "base.UserNotificationPolicy", on_delete=models.SET_NULL, null=True, default=None + ) + + receiver = models.ForeignKey("user_management.User", on_delete=models.CASCADE, null=True, default=None) + + created_at = models.DateTimeField(auto_now_add=True) + + grafana_cloud_notification = models.BooleanField(default=False) # rename + + # deprecated. It's here for backward compatibility for calls made during or shortly before migration. + # Should be removed soon after migration + status = models.PositiveSmallIntegerField( + blank=True, + null=True, + choices=TwilioCallStatuses.CHOICES, + ) + + sid = models.CharField( + blank=True, + max_length=50, + ) + + +class ProviderPhoneCall(models.Model): + """ + ProviderPhoneCall is an interface between PhoneCallRecord and call data returned from PhoneProvider. + + Some phone providers allows to track status of call or gather pressed digits (we use it to ack/resolve alert group). + It is needed to link phone call and alert group without exposing internals of concrete phone provider to PhoneBackend. + """ + + class Meta: + abstract = True + + phone_call_record = models.OneToOneField( + "phone_notifications.PhoneCallRecord", + on_delete=models.CASCADE, + related_name="%(app_label)s_%(class)s_related", + related_query_name="%(app_label)s_%(class)ss", + null=False, + ) + + def link_and_save(self, phone_call_record: PhoneCallRecord): + self.phone_call_record = phone_call_record + self.save() diff --git a/engine/apps/phone_notifications/models/sms.py b/engine/apps/phone_notifications/models/sms.py new file mode 100644 index 00000000..4bad9eb4 --- /dev/null +++ b/engine/apps/phone_notifications/models/sms.py @@ -0,0 +1,87 @@ +from django.db import models + + +# Duplicate to avoid circular import to provide values for status field +class TwilioSMSstatuses: + """ + https://www.twilio.com/docs/sms/tutorials/how-to-confirm-delivery-python?code-sample=code-handle-a-sms-statuscallback&code-language=Python&code-sdk-version=5.x#receive-status-events-in-your-web-application + https://www.twilio.com/docs/sms/api/message-resource#message-status-values + """ + + ACCEPTED = 10 + QUEUED = 20 + SENDING = 30 + SENT = 40 + FAILED = 50 + DELIVERED = 60 + UNDELIVERED = 70 + RECEIVING = 80 + RECEIVED = 90 + READ = 100 + + CHOICES = ( + (ACCEPTED, "accepted"), + (QUEUED, "queued"), + (SENDING, "sending"), + (SENT, "sent"), + (FAILED, "failed"), + (DELIVERED, "delivered"), + (UNDELIVERED, "undelivered"), + (RECEIVING, "receiving"), + (RECEIVED, "received"), + (READ, "read"), + ) + + +class SMSRecord(models.Model): + class Meta: + db_table = "twilioapp_smsmessage" + + exceeded_limit = models.BooleanField(null=True, default=None) + represents_alert = models.ForeignKey( + "alerts.Alert", on_delete=models.SET_NULL, null=True, default=None + ) # deprecated + represents_alert_group = models.ForeignKey("alerts.AlertGroup", on_delete=models.SET_NULL, null=True, default=None) + notification_policy = models.ForeignKey( + "base.UserNotificationPolicy", on_delete=models.SET_NULL, null=True, default=None + ) + + receiver = models.ForeignKey("user_management.User", on_delete=models.CASCADE, null=True, default=None) + grafana_cloud_notification = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + # deprecated. It's here for backward compatibility for sms sent during or shortly before migration. + # Should be removed soon after migration + status = models.PositiveSmallIntegerField( + blank=True, + null=True, + choices=TwilioSMSstatuses.CHOICES, + ) + + sid = models.CharField( + blank=True, + max_length=50, + ) + + +class ProviderSMS(models.Model): + """ + ProviderSMS is an interface between SMSRecord and call data returned from PhoneProvider. + + The idea is same as for ProviderCall - to save provider specific data without exposing them to ProheBackend. + """ + + class Meta: + abstract = True + + sms_record = models.OneToOneField( + "phone_notifications.SMSRecord", + on_delete=models.CASCADE, + related_name="%(app_label)s_%(class)s_related", + related_query_name="%(app_label)s_%(class)ss", + null=False, + ) + + def link_and_save(self, sms_record: SMSRecord): + self.sms_record = sms_record + self.save() diff --git a/engine/apps/phone_notifications/phone_backend.py b/engine/apps/phone_notifications/phone_backend.py new file mode 100644 index 00000000..db070e61 --- /dev/null +++ b/engine/apps/phone_notifications/phone_backend.py @@ -0,0 +1,399 @@ +import logging +from typing import Optional + +import requests +from django.apps import apps +from django.conf import settings + +from apps.alerts.incident_appearance.renderers.phone_call_renderer import AlertGroupPhoneCallRenderer +from apps.alerts.incident_appearance.renderers.sms_renderer import AlertGroupSmsRenderer +from apps.alerts.signals import user_notification_action_triggered_signal +from apps.base.utils import live_settings +from common.api_helpers.utils import create_engine_url +from common.utils import clean_markup + +from .exceptions import ( + CallsLimitExceeded, + FailedToMakeCall, + FailedToSendSMS, + NumberAlreadyVerified, + NumberNotVerified, + ProviderNotSupports, + SMSLimitExceeded, +) +from .models import PhoneCallRecord, ProviderPhoneCall, ProviderSMS, SMSRecord +from .phone_provider import PhoneProvider, get_phone_provider + +logger = logging.getLogger(__name__) + + +class PhoneBackend: + def __init__(self): + self.phone_provider: PhoneProvider = self._get_phone_provider() + + def _get_phone_provider(self) -> PhoneProvider: + # wrapper to simplify mocking + return get_phone_provider() + + def notify_by_call(self, user, alert_group, notification_policy): + """ + notify_by_call makes a notification call to a user using configured phone provider or cloud notifications. + It handles all business logic related to the call. + """ + UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") + log_record_error_code = None + + renderer = AlertGroupPhoneCallRenderer(alert_group) + message = renderer.render() + + record = PhoneCallRecord.objects.create( + represents_alert_group=alert_group, + receiver=user, + notification_policy=notification_policy, + exceeded_limit=False, + ) + + try: + if live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED and settings.IS_OPEN_SOURCE: + self._notify_by_cloud_call(user, message) + record.save() + else: + provider_call = self._notify_by_provider_call(user, message) + # it is important that record is saved here, so it is possible to execute link_and_save + record.save() + if provider_call: + provider_call.link_and_save(record) + except FailedToMakeCall: + log_record_error_code = UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_CALL + except ProviderNotSupports: + log_record_error_code = UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_CALL + except CallsLimitExceeded: + record.exceeded_limit = True + record.save() + log_record_error_code = UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_CALLS_LIMIT_EXCEEDED + except NumberNotVerified: + log_record_error_code = UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_NUMBER_IS_NOT_VERIFIED + + if log_record_error_code is not None: + log_record = UserNotificationPolicyLogRecord( + author=user, + type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, + notification_policy=notification_policy, + alert_group=alert_group, + notification_error_code=log_record_error_code, + notification_step=notification_policy.step if notification_policy else None, + notification_channel=notification_policy.notify_by if notification_policy else None, + ) + log_record.save() + user_notification_action_triggered_signal.send(sender=PhoneBackend.notify_by_call, log_record=log_record) + + def _notify_by_provider_call(self, user, message) -> Optional[ProviderPhoneCall]: + """ + _notify_by_provider_call makes a notification call using configured phone provider. + """ + if not self._validate_user_number(user): + raise NumberNotVerified + + calls_left = self._validate_phone_calls_left(user) + if calls_left <= 0: + raise CallsLimitExceeded + elif calls_left < 3: + message = self._add_call_limit_warning(calls_left, message) + return self.phone_provider.make_notification_call(user.verified_phone_number, message) + + def _notify_by_cloud_call(self, user, message): + """ + _notify_by_cloud_call makes a call using connected Grafana Cloud Instance. + This method should be used only in OSS instances. + """ + url = create_engine_url("api/v1/make_call", override_base=settings.GRAFANA_CLOUD_ONCALL_API_URL) + auth = {"Authorization": live_settings.GRAFANA_CLOUD_ONCALL_TOKEN} + data = { + "email": user.email, + "message": message, + } + try: + response = requests.post(url, headers=auth, data=data, timeout=5) + except requests.exceptions.RequestException as e: + logger.error(f"PhoneBackend._notify_by_cloud_call: request exception {str(e)}") + raise FailedToMakeCall + if response.status_code == 200: + logger.info("PhoneBackend._notify_by_cloud_call: OK") + elif response.status_code == 400 and response.json().get("error") == "limit-exceeded": + logger.info(f"PhoneBackend._notify_by_cloud_call: phone calls limit exceeded") + raise CallsLimitExceeded + elif response.status_code == 400 and response.json().get("error") == "number-not-verified": + logger.info(f"PhoneBackend._notify_by_cloud_call: cloud number not verified") + raise NumberNotVerified + elif response.status_code == 404: + logger.info(f"PhoneBackend._notify_by_cloud_call: user not found id={user.id} email={user.email}") + raise FailedToMakeCall + else: + logger.error(f"PhoneBackend._notify_by_cloud_call: unexpected response code {response.status_code}") + raise FailedToMakeCall + + def _add_call_limit_warning(self, calls_left, message): + return f"{message} {calls_left} phone calls left. Contact your admin." + + def _validate_phone_calls_left(self, user) -> int: + return user.organization.phone_calls_left(user) + + def notify_by_sms(self, user, alert_group, notification_policy): + """ + notify_by_sms sends a notification sms to a user using configured phone provider. + It handles business logic - limits, cloud notifications and UserNotificationPolicyLogRecord creation + SMS itself is handled by phone provider. + """ + + UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") + log_record_error_code = None + + renderer = AlertGroupSmsRenderer(alert_group) + message = renderer.render() + + record = SMSRecord( + represents_alert_group=alert_group, + receiver=user, + notification_policy=notification_policy, + exceeded_limit=False, + ) + + try: + if live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED and settings.IS_OPEN_SOURCE: + self._notify_by_cloud_sms(user, message) + record.save() + else: + provider_sms = self._notify_by_provider_sms(user, message) + record.save() + if provider_sms: + provider_sms.link_and_save(record) + except FailedToSendSMS: + log_record_error_code = UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_SEND_SMS + except ProviderNotSupports: + log_record_error_code = UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_SEND_SMS + except SMSLimitExceeded: + record.exceeded_limit = True + record.save() + log_record_error_code = UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_SMS_LIMIT_EXCEEDED + except NumberNotVerified: + log_record_error_code = UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_NUMBER_IS_NOT_VERIFIED + + if log_record_error_code is not None: + log_record = UserNotificationPolicyLogRecord( + author=user, + type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, + notification_policy=notification_policy, + alert_group=alert_group, + notification_error_code=log_record_error_code, + notification_step=notification_policy.step if notification_policy else None, + notification_channel=notification_policy.notify_by if notification_policy else None, + ) + log_record.save() + user_notification_action_triggered_signal.send(sender=PhoneBackend.notify_by_sms, log_record=log_record) + + def _notify_by_provider_sms(self, user, message) -> Optional[ProviderSMS]: + """ + _notify_by_provider_sms sends a notification sms using configured phone provider. + """ + if not self._validate_user_number(user): + raise NumberNotVerified + + sms_left = self._validate_sms_left(user) + if sms_left <= 0: + raise SMSLimitExceeded + elif sms_left < 3: + message = self._add_sms_limit_warning(sms_left, message) + return self.phone_provider.send_notification_sms(user.verified_phone_number, message) + + def _notify_by_cloud_sms(self, user, message): + """ + _notify_by_cloud_sms sends a sms using connected Grafana Cloud Instance. + This method is used only in OSS instances. + """ + url = create_engine_url("api/v1/send_sms", override_base=settings.GRAFANA_CLOUD_ONCALL_API_URL) + auth = {"Authorization": live_settings.GRAFANA_CLOUD_ONCALL_TOKEN} + data = { + "email": user.email, + "message": message, + } + try: + response = requests.post(url, headers=auth, data=data, timeout=5) + except requests.exceptions.RequestException as e: + logger.error(f"Unable to send SMS through cloud. Request exception {str(e)}") + raise FailedToSendSMS + if response.status_code == 200: + logger.info("Sent cloud sms successfully") + elif response.status_code == 400 and response.json().get("error") == "limit-exceeded": + raise SMSLimitExceeded + elif response.status_code == 400 and response.json().get("error") == "number-not-verified": + raise NumberNotVerified + elif response.status_code == 404: + # user not found + raise FailedToSendSMS + else: + raise FailedToSendSMS + + def _validate_sms_left(self, user) -> int: + return user.organization.sms_left(user) + + def _add_sms_limit_warning(self, calls_left, message): + return f"{message} {calls_left} sms left. Contact your admin." + + def _validate_user_number(self, user): + return user.verified_phone_number is not None + + # relay calls/sms from oss related code + def relay_oss_call(self, user, message): + """ + relay_oss_call make phone call received from oss instance. + Caller should handle exceptions raised by phone_provider.make_call. + + The difference between relay_oss_call and notify_by_call is that relay_oss_call uses phone_provider.make_call + to only make call, not track status, gather digits or create logs. + """ + if not self._validate_user_number(user): + raise NumberNotVerified + + calls_left = self._validate_phone_calls_left(user) + if calls_left <= 0: + PhoneCallRecord.objects.create( + receiver=user, + exceeded_limit=True, + grafana_cloud_notification=True, + ) + raise CallsLimitExceeded + elif calls_left < 3: + message = self._add_call_limit_warning(calls_left, message) + + # additional cleaning, since message come from api call and wasn't cleaned by our renderer + message = clean_markup(message).replace('"', "") + + self.phone_provider.make_call(message, user.verified_phone_number) + # create PhoneCallRecord to track limits for calls from oss instances + PhoneCallRecord.objects.create( + receiver=user, + exceeded_limit=False, + grafana_cloud_notification=True, + ) + + def relay_oss_sms(self, user, message): + """ + relay_oss_sms send sms received from oss instance. + Caller should handle exceptions raised by phone_provider.send_sms. + + The difference between relay_oss_sms and notify_by_sms is that relay_oss_call uses phone_provider.make_call + to only send, not track status or create logs. + """ + if not self._validate_user_number(user): + raise NumberNotVerified + + sms_left = self._validate_sms_left(user) + if sms_left <= 0: + SMSRecord.objects.create( + receiver=user, + exceeded_limit=True, + grafana_cloud_notification=True, + ) + raise SMSLimitExceeded + elif sms_left < 3: + message = self._add_sms_limit_warning(sms_left, message) + + self.phone_provider.send_sms(message, user.verified_phone_number) + SMSRecord.objects.create( + receiver=user, + exceeded_limit=False, + grafana_cloud_notification=True, + ) + + # Number verification related code + def send_verification_sms(self, user): + """ + send_verification_sms sends a verification code to a user. + Caller should handle exceptions raised by phone_provider.send_verification_sms. + """ + logger.info(f"PhoneBackend.send_verification_sms: start verification for user {user.id}") + if self._validate_user_number(user): + logger.info(f"PhoneBackend.send_verification_sms: number already verified for user {user.id}") + raise NumberAlreadyVerified + self.phone_provider.send_verification_sms(user.unverified_phone_number) + + def make_verification_call(self, user): + """ + make_verification_call makes a verification call to a user. + Caller should handle exceptions raised by phone_provider.make_verification_call + """ + logger.info(f"PhoneBackend.make_verification_call: start verification user_id={user.id}") + if self._validate_user_number(user): + logger.info(f"PhoneBackend.make_verification_call: number already verified user_id={user.id}") + raise NumberAlreadyVerified + self.phone_provider.make_verification_call(user.unverified_phone_number) + + def verify_phone_number(self, user, code) -> bool: + prev_number = user.verified_phone_number + new_number = self.phone_provider.finish_verification(user.unverified_phone_number, code) + if new_number: + user.save_verified_phone_number(new_number) + # TODO: move this to async task + if prev_number: + self._notify_disconnected_number(user, prev_number) + self._notify_connected_number(user) + logger.info(f"PhoneBackend.verify_phone_number: verified user_id={user.id}") + return True + else: + logger.info(f"PhoneBackend.verify_phone_number: verification failed user_id={user.id}") + return False + + def forget_number(self, user) -> bool: + prev_number = user.verified_phone_number + user.clear_phone_numbers() + if prev_number: + self._notify_disconnected_number(user, prev_number) + return True + return False + + def make_test_call(self, user): + """ + make_test_call makes a test call to user's verified phone number + Caller should handle exceptions raised by phone_provider.make_call. + """ + text = "It is a test call from Grafana OnCall" + if not user.verified_phone_number: + raise NumberNotVerified + self.phone_provider.make_call(user.verified_phone_number, text) + + def send_test_sms(self, user): + """ + send_test_sms sends a test sms to user's verified phone number + Caller should handle exceptions raised by phone_provider.send_sms. + """ + text = "It is a test sms from Grafana OnCall" + if not user.verified_phone_number: + raise NumberNotVerified + self.phone_provider.send_sms(user.verified_phone_number, text) + + def _notify_connected_number(self, user): + text = ( + f"This phone number has been connected to Grafana OnCall team" + f'"{user.organization.stack_slug}"\nYour Grafana OnCall <3' + ) + try: + if not user.verified_phone_number: + logger.error("PhoneBackend._notify_connected_number: number not verified") + return + self.phone_provider.send_sms(user.verified_phone_number, text) + except FailedToSendSMS: + logger.error("PhoneBackend._notify_connected_number: failed") + except ProviderNotSupports: + logger.info("PhoneBackend._notify_connected_number: provider not supports sms") + + def _notify_disconnected_number(self, user, number): + text = ( + f"This phone number has been disconnected from Grafana OnCall team" + f'"{user.organization.stack_slug}"\nYour Grafana OnCall <3' + ) + try: + self.phone_provider.send_sms(number, text) + except FailedToSendSMS: + logger.error("PhoneBackend._notify_disconnected_number: failed") + except ProviderNotSupports: + logger.info("PhoneBackend._notify_disconnected_number: provider not supports sms") diff --git a/engine/apps/phone_notifications/phone_provider.py b/engine/apps/phone_notifications/phone_provider.py new file mode 100644 index 00000000..68f47721 --- /dev/null +++ b/engine/apps/phone_notifications/phone_provider.py @@ -0,0 +1,174 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Optional + +from django.conf import settings +from django.utils.module_loading import import_string + +from apps.base.utils import live_settings +from apps.phone_notifications.exceptions import ProviderNotSupports +from apps.phone_notifications.models import ProviderPhoneCall, ProviderSMS + + +@dataclass +class ProviderFlags: + """ + ProviderFlags is set of feature flags enabled for concrete provider. + It is needed to show correct buttons in UI. + """ + + configured: bool # indicates if provider live settings are present and valid + test_sms: bool + test_call: bool + verification_call: bool + verification_sms: bool + + +class PhoneProvider(ABC): + """ + PhoneProvider is an interface to all phone providers. + It is needed to hide details of external phone providers from core code. + + New PhoneProviders should be added to settings.PHONE_PROVIDERS dict. + + For reference, you can check: + SimplePhoneProvider as example of tiny, but working provider. + TwilioPhoneProvider as example of complicated phone provider which supports status callbacks and gather actions. + """ + + def make_notification_call(self, number: str, text: str) -> Optional[ProviderPhoneCall]: + """ + make_notification_call makes a call to notify about alert group and optionally returns unsaved ProviderPhoneCall + instance. If returned, instance will be linked to PhoneCallRecord and saved by PhoneBackend. + Check ProviderPhoneCall doc for more info. + + If provider doesn't perform additional logic for notifications or doesn't save phone call data - wrap make_call: + def make_notification_call(self, number, text): + self.make_call(number, text) + + Args: + number: phone number to call + text: text of the call + Returns: + Unsaved ProviderPhoneCall instance to link to PhoneCallRecord or None if provider-specific data not stored. + + Raises: + FailedToMakeCall: if some exception in external provider happens. + ProviderNotSupports: if provider not supports calls (it's a valid use-case). + """ + raise ProviderNotSupports + + def send_notification_sms(self, number: str, message: str) -> Optional[ProviderSMS]: + """ + send_notification_sms sends a sms to notify about alert group. + + send_notification_sms sends a sms to notify about alert group and optionally returns unsaved ProviderSMS + instance. If returned, instance will be linked to SMSRecord and saved by PhoneBackend. + + You can just wrap send_sms if no additional logic is performed for notification sms: + + def send_notification_sms(self, number, text, phone_call_record): + self.send_sms(number, text) + + Args: + number: phone number to send sms + message: text of the sms + Returns: + Unsaved ProviderSMS instance to link to SMSRecord or None if provider-specific data not stored. + + Raises: + FailedToSendSMS: if some exception in external provider happens + ProviderNotSupports: if provider not supports sms (it's a valid use-case) + """ + raise ProviderNotSupports + + def make_call(self, number: str, text: str): + """ + make_call make a call with given text to given number. + + Args: + number: phone number to make a call + text: call text to deliver to user + + Raises: + FailedToMakeCall: if some exception in external provider happens + ProviderNotSupports: if provider not supports calls (it's a valid use-case) + """ + raise ProviderNotSupports + + def send_sms(self, number: str, text: str): + """ + send_sms sends an SMS to the specified phone number with the given text. + + Args: + number: phone number to send a sms + text: text to deliver to user + + Raises: + FailedToSendSMS: if some exception in external provider occurred + ProviderNotSupports: if provider not supports calls + + """ + raise ProviderNotSupports + + def send_verification_sms(self, number: str): + """ + send_verification_sms starts phone number verification by sending code via sms + + Args: + number: number to verify + + Raises: + FailedToStartVerification: if some exception in external provider occurred + ProviderNotSupports: if concrete provider not phone number verification via sms + """ + raise ProviderNotSupports + + def make_verification_call(self, number: str): + """ + make_verification_call starts phone number verification by calling to user + + Args: + number: number to verify + + Raises: + FailedToStartVerification: if some exception in external provider occurred + ProviderNotSupports: if concrete provider not phone number verification via call + """ + raise ProviderNotSupports + + def finish_verification(self, number: str, code: str) -> Optional[str]: + """ + finish_verification validates the verification code. + + Args: + number: number to verify + code: verification code + Returns: + verified phone number or None if code is invalid + + Raises: + FailedToFinishVerification: when some exception in external service occurred + ProviderNotSupports: if concrete provider not supports number verification + """ + raise ProviderNotSupports + + @property + @abstractmethod + def flags(self) -> ProviderFlags: + """ + flags returns ProviderFlags instance to control web UI + """ + raise NotImplementedError + + +_providers = {} + + +def get_phone_provider() -> PhoneProvider: + global _providers + # load all providers in memory on first call + if len(_providers) == 0: + for provider_alias, importpath in settings.PHONE_PROVIDERS.items(): + _providers[provider_alias] = import_string(importpath)() + return _providers[live_settings.PHONE_PROVIDER] diff --git a/engine/apps/phone_notifications/simple_phone_provider.py b/engine/apps/phone_notifications/simple_phone_provider.py new file mode 100644 index 00000000..f6d0df03 --- /dev/null +++ b/engine/apps/phone_notifications/simple_phone_provider.py @@ -0,0 +1,43 @@ +from random import randint + +from django.core.cache import cache + +from .phone_provider import PhoneProvider, ProviderFlags + + +class SimplePhoneProvider(PhoneProvider): + """ + SimplePhoneProvider is an example of phone provider which supports only SMS messages. + It is not intended for real-life usage and needed only as example of PhoneProviders suitable to use ONLY in OSS. + """ + + def send_notification_sms(self, number, message): + self.send_sms(number, message) + + def send_sms(self, number, text): + print(f'SimplePhoneProvider.send_sms: send message "{text}" to {number}') + + def send_verification_sms(self, number): + code = str(randint(100000, 999999)) + cache.set(self._cache_key(number), code, timeout=10 * 60) + self.send_sms(number, f"Your verification code is {code}") + + def finish_verification(self, number, code): + has = cache.get(self._cache_key(number)) + if has is not None and has == code: + return number + else: + return None + + def _cache_key(self, number): + return f"simple_provider_{number}" + + @property + def flags(self) -> ProviderFlags: + return ProviderFlags( + configured=True, + test_sms=True, + test_call=False, + verification_call=False, + verification_sms=True, + ) diff --git a/engine/apps/phone_notifications/tests/__init__.py b/engine/apps/phone_notifications/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/engine/apps/phone_notifications/tests/factories.py b/engine/apps/phone_notifications/tests/factories.py new file mode 100644 index 00000000..d2904eb1 --- /dev/null +++ b/engine/apps/phone_notifications/tests/factories.py @@ -0,0 +1,13 @@ +import factory + +from apps.phone_notifications.models import PhoneCallRecord, SMSRecord + + +class PhoneCallRecordFactory(factory.DjangoModelFactory): + class Meta: + model = PhoneCallRecord + + +class SMSRecordFactory(factory.DjangoModelFactory): + class Meta: + model = SMSRecord diff --git a/engine/apps/phone_notifications/tests/mock_phone_provider.py b/engine/apps/phone_notifications/tests/mock_phone_provider.py new file mode 100644 index 00000000..b964fb6d --- /dev/null +++ b/engine/apps/phone_notifications/tests/mock_phone_provider.py @@ -0,0 +1,38 @@ +from apps.phone_notifications.phone_provider import PhoneProvider, ProviderFlags + + +class MockPhoneProvider(PhoneProvider): + """ + MockPhoneProvider exists only for tests, feel free to mock any method to imitate any use-case, exception, etc. + """ + + def make_notification_call(self, number: str, text: str): + pass + + def send_notification_sms(self, number: str, message: str): + pass + + def make_call(self, number: str, text: str): + pass + + def send_sms(self, number: str, text: str): + pass + + def send_verification_sms(self, number: str): + pass + + def make_verification_call(self, number: str): + pass + + def finish_verification(self, number: str, code: str): + pass + + @property + def flags(self) -> ProviderFlags: + return ProviderFlags( + configured=True, + test_sms=True, + test_call=True, + verification_call=True, + verification_sms=True, + ) diff --git a/engine/apps/phone_notifications/tests/test_phone_backend_call.py b/engine/apps/phone_notifications/tests/test_phone_backend_call.py new file mode 100644 index 00000000..cab9e406 --- /dev/null +++ b/engine/apps/phone_notifications/tests/test_phone_backend_call.py @@ -0,0 +1,227 @@ +from unittest import mock + +import pytest +from django.test import override_settings + +from apps.base.models import UserNotificationPolicy, UserNotificationPolicyLogRecord +from apps.phone_notifications.exceptions import ( + CallsLimitExceeded, + FailedToMakeCall, + NumberNotVerified, + ProviderNotSupports, +) +from apps.phone_notifications.models import PhoneCallRecord +from apps.phone_notifications.phone_backend import PhoneBackend + +notify = UserNotificationPolicy.Step.NOTIFY +notify_by_phone = 2 + + +@pytest.fixture() +def setup( + make_organization_and_user, make_alert_receive_channel, make_alert_group, make_alert, make_user_notification_policy +): + org, user = make_organization_and_user() + arc = make_alert_receive_channel(org) + alert_group = make_alert_group(arc) + make_alert(alert_group, {}) + notification_policy = make_user_notification_policy( + user, UserNotificationPolicy.Step.NOTIFY, notify_by=notify_by_phone + ) + + return user, alert_group, notification_policy + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._notify_by_provider_call") +@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=False) +def test_notify_by_call_uses_provider(mock_notify_by_provider_call, setup): + """ + test if make_provider_call called when GRAFANA_CLOUD_NOTIFICATIONS_ENABLED is False + """ + user, alert_group, notification_policy = setup + + phone_backend = PhoneBackend() + phone_backend.notify_by_call(user, alert_group, notification_policy) + + assert mock_notify_by_provider_call.called + assert ( + PhoneCallRecord.objects.filter( + exceeded_limit=False, + represents_alert_group=alert_group, + notification_policy=notification_policy, + receiver=user, + grafana_cloud_notification=False, + ).count() + == 1 + ) + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._notify_by_cloud_call") +@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=True) +def test_notify_by_call_uses_cloud(mock_notify_by_cloud_call, setup): + """ + test if notify_by_cloud_call called when GRAFANA_CLOUD_NOTIFICATIONS_ENABLED is True + """ + user, alert_group, notification_policy = setup + + phone_backend = PhoneBackend() + phone_backend.notify_by_call(user, alert_group, notification_policy) + + assert mock_notify_by_cloud_call.called + assert ( + PhoneCallRecord.objects.filter( + exceeded_limit=False, + represents_alert_group=alert_group, + notification_policy=notification_policy, + receiver=user, + grafana_cloud_notification=False, + ).count() + == 1 + ) + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=False) +@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=False) +def test_notify_by_provider_call_raises_number_not_verified( + mock_validate_user_number, + make_organization_and_user, +): + _, user = make_organization_and_user() + phone_backend = PhoneBackend() + + with pytest.raises(NumberNotVerified): + phone_backend._notify_by_provider_call(user, "some_message") + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=True) +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_phone_calls_left", return_value=0) +@mock.patch("apps.phone_notifications.tests.mock_phone_provider.MockPhoneProvider.make_notification_call") +@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=False) +def test_notify_by_provider_call_rases_limit_exceeded( + mock_make_notification_call, + mock_phone_calls_left, + mock_validate_user_number, + make_organization_and_user, +): + """ + test if CallsLimitExceeded raised when phone notifications limit is empty + """ + _, user = make_organization_and_user() + phone_backend = PhoneBackend() + + with pytest.raises(CallsLimitExceeded): + phone_backend._notify_by_provider_call(user, "some_message") + assert mock_make_notification_call.called is False + assert PhoneCallRecord.objects.all().count() == 0 + + +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=True) +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_phone_calls_left", return_value=2) +@mock.patch( + "apps.phone_notifications.phone_backend.PhoneBackend._add_call_limit_warning", return_value="mock warning value" +) +@mock.patch("apps.phone_notifications.tests.mock_phone_provider.MockPhoneProvider.make_notification_call") +@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=False) +@pytest.mark.django_db +def test_notify_by_provider_call_limits_warning( + mock_make_notification_call, + mock_add_call_limit_warning, + mock_validate_phone_calls_left, + mock_validate_user_number, + make_organization_and_user, +): + """ + test if warning message added to call message, when almost no phone notifications left + """ + _, user = make_organization_and_user() + phone_backend = PhoneBackend() + phone_backend._notify_by_provider_call(user, "some_message") + + assert mock_add_call_limit_warning.called_once_with(2, "some_message") + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._notify_by_provider_call") +@pytest.mark.parametrize( + "exc,log_err_code", + [ + (NumberNotVerified, UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_NUMBER_IS_NOT_VERIFIED), + (CallsLimitExceeded, UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_CALLS_LIMIT_EXCEEDED), + (FailedToMakeCall, UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_CALL), + (ProviderNotSupports, UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_CALL), + ], +) +@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=False) +def test_notify_by_call_handles_exceptions_from_provider( + mock_notify_by_provider_call, + setup, + exc, + log_err_code, +): + """ + test if UserNotificationPolicyLogRecord is created when exception is raised from _notify_by_provider_call. + _notify_by_provider_call is mocked to raise exceptions which may occur while checking if phone call possible to male and + exceptions from phone_provider also + """ + user, alert_group, notification_policy = setup + mock_notify_by_provider_call.side_effect = exc + + phone_backend = PhoneBackend() + phone_backend.notify_by_call(user, alert_group, notification_policy) + + assert ( + UserNotificationPolicyLogRecord.objects.filter( + author=user, + type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, + notification_policy=notification_policy, + alert_group=alert_group, + notification_error_code=log_err_code, + notification_step=notification_policy.step, + notification_channel=notification_policy.notify_by, + ).count() + == 1 + ) + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._notify_by_cloud_call") +@pytest.mark.parametrize( + "exc,log_err_code", + [ + (FailedToMakeCall, UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_CALL), + (NumberNotVerified, UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_NUMBER_IS_NOT_VERIFIED), + (CallsLimitExceeded, UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_CALLS_LIMIT_EXCEEDED), + ], +) +@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=True) +def test_notify_by_cloud_call_handles_exceptions_from_cloud( + mock_notify_by_cloud_call, + setup, + exc, + log_err_code, +): + """ + test if UserNotificationPolicyLogRecord is created when exception is raised from _notify_by_cloud_call + """ + user, alert_group, notification_policy = setup + mock_notify_by_cloud_call.side_effect = exc + + phone_backend = PhoneBackend() + phone_backend.notify_by_call(user, alert_group, notification_policy) + + assert ( + UserNotificationPolicyLogRecord.objects.filter( + author=user, + type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, + notification_policy=notification_policy, + alert_group=alert_group, + notification_error_code=log_err_code, + notification_step=notification_policy.step, + notification_channel=notification_policy.notify_by, + ).count() + == 1 + ) diff --git a/engine/apps/phone_notifications/tests/test_phone_backend_oss_relay.py b/engine/apps/phone_notifications/tests/test_phone_backend_oss_relay.py new file mode 100644 index 00000000..4bacca06 --- /dev/null +++ b/engine/apps/phone_notifications/tests/test_phone_backend_oss_relay.py @@ -0,0 +1,111 @@ +from unittest import mock + +import pytest + +from apps.phone_notifications.exceptions import CallsLimitExceeded, NumberNotVerified, SMSLimitExceeded +from apps.phone_notifications.phone_backend import PhoneBackend +from apps.phone_notifications.tests.mock_phone_provider import MockPhoneProvider + + +@pytest.fixture(autouse=True) +def mock_phone_provider(monkeypatch): + def mock_get_provider(*args, **kwargs): + return MockPhoneProvider() + + monkeypatch.setattr(PhoneBackend, "_get_phone_provider", mock_get_provider) + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=True) +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_phone_calls_left", return_value=10) +@mock.patch("apps.phone_notifications.tests.mock_phone_provider.MockPhoneProvider.make_call") +def test_relay_oss_call( + mock_make_call, + mock_validate_user_number, + mock_phone_calls_left, + make_organization_and_user, +): + _, user = make_organization_and_user() + phone_backend = PhoneBackend() + phone_backend.relay_oss_call(user, "relayed_call") + assert mock_make_call.called + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=False) +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_phone_calls_left", return_value=10) +@mock.patch("apps.phone_notifications.tests.mock_phone_provider.MockPhoneProvider.make_call") +def test_relay_oss_call_number_not_verified( + mock_make_call, + mock_validate_user_number, + mock_phone_calls_left, + make_organization_and_user, +): + _, user = make_organization_and_user() + phone_backend = PhoneBackend() + with pytest.raises(NumberNotVerified): + phone_backend.relay_oss_call(user, "relayed_call") + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=True) +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_phone_calls_left", return_value=0) +@mock.patch("apps.phone_notifications.tests.mock_phone_provider.MockPhoneProvider.make_call") +def test_relay_oss_call_limit_exceed( + mock_make_call, + mock_validate_user_number, + mock_phone_calls_left, + make_organization_and_user, +): + _, user = make_organization_and_user() + phone_backend = PhoneBackend() + with pytest.raises(CallsLimitExceeded): + phone_backend.relay_oss_call(user, "relayed_call") + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=True) +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_sms_left", return_value=10) +@mock.patch("apps.phone_notifications.tests.mock_phone_provider.MockPhoneProvider.make_call") +def test_relay_oss_sms( + mock_send_sms, + mock_validate_user_number, + mock_sms_left, + make_organization_and_user, +): + _, user = make_organization_and_user() + phone_backend = PhoneBackend() + phone_backend.relay_oss_call(user, "relayed_call") + assert mock_send_sms.called + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=False) +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_sms_left", return_value=10) +@mock.patch("apps.phone_notifications.tests.mock_phone_provider.MockPhoneProvider.send_sms") +def test_relay_oss_sms_number_not_verified( + mock_send_sms, + mock_validate_user_number, + mock_sms_left, + make_organization_and_user, +): + _, user = make_organization_and_user() + phone_backend = PhoneBackend() + with pytest.raises(NumberNotVerified): + phone_backend.relay_oss_sms(user, "relayed_sms") + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=True) +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_sms_left", return_value=0) +@mock.patch("apps.phone_notifications.tests.mock_phone_provider.MockPhoneProvider.send_sms") +def test_relay_oss_sms_limit_exceed( + mock_send_sms, + mock_validate_user_number, + mock_sms_left, + make_organization_and_user, +): + _, user = make_organization_and_user() + phone_backend = PhoneBackend() + with pytest.raises(SMSLimitExceeded): + phone_backend.relay_oss_sms(user, "relayed_sms") diff --git a/engine/apps/phone_notifications/tests/test_phone_backend_phone_verification.py b/engine/apps/phone_notifications/tests/test_phone_backend_phone_verification.py new file mode 100644 index 00000000..cfb8996c --- /dev/null +++ b/engine/apps/phone_notifications/tests/test_phone_backend_phone_verification.py @@ -0,0 +1,69 @@ +from unittest import mock + +import pytest + +from apps.phone_notifications.exceptions import NumberAlreadyVerified +from apps.phone_notifications.phone_backend import PhoneBackend +from apps.phone_notifications.tests.mock_phone_provider import MockPhoneProvider + + +@pytest.fixture(autouse=True) +def mock_phone_provider(monkeypatch): + def mock_get_provider(*args, **kwargs): + return MockPhoneProvider() + + monkeypatch.setattr(PhoneBackend, "_get_phone_provider", mock_get_provider) + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=False) +@mock.patch("apps.phone_notifications.tests.mock_phone_provider.MockPhoneProvider.send_verification_sms") +def test_send_verification_sms(mock_send_verification_sms, mock_validate_user_number, make_organization_and_user): + _, user = make_organization_and_user() + phone_backend = PhoneBackend() + + number_to_verify = "+1234567890" + user.unverified_phone_number = "+1234567890" + phone_backend.send_verification_sms(user) + mock_send_verification_sms.assert_called_once_with(number_to_verify) + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=True) +@mock.patch("apps.phone_notifications.tests.mock_phone_provider.MockPhoneProvider.send_verification_sms") +def test_send_verification_sms_raises_when_number_verified( + mock_send_verification_sms, mock__validate_user_number, make_organization_and_user +): + _, user = make_organization_and_user() + phone_backend = PhoneBackend() + + user.save_verified_phone_number("+1234567890") + with pytest.raises(NumberAlreadyVerified): + phone_backend.send_verification_sms(user) + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=False) +@mock.patch("apps.phone_notifications.tests.mock_phone_provider.MockPhoneProvider.make_verification_call") +def test_make_verification_call(mock_make_verification_call, mock_validate_user_number, make_organization_and_user): + _, user = make_organization_and_user() + phone_backend = PhoneBackend() + + number_to_verify = "+1234567890" + user.unverified_phone_number = "+1234567890" + phone_backend.make_verification_call(user) + mock_make_verification_call.assert_called_once_with(number_to_verify) + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=True) +@mock.patch("apps.phone_notifications.tests.mock_phone_provider.MockPhoneProvider.make_verification_call") +def test_make_verification_call_raises_when_number_verified( + mock_make_verification_call, mock__validate_user_number, make_organization_and_user +): + _, user = make_organization_and_user() + phone_backend = PhoneBackend() + + user.save_verified_phone_number("+1234567890") + with pytest.raises(NumberAlreadyVerified): + phone_backend.make_verification_call(user) diff --git a/engine/apps/phone_notifications/tests/test_phone_backend_sms.py b/engine/apps/phone_notifications/tests/test_phone_backend_sms.py new file mode 100644 index 00000000..ec414ad5 --- /dev/null +++ b/engine/apps/phone_notifications/tests/test_phone_backend_sms.py @@ -0,0 +1,236 @@ +from unittest import mock + +import pytest +from django.test import override_settings + +from apps.base.models import UserNotificationPolicy, UserNotificationPolicyLogRecord +from apps.phone_notifications.exceptions import ( + FailedToSendSMS, + NumberNotVerified, + ProviderNotSupports, + SMSLimitExceeded, +) +from apps.phone_notifications.models import SMSRecord +from apps.phone_notifications.phone_backend import PhoneBackend +from apps.phone_notifications.tests.mock_phone_provider import MockPhoneProvider + +notify = UserNotificationPolicy.Step.NOTIFY +notify_by_phone = 2 + + +@pytest.fixture() +def setup( + make_organization_and_user, make_alert_receive_channel, make_alert_group, make_alert, make_user_notification_policy +): + org, user = make_organization_and_user() + arc = make_alert_receive_channel(org) + alert_group = make_alert_group(arc) + make_alert(alert_group, {}) + notification_policy = make_user_notification_policy( + user, UserNotificationPolicy.Step.NOTIFY, notify_by=notify_by_phone + ) + + return user, alert_group, notification_policy + + +@pytest.fixture(autouse=True) +def mock_phone_provider(monkeypatch): + def mock_get_provider(*args, **kwargs): + return MockPhoneProvider() + + monkeypatch.setattr(PhoneBackend, "_get_phone_provider", mock_get_provider) + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._notify_by_provider_sms") +@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=False) +def test_notify_by_sms_uses_provider(mock_notify_by_provider_sms, setup): + """ + test if _notify_by_provider_sms called when GRAFANA_CLOUD_NOTIFICATIONS_ENABLED is False + """ + user, alert_group, notification_policy = setup + + phone_backend = PhoneBackend() + phone_backend.notify_by_sms(user, alert_group, notification_policy) + + assert mock_notify_by_provider_sms.called + assert ( + SMSRecord.objects.filter( + exceeded_limit=False, + represents_alert_group=alert_group, + notification_policy=notification_policy, + receiver=user, + grafana_cloud_notification=False, + ).count() + == 1 + ) + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._notify_by_cloud_sms") +@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=True) +def test_notify_by_sms_uses_cloud(mock_notify_by_cloud_sms, setup): + """ + test if notify_by_cloud_sms called when GRAFANA_CLOUD_NOTIFICATIONS_ENABLED is True + """ + user, alert_group, notification_policy = setup + + phone_backend = PhoneBackend() + phone_backend.notify_by_sms(user, alert_group, notification_policy) + + assert mock_notify_by_cloud_sms.called + assert ( + SMSRecord.objects.filter( + exceeded_limit=False, + represents_alert_group=alert_group, + notification_policy=notification_policy, + receiver=user, + grafana_cloud_notification=False, + ).count() + == 1 + ) + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=False) +@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=False) +def test_notify_by_provider_sms_raises_number_not_verified( + mock_validate_user_number, + make_organization_and_user, +): + _, user = make_organization_and_user() + phone_backend = PhoneBackend() + + with pytest.raises(NumberNotVerified): + phone_backend._notify_by_provider_sms(user, "some_message") + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=True) +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_sms_left", return_value=0) +@mock.patch("apps.phone_notifications.tests.mock_phone_provider.MockPhoneProvider.send_notification_sms") +@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=False) +def test_notify_by_provider_sms_raises_limit_exceeded( + mock_send_notification_sms, + mock_sms_left, + mock_validate_user_number, + make_organization_and_user, +): + """ + test if SMSLimitExceeded raised when phone notifications limit is empty + """ + _, user = make_organization_and_user() + phone_backend = PhoneBackend() + + with pytest.raises(SMSLimitExceeded): + phone_backend._notify_by_provider_sms(user, "some_message") + assert mock_send_notification_sms.called is False + assert SMSRecord.objects.all().count() == 0 + + +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_user_number", return_value=True) +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._validate_sms_left", return_value=2) +@mock.patch( + "apps.phone_notifications.phone_backend.PhoneBackend._add_sms_limit_warning", return_value="mock warning value" +) +@mock.patch("apps.phone_notifications.tests.mock_phone_provider.MockPhoneProvider.send_notification_sms") +@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=False) +@pytest.mark.django_db +def test_notify_by_provider_sms_limits_warning( + mock_send_notification_sms, + mock_add_sms_limit_warning, + mock_validate_phone_sms_left, + mock_validate_user_number, + make_organization_and_user, +): + """ + test if warning message added to message, when almost no phone notifications left + """ + _, user = make_organization_and_user() + phone_backend = PhoneBackend() + phone_backend._notify_by_provider_sms(user, "some_message") + + assert mock_add_sms_limit_warning.called_once_with(2, "some_message") + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._notify_by_provider_sms") +@pytest.mark.parametrize( + "exc,log_err_code", + [ + (NumberNotVerified, UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_NUMBER_IS_NOT_VERIFIED), + (SMSLimitExceeded, UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_SMS_LIMIT_EXCEEDED), + (FailedToSendSMS, UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_SEND_SMS), + (ProviderNotSupports, UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_SEND_SMS), + ], +) +@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=False) +def test_notify_by_sms_handles_exceptions_from_provider( + mock_notify_by_provider_sms, + setup, + exc, + log_err_code, +): + """ + test if UserNotificationPolicyLogRecord is created when exception is raised from _notify_by_provider_sms. + _notify_by_provider_sms is mocked to raise exceptions which may occur while checking if it's possible to send sms and + exceptions from phone_provider + """ + user, alert_group, notification_policy = setup + mock_notify_by_provider_sms.side_effect = exc + + phone_backend = PhoneBackend() + phone_backend.notify_by_sms(user, alert_group, notification_policy) + + assert ( + UserNotificationPolicyLogRecord.objects.filter( + author=user, + type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, + notification_policy=notification_policy, + alert_group=alert_group, + notification_error_code=log_err_code, + notification_step=notification_policy.step, + notification_channel=notification_policy.notify_by, + ).count() + == 1 + ) + + +@pytest.mark.django_db +@mock.patch("apps.phone_notifications.phone_backend.PhoneBackend._notify_by_cloud_sms") +@pytest.mark.parametrize( + "exc,log_err_code", + [ + (FailedToSendSMS, UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_SEND_SMS), + (NumberNotVerified, UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_NUMBER_IS_NOT_VERIFIED), + (SMSLimitExceeded, UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_SMS_LIMIT_EXCEEDED), + ], +) +@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=True) +def test_notify_by_cloud_sms_handles_exceptions_from_cloud( + mock_notify_by_cloud_sms, + setup, + exc, + log_err_code, +): + """ + test if UserNotificationPolicyLogRecord is created when exception is raised from _notify_by_cloud_sms + """ + user, alert_group, notification_policy = setup + mock_notify_by_cloud_sms.side_effect = exc + + phone_backend = PhoneBackend() + phone_backend.notify_by_sms(user, alert_group, notification_policy) + + assert ( + UserNotificationPolicyLogRecord.objects.filter( + author=user, + type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, + notification_policy=notification_policy, + alert_group=alert_group, + notification_error_code=log_err_code, + notification_step=notification_policy.step, + notification_channel=notification_policy.notify_by, + ).count() + == 1 + ) diff --git a/engine/apps/public_api/views/phone_notifications.py b/engine/apps/public_api/views/phone_notifications.py index f9f96f74..d52558f2 100644 --- a/engine/apps/public_api/views/phone_notifications.py +++ b/engine/apps/public_api/views/phone_notifications.py @@ -4,11 +4,17 @@ from rest_framework 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) diff --git a/engine/apps/twilioapp/admin.py b/engine/apps/twilioapp/admin.py deleted file mode 100644 index c769ff5c..00000000 --- a/engine/apps/twilioapp/admin.py +++ /dev/null @@ -1,17 +0,0 @@ -from django.contrib import admin - -from common.admin import CustomModelAdmin - -from .models import SMSMessage, TwilioLogRecord - - -@admin.register(TwilioLogRecord) -class TwilioLogRecordAdmin(CustomModelAdmin): - list_display = ("id", "user", "phone_number", "type", "status", "succeed", "created_at") - list_filter = ("created_at", "type", "status", "succeed") - - -@admin.register(SMSMessage) -class SMSMessageAdmin(CustomModelAdmin): - list_display = ("id", "receiver", "represents_alert_group", "notification_policy", "created_at") - list_filter = ("created_at",) diff --git a/engine/apps/twilioapp/constants.py b/engine/apps/twilioapp/constants.py deleted file mode 100644 index 5785077e..00000000 --- a/engine/apps/twilioapp/constants.py +++ /dev/null @@ -1,108 +0,0 @@ -class TwilioMessageStatuses(object): - """ - https://www.twilio.com/docs/sms/tutorials/how-to-confirm-delivery-python?code-sample=code-handle-a-sms-statuscallback&code-language=Python&code-sdk-version=5.x#receive-status-events-in-your-web-application - https://www.twilio.com/docs/sms/api/message-resource#message-status-values - """ - - ACCEPTED = 10 - QUEUED = 20 - SENDING = 30 - SENT = 40 - FAILED = 50 - DELIVERED = 60 - UNDELIVERED = 70 - RECEIVING = 80 - RECEIVED = 90 - READ = 100 - - CHOICES = ( - (ACCEPTED, "accepted"), - (QUEUED, "queued"), - (SENDING, "sending"), - (SENT, "sent"), - (FAILED, "failed"), - (DELIVERED, "delivered"), - (UNDELIVERED, "undelivered"), - (RECEIVING, "receiving"), - (RECEIVED, "received"), - (READ, "read"), - ) - - DETERMINANT = { - "accepted": ACCEPTED, - "queued": QUEUED, - "sending": SENDING, - "sent": SENT, - "failed": FAILED, - "delivered": DELIVERED, - "undelivered": UNDELIVERED, - "receiving": RECEIVING, - "received": RECEIVED, - "read": READ, - } - - -class TwilioCallStatuses(object): - """ - https://www.twilio.com/docs/voice/twiml#callstatus-values - """ - - QUEUED = 10 - RINGING = 20 - IN_PROGRESS = 30 - COMPLETED = 40 - BUSY = 50 - FAILED = 60 - NO_ANSWER = 70 - CANCELED = 80 - - CHOICES = ( - (QUEUED, "queued"), - (RINGING, "ringing"), - (IN_PROGRESS, "in-progress"), - (COMPLETED, "completed"), - (BUSY, "busy"), - (FAILED, "failed"), - (NO_ANSWER, "no-answer"), - (CANCELED, "canceled"), - ) - - DETERMINANT = { - "queued": QUEUED, - "ringing": RINGING, - "in-progress": IN_PROGRESS, - "completed": COMPLETED, - "busy": BUSY, - "failed": FAILED, - "no-answer": NO_ANSWER, - "canceled": CANCELED, - } - - -class TwilioLogRecordType(object): - VERIFICATION_START = 10 - VERIFICATION_CHECK = 20 - - CHOICES = ((VERIFICATION_START, "verification start"), (VERIFICATION_CHECK, "verification check")) - - -class TwilioLogRecordStatus(object): - # For verification and check it has used the same statuses - # https://www.twilio.com/docs/verify/api/verification#verification-response-properties - # https://www.twilio.com/docs/verify/api/verification-check - - PENDING = 10 - APPROVED = 20 - DENIED = 30 - # Our customized status for TwilioException - ERROR = 40 - - CHOICES = ((PENDING, "pending"), (APPROVED, "approved"), (DENIED, "denied"), (ERROR, "error")) - - DETERMINANT = {"pending": PENDING, "approved": APPROVED, "denied": DENIED, "error": ERROR} - - -TEST_CALL_TEXT = ( - "You are invited to check an incident from Grafana OnCall. " - "Alert via {channel_name} with title {alert_group_name} triggered {alerts_count} times" -) diff --git a/engine/apps/twilioapp/gather.py b/engine/apps/twilioapp/gather.py new file mode 100644 index 00000000..fffce9aa --- /dev/null +++ b/engine/apps/twilioapp/gather.py @@ -0,0 +1,79 @@ +from django.apps import apps +from django.urls import reverse +from twilio.twiml.voice_response import Gather, VoiceResponse + +from apps.alerts.constants import ActionSource +from apps.twilioapp.models import TwilioPhoneCall +from common.api_helpers.utils import create_engine_url + + +def process_gather_data(call_sid: str, digit: str) -> VoiceResponse: + """ + The function processes pressed digit at call time + + Args: + call_sid (str): + digit (str): user pressed digit + + Returns: + response (VoiceResponse) + """ + + response = VoiceResponse() + + if digit in ["1", "2", "3"]: + # Success case + response.say(f"You have pressed digit {digit}") + process_digit(call_sid, digit) + else: + # Error wrong digit pressing + gather = Gather(action=get_gather_url(), method="POST", num_digits=1) + + response.say("Wrong digit") + gather.say(get_gather_message()) + + response.append(gather) + + return response + + +def process_digit(call_sid, digit): + """ + The function get Phone Call instance according to call_sid + and run process of pressed digit + + Args: + call_sid (str): + digit (str): + + Returns: + + """ + if call_sid and digit: + twilio_phone_call = TwilioPhoneCall.objects.filter(sid=call_sid).first() + # Check twilio phone call and then oncall phone call for backward compatibility after PhoneCall migration. + # Will be removed soon. + if twilio_phone_call: + phone_call_record = twilio_phone_call.phone_call_record + else: + PhoneCallRecord = apps.get_model("phone_notifications", "PhoneCallRecord") + phone_call_record = PhoneCallRecord.objects.filter(sid=call_sid).first() + + if phone_call_record is not None: + alert_group = phone_call_record.represents_alert_group + user = phone_call_record.receiver + + if digit == "1": + alert_group.acknowledge_by_user(user, action_source=ActionSource.PHONE) + elif digit == "2": + alert_group.resolve_by_user(user, action_source=ActionSource.PHONE) + elif digit == "3": + alert_group.silence_by_user(user, silence_delay=1800, action_source=ActionSource.PHONE) + + +def get_gather_url(): + return create_engine_url(reverse("twilioapp:gather")) + + +def get_gather_message(): + return "Press 1 to acknowledge, 2 to resolve, 3 to silence to 30 minutes" diff --git a/engine/apps/twilioapp/migrations/0003_auto_20230408_0711.py b/engine/apps/twilioapp/migrations/0003_auto_20230408_0711.py new file mode 100644 index 00000000..8329119b --- /dev/null +++ b/engine/apps/twilioapp/migrations/0003_auto_20230408_0711.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.18 on 2023-04-08 07:11 + +from django.db import migrations +import django_migration_linter as linter + + +class Migration(migrations.Migration): + + dependencies = [ + ('twilioapp', '0002_auto_20220604_1008'), + ] + + state_operations = [ + migrations.DeleteModel('PhoneCall'), + migrations.DeleteModel('SMSMessage') + ] + + operations = [ + migrations.SeparateDatabaseAndState( + state_operations=state_operations + ) + ] diff --git a/engine/apps/twilioapp/migrations/0004_twiliophonecall_twiliosms.py b/engine/apps/twilioapp/migrations/0004_twiliophonecall_twiliosms.py new file mode 100644 index 00000000..8be33dd1 --- /dev/null +++ b/engine/apps/twilioapp/migrations/0004_twiliophonecall_twiliosms.py @@ -0,0 +1,41 @@ +# Generated by Django 3.2.18 on 2023-05-24 03:54 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('phone_notifications', '0001_initial'), + ('twilioapp', '0003_auto_20230408_0711'), + ] + + operations = [ + migrations.CreateModel( + name='TwilioSMS', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.PositiveSmallIntegerField(blank=True, choices=[(10, 'accepted'), (20, 'queued'), (30, 'sending'), (40, 'sent'), (50, 'failed'), (60, 'delivered'), (70, 'undelivered'), (80, 'receiving'), (90, 'received'), (100, 'read')], null=True)), + ('sid', models.CharField(blank=True, max_length=50)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('sms_record', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='twilioapp_twiliosms_related', related_query_name='twilioapp_twiliosmss', to='phone_notifications.smsrecord')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='TwilioPhoneCall', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.PositiveSmallIntegerField(blank=True, choices=[(10, 'queued'), (20, 'ringing'), (30, 'in-progress'), (40, 'completed'), (50, 'busy'), (60, 'failed'), (70, 'no-answer'), (80, 'canceled')], null=True)), + ('sid', models.CharField(blank=True, max_length=50)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('phone_call_record', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='twilio_phone_call', to='phone_notifications.phonecallrecord')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/engine/apps/twilioapp/models/__init__.py b/engine/apps/twilioapp/models/__init__.py index b3d32d81..75450a50 100644 --- a/engine/apps/twilioapp/models/__init__.py +++ b/engine/apps/twilioapp/models/__init__.py @@ -1,3 +1,3 @@ -from .phone_call import PhoneCall # noqa: F401 -from .sms_message import SMSMessage # noqa: F401 from .twilio_log_record import TwilioLogRecord # noqa: F401 +from .twilio_phone_call import TwilioCallStatuses, TwilioPhoneCall # noqa: F401 +from .twilio_sms import TwilioSMS, TwilioSMSstatuses # noqa: F401 diff --git a/engine/apps/twilioapp/models/phone_call.py b/engine/apps/twilioapp/models/phone_call.py deleted file mode 100644 index b0db9f91..00000000 --- a/engine/apps/twilioapp/models/phone_call.py +++ /dev/null @@ -1,272 +0,0 @@ -import logging - -import requests -from django.apps import apps -from django.conf import settings -from django.db import models -from rest_framework import status -from twilio.base.exceptions import TwilioRestException - -from apps.alerts.constants import ActionSource -from apps.alerts.incident_appearance.renderers.phone_call_renderer import AlertGroupPhoneCallRenderer -from apps.alerts.signals import user_notification_action_triggered_signal -from apps.base.utils import live_settings -from apps.twilioapp.constants import TwilioCallStatuses -from apps.twilioapp.twilio_client import twilio_client -from common.api_helpers.utils import create_engine_url -from common.utils import clean_markup, escape_for_twilio_phone_call - -logger = logging.getLogger(__name__) - - -class PhoneCallManager(models.Manager): - def update_status(self, call_sid, call_status): - """The function checks existence of PhoneCall instance - according to call_sid and updates status on message_status - - Args: - call_sid (str): sid of Twilio call - call_status (str): new status - - Returns: - - """ - UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") - - if call_sid and call_status: - phone_call_qs = self.filter(sid=call_sid) - - status = TwilioCallStatuses.DETERMINANT.get(call_status) - - if phone_call_qs.exists() and status: - phone_call_qs.update(status=status) - phone_call = phone_call_qs.first() - if phone_call.grafana_cloud_notification: - # If call was made via grafana twilio it is don't needed to create logs on it's delivery status. - return - log_record = None - if status == TwilioCallStatuses.COMPLETED: - log_record = UserNotificationPolicyLogRecord( - author=phone_call.receiver, - type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_SUCCESS, - notification_policy=phone_call.notification_policy, - alert_group=phone_call.represents_alert_group, - notification_step=phone_call.notification_policy.step - if phone_call.notification_policy - else None, - notification_channel=phone_call.notification_policy.notify_by - if phone_call.notification_policy - else None, - ) - elif status in [TwilioCallStatuses.FAILED, TwilioCallStatuses.BUSY, TwilioCallStatuses.NO_ANSWER]: - log_record = UserNotificationPolicyLogRecord( - author=phone_call.receiver, - type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, - notification_policy=phone_call.notification_policy, - alert_group=phone_call.represents_alert_group, - notification_error_code=PhoneCall.get_error_code_by_twilio_status(status), - notification_step=phone_call.notification_policy.step - if phone_call.notification_policy - else None, - notification_channel=phone_call.notification_policy.notify_by - if phone_call.notification_policy - else None, - ) - - if log_record is not None: - log_record.save() - user_notification_action_triggered_signal.send( - sender=PhoneCall.objects.update_status, log_record=log_record - ) - - def get_and_process_digit(self, call_sid, digit): - """The function get Phone Call instance according to call_sid - and run process of pressed digit - - Args: - call_sid (str): - digit (str): - - Returns: - - """ - if call_sid and digit: - phone_call = self.filter(sid=call_sid).first() - - if phone_call: - phone_call.process_digit(digit=digit) - - -class PhoneCall(models.Model): - - objects = PhoneCallManager() - - exceeded_limit = models.BooleanField(null=True, default=None) - represents_alert = models.ForeignKey("alerts.Alert", on_delete=models.SET_NULL, null=True, default=None) - represents_alert_group = models.ForeignKey("alerts.AlertGroup", on_delete=models.SET_NULL, null=True, default=None) - notification_policy = models.ForeignKey( - "base.UserNotificationPolicy", on_delete=models.SET_NULL, null=True, default=None - ) - - receiver = models.ForeignKey("user_management.User", on_delete=models.CASCADE, null=True, default=None) - - status = models.PositiveSmallIntegerField( - blank=True, - null=True, - choices=TwilioCallStatuses.CHOICES, - ) - - sid = models.CharField( - blank=True, - max_length=50, - ) - - created_at = models.DateTimeField(auto_now_add=True) - - grafana_cloud_notification = models.BooleanField(default=False) - - class PhoneCallsLimitExceeded(Exception): - """Phone calls limit exceeded""" - - class PhoneNumberNotVerifiedError(Exception): - """Phone number is not verified""" - - class CloudSendError(Exception): - """Error making call through cloud""" - - def process_digit(self, digit): - """The function process pressed digit at time of call to user - - Args: - digit (str): - - Returns: - - """ - alert_group = self.represents_alert_group - - if digit == "1": - alert_group.acknowledge_by_user(self.receiver, action_source=ActionSource.TWILIO) - elif digit == "2": - alert_group.resolve_by_user(self.receiver, action_source=ActionSource.TWILIO) - elif digit == "3": - alert_group.silence_by_user(self.receiver, silence_delay=1800, action_source=ActionSource.TWILIO) - - @property - def created_for_slack(self): - return bool(self.represents_alert_group.slack_message) - - @classmethod - def _make_cloud_call(cls, user, message_body): - url = create_engine_url("api/v1/make_call", override_base=settings.GRAFANA_CLOUD_ONCALL_API_URL) - auth = {"Authorization": live_settings.GRAFANA_CLOUD_ONCALL_TOKEN} - data = { - "email": user.email, - "message": message_body, - } - try: - response = requests.post(url, headers=auth, data=data, timeout=5) - except requests.exceptions.RequestException as e: - logger.warning(f"Unable to make call through cloud. Request exception {str(e)}") - raise PhoneCall.CloudSendError("Unable to make call through cloud: request failed") - if response.status_code == status.HTTP_200_OK: - logger.info("Make cloud call successfully") - if response.status_code == status.HTTP_400_BAD_REQUEST and response.json().get("error") == "limit-exceeded": - raise PhoneCall.PhoneCallsLimitExceeded("Organization calls limit exceeded") - elif response.status_code == status.HTTP_404_NOT_FOUND: - raise PhoneCall.CloudSendError("Unable to make call through cloud: user not found") - else: - raise PhoneCall.CloudSendError("Unable to make call through cloud: server error") - - @classmethod - def make_call(cls, user, alert_group, notification_policy, is_cloud_notification=False): - UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") - log_record = None - renderer = AlertGroupPhoneCallRenderer(alert_group) - message_body = renderer.render() - try: - if is_cloud_notification: - cls._make_cloud_call(user, message_body) - else: - cls._make_call(user, message_body, alert_group=alert_group, notification_policy=notification_policy) - except (TwilioRestException, PhoneCall.CloudSendError): - log_record = UserNotificationPolicyLogRecord( - author=user, - type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, - notification_policy=notification_policy, - alert_group=alert_group, - notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_CALL, - notification_step=notification_policy.step if notification_policy else None, - notification_channel=notification_policy.notify_by if notification_policy else None, - ) - except PhoneCall.PhoneCallsLimitExceeded: - log_record = UserNotificationPolicyLogRecord( - author=user, - type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, - notification_policy=notification_policy, - alert_group=alert_group, - notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_CALLS_LIMIT_EXCEEDED, - notification_step=notification_policy.step if notification_policy else None, - notification_channel=notification_policy.notify_by if notification_policy else None, - ) - except PhoneCall.PhoneNumberNotVerifiedError: - log_record = UserNotificationPolicyLogRecord( - author=user, - type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, - notification_policy=notification_policy, - alert_group=alert_group, - notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_NUMBER_IS_NOT_VERIFIED, - notification_step=notification_policy.step if notification_policy else None, - notification_channel=notification_policy.notify_by if notification_policy else None, - ) - - if log_record is not None: - log_record.save() - user_notification_action_triggered_signal.send(sender=PhoneCall.make_call, log_record=log_record) - - @classmethod - def make_grafana_cloud_call(cls, user, message_body): - message_body = escape_for_twilio_phone_call(clean_markup(message_body)) - cls._make_call(user, message_body, grafana_cloud=True) - - @classmethod - def _make_call(cls, user, message_body, alert_group=None, notification_policy=None, grafana_cloud=False): - if not user.verified_phone_number: - raise PhoneCall.PhoneNumberNotVerifiedError("User phone number is not verified") - - phone_call = PhoneCall( - represents_alert_group=alert_group, - receiver=user, - notification_policy=notification_policy, - grafana_cloud_notification=grafana_cloud, - ) - phone_calls_left = user.organization.phone_calls_left(user) - - if phone_calls_left <= 0: - phone_call.exceeded_limit = True - phone_call.save() - raise PhoneCall.PhoneCallsLimitExceeded("Organization calls limit exceeded") - - phone_call.exceeded_limit = False - if phone_calls_left < 3: - message_body += " {} phone calls left. Contact your admin.".format(phone_calls_left) - - twilio_call = twilio_client.make_call(message_body, user.verified_phone_number, grafana_cloud=grafana_cloud) - if twilio_call.status and twilio_call.sid: - phone_call.status = TwilioCallStatuses.DETERMINANT.get(twilio_call.status, None) - phone_call.sid = twilio_call.sid - phone_call.save() - - return phone_call - - @staticmethod - def get_error_code_by_twilio_status(status): - UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") - - TWILIO_ERRORS_TO_ERROR_CODES_MAP = { - TwilioCallStatuses.BUSY: UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_CALL_LINE_BUSY, - TwilioCallStatuses.FAILED: UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_CALL_FAILED, - TwilioCallStatuses.NO_ANSWER: UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_CALL_NO_ANSWER, - } - - return TWILIO_ERRORS_TO_ERROR_CODES_MAP.get(status, None) diff --git a/engine/apps/twilioapp/models/sms_message.py b/engine/apps/twilioapp/models/sms_message.py deleted file mode 100644 index 55aea7e8..00000000 --- a/engine/apps/twilioapp/models/sms_message.py +++ /dev/null @@ -1,240 +0,0 @@ -import logging - -import requests -from django.apps import apps -from django.conf import settings -from django.db import models -from rest_framework import status -from twilio.base.exceptions import TwilioRestException - -from apps.alerts.incident_appearance.renderers.sms_renderer import AlertGroupSmsRenderer -from apps.alerts.signals import user_notification_action_triggered_signal -from apps.base.utils import live_settings -from apps.twilioapp.constants import TwilioMessageStatuses -from apps.twilioapp.twilio_client import twilio_client -from common.api_helpers.utils import create_engine_url -from common.utils import clean_markup - -logger = logging.getLogger(__name__) - - -class SMSMessageManager(models.Manager): - def update_status(self, message_sid, message_status): - """The function checks existence of SMSMessage - instance according to message_sid and updates status on - message_status - - Args: - message_sid (str): sid of Twilio message - message_status (str): new status - - Returns: - - """ - UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") - - if message_sid and message_status: - sms_message_qs = self.filter(sid=message_sid) - - status = TwilioMessageStatuses.DETERMINANT.get(message_status) - - if sms_message_qs.exists() and status: - sms_message_qs.update(status=status) - - sms_message = sms_message_qs.first() - if sms_message.grafana_cloud_notification: - # If sms was sent via grafana cloud notifications don't create logs on its delivery status. - return - log_record = None - - if status == TwilioMessageStatuses.DELIVERED: - log_record = UserNotificationPolicyLogRecord( - author=sms_message.receiver, - type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_SUCCESS, - notification_policy=sms_message.notification_policy, - alert_group=sms_message.represents_alert_group, - notification_step=sms_message.notification_policy.step - if sms_message.notification_policy - else None, - notification_channel=sms_message.notification_policy.notify_by - if sms_message.notification_policy - else None, - ) - elif status in [TwilioMessageStatuses.UNDELIVERED, TwilioMessageStatuses.FAILED]: - log_record = UserNotificationPolicyLogRecord( - author=sms_message.receiver, - type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, - notification_policy=sms_message.notification_policy, - alert_group=sms_message.represents_alert_group, - notification_error_code=sms_message.get_error_code_by_twilio_status(status), - notification_step=sms_message.notification_policy.step - if sms_message.notification_policy - else None, - notification_channel=sms_message.notification_policy.notify_by - if sms_message.notification_policy - else None, - ) - if log_record is not None: - log_record.save() - user_notification_action_triggered_signal.send( - sender=SMSMessage.objects.update_status, log_record=log_record - ) - - -class SMSMessage(models.Model): - objects = SMSMessageManager() - - exceeded_limit = models.BooleanField(null=True, default=None) - represents_alert = models.ForeignKey("alerts.Alert", on_delete=models.SET_NULL, null=True, default=None) - represents_alert_group = models.ForeignKey("alerts.AlertGroup", on_delete=models.SET_NULL, null=True, default=None) - notification_policy = models.ForeignKey( - "base.UserNotificationPolicy", on_delete=models.SET_NULL, null=True, default=None - ) - - receiver = models.ForeignKey("user_management.User", on_delete=models.CASCADE, null=True, default=None) - - status = models.PositiveSmallIntegerField( - blank=True, - null=True, - choices=TwilioMessageStatuses.CHOICES, - ) - grafana_cloud_notification = models.BooleanField(default=False) - - # https://www.twilio.com/docs/sms/api/message-resource#message-properties - sid = models.CharField( - blank=True, - max_length=50, - ) - - created_at = models.DateTimeField(auto_now_add=True) - - class SMSLimitExceeded(Exception): - """SMS limit exceeded""" - - class PhoneNumberNotVerifiedError(Exception): - """Phone number is not verified""" - - class CloudSendError(Exception): - """SMS sending through cloud error""" - - @property - def created_for_slack(self): - return bool(self.represents_alert_group.slack_message) - - @classmethod - def _send_cloud_sms(cls, user, message_body): - url = create_engine_url("api/v1/send_sms", override_base=settings.GRAFANA_CLOUD_ONCALL_API_URL) - auth = {"Authorization": live_settings.GRAFANA_CLOUD_ONCALL_TOKEN} - data = { - "email": user.email, - "message": message_body, - } - try: - response = requests.post(url, headers=auth, data=data, timeout=5) - except requests.exceptions.RequestException as e: - logger.warning(f"Unable to send SMS through cloud. Request exception {str(e)}") - raise SMSMessage.CloudSendError("Unable to send SMS through cloud: request failed") - if response.status_code == status.HTTP_200_OK: - logger.info("Sent cloud sms successfully") - elif response.status_code == status.HTTP_400_BAD_REQUEST and response.json().get("error") == "limit-exceeded": - raise SMSMessage.SMSLimitExceeded("Organization sms limit exceeded") - elif response.status_code == status.HTTP_404_NOT_FOUND: - raise SMSMessage.CloudSendError("Unable to send SMS through cloud: user not found") - else: - raise SMSMessage.CloudSendError("Unable to send SMS through cloud: server error") - - @classmethod - def send_sms(cls, user, alert_group, notification_policy, is_cloud_notification=False): - UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") - - log_record = None - renderer = AlertGroupSmsRenderer(alert_group) - message_body = renderer.render() - try: - if is_cloud_notification: - cls._send_cloud_sms(user, message_body) - else: - cls._send_sms(user, message_body, alert_group=alert_group, notification_policy=notification_policy) - except (TwilioRestException, SMSMessage.CloudSendError) as e: - logger.warning(f"Unable to send sms. Exception {e}") - log_record = UserNotificationPolicyLogRecord( - author=user, - type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, - notification_policy=notification_policy, - alert_group=alert_group, - notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_SEND_SMS, - notification_step=notification_policy.step if notification_policy else None, - notification_channel=notification_policy.notify_by if notification_policy else None, - ) - except SMSMessage.SMSLimitExceeded as e: - logger.warning(f"Unable to send sms. Exception {e}") - log_record = UserNotificationPolicyLogRecord( - author=user, - type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, - notification_policy=notification_policy, - alert_group=alert_group, - notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_SMS_LIMIT_EXCEEDED, - notification_step=notification_policy.step if notification_policy else None, - notification_channel=notification_policy.notify_by if notification_policy else None, - ) - except SMSMessage.PhoneNumberNotVerifiedError as e: - logger.warning(f"Unable to send sms. Exception {e}") - log_record = UserNotificationPolicyLogRecord( - author=user, - type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, - notification_policy=notification_policy, - alert_group=alert_group, - notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_NUMBER_IS_NOT_VERIFIED, - notification_step=notification_policy.step if notification_policy else None, - notification_channel=notification_policy.notify_by if notification_policy else None, - ) - - if log_record is not None: - log_record.save() - user_notification_action_triggered_signal.send(sender=SMSMessage.send_sms, log_record=log_record) - - @classmethod - def send_grafana_cloud_sms(cls, user, message_body): - message_body = clean_markup(message_body) - cls._send_sms(user, message_body, grafana_cloud=True) - - @classmethod - def _send_sms(cls, user, message_body, alert_group=None, notification_policy=None, grafana_cloud=False): - if not user.verified_phone_number: - raise SMSMessage.PhoneNumberNotVerifiedError("User phone number is not verified") - - sms_message = SMSMessage( - represents_alert_group=alert_group, - receiver=user, - notification_policy=notification_policy, - grafana_cloud_notification=grafana_cloud, - ) - sms_left = user.organization.sms_left(user) - - if sms_left <= 0: - sms_message.exceeded_limit = True - sms_message.save() - raise SMSMessage.SMSLimitExceeded("Organization sms limit exceeded") - - sms_message.exceeded_limit = False - if sms_left < 3: - message_body += " {} sms left. Contact your admin.".format(sms_left) - - twilio_message = twilio_client.send_message(message_body, user.verified_phone_number) - if twilio_message.status and twilio_message.sid: - sms_message.status = TwilioMessageStatuses.DETERMINANT.get(twilio_message.status, None) - sms_message.sid = twilio_message.sid - sms_message.save() - - return sms_message - - @staticmethod - def get_error_code_by_twilio_status(status): - UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") - - TWILIO_ERRORS_TO_ERROR_CODES_MAP = { - TwilioMessageStatuses.UNDELIVERED: UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_SMS_DELIVERY_FAILED, - TwilioMessageStatuses.FAILED: UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_SMS_DELIVERY_FAILED, - } - - return TWILIO_ERRORS_TO_ERROR_CODES_MAP.get(status, None) diff --git a/engine/apps/twilioapp/models/twilio_log_record.py b/engine/apps/twilioapp/models/twilio_log_record.py index f4530b5d..bec915c2 100644 --- a/engine/apps/twilioapp/models/twilio_log_record.py +++ b/engine/apps/twilioapp/models/twilio_log_record.py @@ -1,8 +1,30 @@ from django.db import models -from apps.twilioapp.constants import TwilioLogRecordStatus, TwilioLogRecordType + +class TwilioLogRecordType(object): + VERIFICATION_START = 10 + VERIFICATION_CHECK = 20 + + CHOICES = ((VERIFICATION_START, "verification start"), (VERIFICATION_CHECK, "verification check")) +class TwilioLogRecordStatus(object): + # For verification and check it has used the same statuses + # https://www.twilio.com/docs/verify/api/verification#verification-response-properties + # https://www.twilio.com/docs/verify/api/verification-check + + PENDING = 10 + APPROVED = 20 + DENIED = 30 + # Our customized status for TwilioException + ERROR = 40 + + CHOICES = ((PENDING, "pending"), (APPROVED, "approved"), (DENIED, "denied"), (ERROR, "error")) + + DETERMINANT = {"pending": PENDING, "approved": APPROVED, "denied": DENIED, "error": ERROR} + + +# Deprecated model. Kept here for backward compatibility, should be removed after phone notificator release class TwilioLogRecord(models.Model): user = models.ForeignKey("user_management.User", on_delete=models.CASCADE) diff --git a/engine/apps/twilioapp/models/twilio_phone_call.py b/engine/apps/twilioapp/models/twilio_phone_call.py new file mode 100644 index 00000000..4b4423eb --- /dev/null +++ b/engine/apps/twilioapp/models/twilio_phone_call.py @@ -0,0 +1,72 @@ +import logging + +from django.db import models + +from apps.phone_notifications.models import PhoneCallRecord +from apps.phone_notifications.phone_provider import ProviderPhoneCall + +logger = logging.getLogger(__name__) + + +class TwilioCallStatuses: + """ + https://www.twilio.com/docs/voice/twiml#callstatus-values + """ + + QUEUED = 10 + RINGING = 20 + IN_PROGRESS = 30 + COMPLETED = 40 + BUSY = 50 + FAILED = 60 + NO_ANSWER = 70 + CANCELED = 80 + + CHOICES = ( + (QUEUED, "queued"), + (RINGING, "ringing"), + (IN_PROGRESS, "in-progress"), + (COMPLETED, "completed"), + (BUSY, "busy"), + (FAILED, "failed"), + (NO_ANSWER, "no-answer"), + (CANCELED, "canceled"), + ) + + DETERMINANT = { + "queued": QUEUED, + "ringing": RINGING, + "in-progress": IN_PROGRESS, + "completed": COMPLETED, + "busy": BUSY, + "failed": FAILED, + "no-answer": NO_ANSWER, + "canceled": CANCELED, + } + + +class TwilioPhoneCall(ProviderPhoneCall, models.Model): + + status = models.PositiveSmallIntegerField( + blank=True, + null=True, + choices=TwilioCallStatuses.CHOICES, + ) + + phone_call_record = models.OneToOneField( + "phone_notifications.PhoneCallRecord", + on_delete=models.CASCADE, + related_name="twilio_phone_call", + null=False, + ) + + sid = models.CharField( + blank=True, + max_length=50, + ) + + created_at = models.DateTimeField(auto_now_add=True) + + def link_and_save(self, phone_call_record: PhoneCallRecord): + self.phone_call_record = phone_call_record + self.save() diff --git a/engine/apps/twilioapp/models/twilio_sms.py b/engine/apps/twilioapp/models/twilio_sms.py new file mode 100644 index 00000000..8050d50b --- /dev/null +++ b/engine/apps/twilioapp/models/twilio_sms.py @@ -0,0 +1,63 @@ +from django.db import models + +from apps.phone_notifications.models import ProviderSMS + + +class TwilioSMSstatuses: + """ + https://www.twilio.com/docs/sms/tutorials/how-to-confirm-delivery-python?code-sample=code-handle-a-sms-statuscallback&code-language=Python&code-sdk-version=5.x#receive-status-events-in-your-web-application + https://www.twilio.com/docs/sms/api/message-resource#message-status-values + """ + + ACCEPTED = 10 + QUEUED = 20 + SENDING = 30 + SENT = 40 + FAILED = 50 + DELIVERED = 60 + UNDELIVERED = 70 + RECEIVING = 80 + RECEIVED = 90 + READ = 100 + + CHOICES = ( + (ACCEPTED, "accepted"), + (QUEUED, "queued"), + (SENDING, "sending"), + (SENT, "sent"), + (FAILED, "failed"), + (DELIVERED, "delivered"), + (UNDELIVERED, "undelivered"), + (RECEIVING, "receiving"), + (RECEIVED, "received"), + (READ, "read"), + ) + + DETERMINANT = { + "accepted": ACCEPTED, + "queued": QUEUED, + "sending": SENDING, + "sent": SENT, + "failed": FAILED, + "delivered": DELIVERED, + "undelivered": UNDELIVERED, + "receiving": RECEIVING, + "received": RECEIVED, + "read": READ, + } + + +class TwilioSMS(ProviderSMS, models.Model): + status = models.PositiveSmallIntegerField( + blank=True, + null=True, + choices=TwilioSMSstatuses.CHOICES, + ) + + # https://www.twilio.com/docs/sms/api/message-resource#message-properties + sid = models.CharField( + blank=True, + max_length=50, + ) + + created_at = models.DateTimeField(auto_now_add=True) diff --git a/engine/apps/twilioapp/phone_manager.py b/engine/apps/twilioapp/phone_manager.py deleted file mode 100644 index e26c64e8..00000000 --- a/engine/apps/twilioapp/phone_manager.py +++ /dev/null @@ -1,75 +0,0 @@ -import logging - -from twilio.base.exceptions import TwilioRestException - -from apps.twilioapp.twilio_client import twilio_client - -logger = logging.getLogger(__name__) - - -class PhoneManager: - def __init__(self, user): - self.user = user - - def send_verification_code(self): - if self.user.unverified_phone_number != self.user.verified_phone_number: - res = twilio_client.verification_start_via_twilio( - user=self.user, phone_number=self.user.unverified_phone_number, via="sms" - ) - if res and res.status != "denied": - return True - else: - logger.error(f"Failed to send verification code to User {self.user.pk}:\n{res}") - return False - - def verify_phone_number(self, code): - normalized_phone_number, _ = twilio_client.normalize_phone_number_via_twilio(self.user.unverified_phone_number) - if normalized_phone_number: - if normalized_phone_number == self.user.verified_phone_number: - verified = False - error = "This Phone Number has already been verified." - elif twilio_client.verification_check_via_twilio( - user=self.user, - phone_number=normalized_phone_number, - code=code, - ): - old_verified_phone_number = self.user.verified_phone_number - self.user.save_verified_phone_number(normalized_phone_number) - # send sms to the new number and to the old one - if old_verified_phone_number: - # notify about disconnect - self.notify_about_changed_verified_phone_number(old_verified_phone_number) - # notify about new connection - self.notify_about_changed_verified_phone_number(normalized_phone_number, True) - - verified = True - error = None - else: - verified = False - error = "Verification code is not correct." - else: - verified = False - error = "Phone Number is incorrect." - return verified, error - - def forget_phone_number(self): - if self.user.verified_phone_number or self.user.unverified_phone_number: - old_verified_phone_number = self.user.verified_phone_number - self.user.clear_phone_numbers() - if old_verified_phone_number: - self.notify_about_changed_verified_phone_number(old_verified_phone_number) - return True - return False - - def notify_about_changed_verified_phone_number(self, phone_number, connected=False): - text = ( - f"This phone number has been {'connected to' if connected else 'disconnected from'} Grafana OnCall team " - f'"{self.user.organization.stack_slug}"\nYour Grafana OnCall <3' - ) - try: - twilio_client.send_message(text, phone_number) - except TwilioRestException as e: - logger.error( - f"Failed to notify user {self.user.pk} about phone number " - f"{'connection' if connected else 'disconnection'}:\n{e}" - ) diff --git a/engine/apps/twilioapp/phone_provider.py b/engine/apps/twilioapp/phone_provider.py new file mode 100644 index 00000000..09b2480d --- /dev/null +++ b/engine/apps/twilioapp/phone_provider.py @@ -0,0 +1,256 @@ +import logging +import urllib.parse +from string import digits + +from phonenumbers import COUNTRY_CODE_TO_REGION_CODE +from twilio.base.exceptions import TwilioRestException +from twilio.rest import Client + +from apps.base.models import LiveSetting +from apps.base.utils import live_settings +from apps.phone_notifications.exceptions import ( + FailedToFinishVerification, + FailedToMakeCall, + FailedToSendSMS, + FailedToStartVerification, +) +from apps.phone_notifications.phone_provider import PhoneProvider, ProviderFlags +from apps.twilioapp.gather import get_gather_message, get_gather_url +from apps.twilioapp.models import TwilioCallStatuses, TwilioPhoneCall, TwilioSMS +from apps.twilioapp.status_callback import get_call_status_callback_url, get_sms_status_callback_url + +logger = logging.getLogger(__name__) + + +class TwilioPhoneProvider(PhoneProvider): + def make_notification_call(self, number: str, message: str) -> TwilioPhoneCall: + message = self._escape_call_message(message) + + twiml_query = self._message_to_twiml(message, with_gather=True) + + response = None + try_without_callback = False + + try: + response = self._call_create(twiml_query, number, with_callback=True) + except TwilioRestException as e: + # If status callback is not valid and not accessible from public url then trying to send message without it + # https://www.twilio.com/docs/api/errors/21609 + if e.code == 21609: + logger.info(f"TwilioPhoneProvider.make_notification_call: error 21609, calling without callback_url") + try_without_callback = True + else: + logger.error(f"TwilioPhoneProvider.make_notification_call: failed {e}") + raise FailedToMakeCall + + if try_without_callback: + try: + response = self._call_create(twiml_query, number, with_callback=False) + except TwilioRestException as e: + logger.error(f"TwilioPhoneProvider.make_notification_call: failed {e}") + raise FailedToMakeCall + + if response and response.status and response.sid: + return TwilioPhoneCall( + status=TwilioCallStatuses.DETERMINANT.get(response.status, None), + sid=response.sid, + ) + + def send_notification_sms(self, number: str, message: str) -> TwilioSMS: + try_without_callback = False + response = None + + try: + response = self._messages_create(number, message, with_callback=True) + except TwilioRestException as e: + # If status callback is not valid and not accessible from public url then trying to send message without it + # https://www.twilio.com/docs/api/errors/21609 + if e.code == 21609: + logger.info(f"TwilioPhoneProvider.send_notification_sms: error 21609, sending without callback_url") + try_without_callback = True + else: + logger.error(f"TwilioPhoneProvider.send_notification_sms: failed {e}") + raise FailedToSendSMS + + if try_without_callback: + try: + response = self._messages_create(number, message, with_callback=False) + except TwilioRestException as e: + logger.error(f"TwilioPhoneProvider.send_notification_sms: failed {e}") + raise FailedToSendSMS + + if response and response.status and response.sid: + return TwilioSMS( + status=TwilioCallStatuses.DETERMINANT.get(response.status, None), + sid=response.sid, + ) + + def send_verification_sms(self, number: str): + self._send_verification_code(number, via="sms") + + def finish_verification(self, number: str, code: str): + # I'm not sure if we need verification_and_parse via twilio pipeline here + # Verification code anyway is sent to not verified phone number. + # Just leaving it as it was before phone_provider refactoring. + normalized_number, _ = self._normalize_phone_number(number) + if normalized_number: + try: + verification_check = self._twilio_api_client.verify.services( + live_settings.TWILIO_VERIFY_SERVICE_SID + ).verification_checks.create(to=normalized_number, code=code) + logger.info(f"TwilioPhoneProvider.finish_verification: verification_status {verification_check.status}") + if verification_check.status == "approved": + return normalized_number + except TwilioRestException as e: + logger.error(f"TwilioPhoneProvider.finish_verification: failed to verify number {number}: {e}") + raise FailedToFinishVerification + else: + return None + + def make_call(self, number: str, message: str): + twiml_query = self._message_to_twiml(message, with_gather=False) + try: + self._call_create(twiml_query, number, with_callback=False) + except TwilioRestException as e: + logger.error(f"TwilioPhoneProvider.make_call: failed {e}") + raise FailedToMakeCall + + def send_sms(self, number: str, message: str): + try: + self._messages_create(number, message, with_callback=False) + except TwilioRestException as e: + logger.error(f"TwilioPhoneProvider.send_sms: failed {e}") + raise FailedToSendSMS + + def _message_to_twiml(self, message: str, with_gather=False): + q = f"{message}" + if with_gather: + gather_subquery = f'{get_gather_message()}' + q = f"{message}{gather_subquery}" + return urllib.parse.quote( + q, + safe="", + ) + + def _call_create(self, twiml_query: str, to: str, with_callback: bool): + url = "http://twimlets.com/echo?Twiml=" + twiml_query + if with_callback: + status_callback = get_call_status_callback_url() + status_callback_events = ["initiated", "ringing", "answered", "completed"] + return self._twilio_api_client.calls.create( + url=url, + to=to, + from_=self._twilio_number, + method="GET", + status_callback=status_callback, + status_callback_event=status_callback_events, + status_callback_method="POST", + ) + else: + return self._twilio_api_client.calls.create( + url=url, + to=to, + from_=self._twilio_number, + method="GET", + ) + + def _messages_create(self, number: str, text: str, with_callback: bool): + if with_callback: + status_callback = get_sms_status_callback_url() + return self._twilio_api_client.messages.create( + body=text, to=number, from_=self._twilio_number, status_callback=status_callback + ) + else: + return self._twilio_api_client.messages.create( + body=text, + to=number, + from_=self._twilio_number, + ) + + def _send_verification_code(self, number: str, via: str): + # https://www.twilio.com/docs/verify/api/verification?code-sample=code-start-a-verification-with-sms&code-language=Python&code-sdk-version=6.x + try: + verification = self._twilio_api_client.verify.services( + live_settings.TWILIO_VERIFY_SERVICE_SID + ).verifications.create(to=number, channel=via) + logger.info(f"TwilioPhoneProvider._send_verification_code: verification status {verification.status}") + except TwilioRestException as e: + logger.error(f"Twilio verification start error: {e} to number {number}") + raise FailedToStartVerification + + def _normalize_phone_number(self, number: str): + # TODO: phone_provider: is it best place to parse phone number? + number = self._parse_phone_number(number) + + # Verify and parse phone number with Twilio API + normalized_phone_number = None + country_code = None + if number != "" and number != "+": + try: + ok, normalized_phone_number, country_code = self._parse_number(number) + if normalized_phone_number == "": + normalized_phone_number = None + country_code = None + if not ok: + normalized_phone_number = None + country_code = None + except TypeError: + return None, None + + return normalized_phone_number, country_code + + # Use responsibly + def _parse_number(self, number: str): + try: + response = self._twilio_api_client.lookups.phone_numbers(number).fetch() + return True, response.phone_number, self._get_calling_code(response.country_code) + except TwilioRestException as e: + if e.code == 20404: + # Not sure, why 20404 (NotFound according to TwilioDocs) handled gracefully, leaving it as it is. + # https://www.twilio.com/docs/api/errors/20404" + return False, None, None + if e.code == 20003: + raise e + except KeyError as e: + # Don't know why KeyError is gracefully handled here, probably exception raised by twilio_client. + logger.info(f"twilio_client._parse_number: Gracefully handle KeyError: {e}") + return False, None, None + + @property + def _twilio_api_client(self): + if live_settings.TWILIO_API_KEY_SID and live_settings.TWILIO_API_KEY_SECRET: + return Client( + live_settings.TWILIO_API_KEY_SID, live_settings.TWILIO_API_KEY_SECRET, live_settings.TWILIO_ACCOUNT_SID + ) + else: + return Client(live_settings.TWILIO_ACCOUNT_SID, live_settings.TWILIO_AUTH_TOKEN) + + def _get_calling_code(self, iso): + for code, isos in COUNTRY_CODE_TO_REGION_CODE.items(): + if iso.upper() in isos: + return code + return None + + @property + def _twilio_number(self): + return live_settings.TWILIO_NUMBER + + def _escape_call_message(self, message): + # https://www.twilio.com/docs/api/errors/12100 + message = message.replace("&", "&") + message = message.replace(">", ">") + message = message.replace("<", "<") + return message + + def _parse_phone_number(self, raw_phone_number): + return "+" + "".join(c for c in raw_phone_number if c in digits) + + @property + def flags(self) -> ProviderFlags: + return ProviderFlags( + configured=not LiveSetting.objects.filter(name__startswith="TWILIO", error__isnull=False).exists(), + test_sms=True, + test_call=True, + verification_call=True, + verification_sms=True, + ) diff --git a/engine/apps/twilioapp/status_callback.py b/engine/apps/twilioapp/status_callback.py new file mode 100644 index 00000000..067884ae --- /dev/null +++ b/engine/apps/twilioapp/status_callback.py @@ -0,0 +1,142 @@ +from django.apps import apps +from django.urls import reverse + +from apps.alerts.signals import user_notification_action_triggered_signal +from apps.twilioapp.models import TwilioCallStatuses, TwilioPhoneCall, TwilioSMS, TwilioSMSstatuses +from common.api_helpers.utils import create_engine_url + + +def update_twilio_call_status(call_sid, call_status): + """The function checks existence of TwilioPhoneCall instance + according to call_sid and updates status on message_status + + Args: + call_sid (str): sid of Twilio call + call_status (str): new status + + Returns: + + """ + UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") + + if call_sid and call_status: + status = TwilioCallStatuses.DETERMINANT.get(call_status) + + twilio_phone_call = TwilioPhoneCall.objects.filter(sid=call_sid).first() + + # Check twilio phone call and then oncall phone call for backward compatibility after PhoneCall migration. + # Will be removed soon. + if twilio_phone_call: + status = TwilioCallStatuses.DETERMINANT.get(call_status) + twilio_phone_call.status = status + twilio_phone_call.save(update_fields=["status"]) + phone_call_record = twilio_phone_call.phone_call_record + else: + PhoneCallRecord = apps.get_model("phone_notifications", "PhoneCallRecord") + phone_call_record = PhoneCallRecord.objects.filter(sid=call_sid).first() + + if phone_call_record and status: + log_record_type = None + log_record_error_code = None + + if status == TwilioCallStatuses.COMPLETED: + log_record_type = UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_SUCCESS + elif status in [TwilioCallStatuses.FAILED, TwilioCallStatuses.BUSY, TwilioCallStatuses.NO_ANSWER]: + log_record_type = UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED + log_record_error_code = get_error_code_by_twilio_status(status) + + if log_record_type is not None: + log_record = UserNotificationPolicyLogRecord( + type=log_record_type, + notification_error_code=log_record_error_code, + author=phone_call_record.receiver, + notification_policy=phone_call_record.notification_policy, + alert_group=phone_call_record.represents_alert_group, + notification_step=phone_call_record.notification_policy.step + if phone_call_record.notification_policy + else None, + notification_channel=phone_call_record.notification_policy.notify_by + if phone_call_record.notification_policy + else None, + ) + user_notification_action_triggered_signal.send(sender=update_twilio_call_status, log_record=log_record) + + +def get_error_code_by_twilio_status(status): + UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") + TWILIO_ERRORS_TO_ERROR_CODES_MAP = { + TwilioCallStatuses.BUSY: UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_CALL_LINE_BUSY, + TwilioCallStatuses.FAILED: UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_CALL_FAILED, + TwilioCallStatuses.NO_ANSWER: UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_CALL_NO_ANSWER, + } + return TWILIO_ERRORS_TO_ERROR_CODES_MAP.get(status, None) + + +def update_twilio_sms_status(message_sid, message_status): + """The function checks existence of SMSMessage + instance according to message_sid and updates status on + message_status + + Args: + message_sid (str): sid of Twilio message + message_status (str): new status + + Returns: + + """ + UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") + + if message_sid and message_status: + status = TwilioSMSstatuses.DETERMINANT.get(message_status) + + twilio_sms = TwilioSMS.objects.filter(sid=message_sid).first() + + # Check twilio phone call and then oncall phone call for backward compatibility after PhoneCall migration. + # Will be removed soon. + if twilio_sms: + twilio_sms.status = status + twilio_sms.save(update_fields=["status"]) + sms_record = twilio_sms.sms_record + else: + PhoneCallRecord = apps.get_model("phone_notifications", "PhoneCallRecord") + sms_record = PhoneCallRecord.objects.filter(sid=message_sid).first() + + if sms_record and status: + log_record_type = None + log_record_error_code = None + if status == TwilioSMSstatuses.DELIVERED: + log_record_type = UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_SUCCESS + elif status in [TwilioSMSstatuses.UNDELIVERED, TwilioSMSstatuses.FAILED]: + log_record_type = UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED + log_record_error_code = get_sms_error_code_by_twilio_status(status) + + if log_record_type is not None: + log_record = UserNotificationPolicyLogRecord( + type=log_record_type, + notification_error_code=log_record_error_code, + author=sms_record.receiver, + notification_policy=sms_record.notification_policy, + alert_group=sms_record.represents_alert_group, + notification_step=sms_record.notification_policy.step if sms_record.notification_policy else None, + notification_channel=sms_record.notification_policy.notify_by + if sms_record.notification_policy + else None, + ) + user_notification_action_triggered_signal.send(sender=update_twilio_sms_status, log_record=log_record) + + +def get_sms_error_code_by_twilio_status(status): + UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") + TWILIO_ERRORS_TO_ERROR_CODES_MAP = { + TwilioSMSstatuses.UNDELIVERED: UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_SMS_DELIVERY_FAILED, + TwilioSMSstatuses.FAILED: UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_SMS_DELIVERY_FAILED, + } + return TWILIO_ERRORS_TO_ERROR_CODES_MAP.get(status, None) + + +def get_call_status_callback_url(): + return create_engine_url(reverse("twilioapp:call_status_events")) + + +def get_sms_status_callback_url(): + return create_engine_url(reverse("twilioapp:sms_status_events")) diff --git a/engine/apps/twilioapp/tests/factories.py b/engine/apps/twilioapp/tests/factories.py deleted file mode 100644 index e1b49940..00000000 --- a/engine/apps/twilioapp/tests/factories.py +++ /dev/null @@ -1,13 +0,0 @@ -import factory - -from apps.twilioapp.models import PhoneCall, SMSMessage - - -class PhoneCallFactory(factory.DjangoModelFactory): - class Meta: - model = PhoneCall - - -class SMSFactory(factory.DjangoModelFactory): - class Meta: - model = SMSMessage diff --git a/engine/apps/twilioapp/tests/test_phone_calls.py b/engine/apps/twilioapp/tests/test_phone_calls.py index 17ec3556..4fa2aaed 100644 --- a/engine/apps/twilioapp/tests/test_phone_calls.py +++ b/engine/apps/twilioapp/tests/test_phone_calls.py @@ -1,81 +1,46 @@ -import urllib from unittest import mock import pytest from bs4 import BeautifulSoup from django.urls import reverse -from django.utils import timezone from django.utils.datastructures import MultiValueDict from django.utils.http import urlencode from rest_framework.test import APIClient from apps.base.models import UserNotificationPolicy -from apps.twilioapp.constants import TwilioCallStatuses -from apps.twilioapp.models import PhoneCall -from apps.twilioapp.utils import get_gather_message - - -class FakeTwilioCall: - def __init__(self): - self.sid = "123" - self.status = TwilioCallStatuses.COMPLETED +from apps.twilioapp.models import TwilioCallStatuses, TwilioPhoneCall @pytest.fixture -def phone_call_setup( +def make_twilio_phone_call( make_organization_and_user, make_alert_receive_channel, make_user_notification_policy, make_alert_group, + make_phone_call_record, make_alert, - make_phone_call, ): organization, user = make_organization_and_user() alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) - make_alert( - alert_group, - raw_request_data={ - "status": "firing", - "labels": { - "alertname": "TestAlert", - "region": "eu-1", - }, - "annotations": {}, - "startsAt": "2018-12-25T15:47:47.377363608Z", - "endsAt": "0001-01-01T00:00:00Z", - "generatorURL": "", - }, - ) - + make_alert(alert_group, raw_request_data="{}") notification_policy = make_user_notification_policy( user=user, step=UserNotificationPolicy.Step.NOTIFY, notify_by=UserNotificationPolicy.NotificationChannel.PHONE_CALL, ) - - phone_call = make_phone_call( + phone_call_record = make_phone_call_record( receiver=user, - status=TwilioCallStatuses.QUEUED, represents_alert_group=alert_group, - sid="SMa12312312a123a123123c6dd2f1aee77", notification_policy=notification_policy, ) - - return phone_call, alert_group + 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"] diff --git a/engine/apps/twilioapp/tests/test_sms_message.py b/engine/apps/twilioapp/tests/test_sms_message.py index 86ab2390..bba035b5 100644 --- a/engine/apps/twilioapp/tests/test_sms_message.py +++ b/engine/apps/twilioapp/tests/test_sms_message.py @@ -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] diff --git a/engine/apps/twilioapp/tests/test_twilio_provider.py b/engine/apps/twilioapp/tests/test_twilio_provider.py new file mode 100644 index 00000000..20892109 --- /dev/null +++ b/engine/apps/twilioapp/tests/test_twilio_provider.py @@ -0,0 +1,65 @@ +from unittest import mock + +import pytest + +from apps.twilioapp.phone_provider import TwilioPhoneProvider + + +class MockTwilioCallInstance: + status = "mock_status" + sid = "mock_sid" + + +@pytest.mark.django_db +@mock.patch("apps.twilioapp.phone_provider.TwilioPhoneProvider._call_create", return_value=MockTwilioCallInstance()) +@mock.patch("apps.twilioapp.phone_provider.TwilioPhoneProvider._message_to_twiml", return_value="mocked_twiml") +def test_make_notification_call(mock_twiml, mock_call_create): + number = "+1234567890" + message = "Hello" + provider = TwilioPhoneProvider() + provider_call = provider.make_notification_call(number, message) + mock_call_create.assert_called_once_with("mocked_twiml", number, with_callback=True) + assert provider_call is not None + assert provider_call.sid == MockTwilioCallInstance.sid + assert provider_call.id is None # test that provider_call is returned by notification call and NOT saved + + +@pytest.mark.django_db +@mock.patch("apps.twilioapp.phone_provider.TwilioPhoneProvider._call_create", return_value=MockTwilioCallInstance()) +@mock.patch("apps.twilioapp.phone_provider.TwilioPhoneProvider._message_to_twiml", return_value="mocked_twiml") +def test_make_call(mock_twiml, mock_call_create): + number = "+1234567890" + message = "Hello" + provider = TwilioPhoneProvider() + provider_call = provider.make_call(number, message) + assert provider_call is None # test that provider_call is not returned from make_call + mock_call_create.assert_called_once_with("mocked_twiml", number, with_callback=False) + + +class MockTwilioSMSInstance: + status = "mock_status" + sid = "mock_sid" + + +@pytest.mark.django_db +@mock.patch("apps.twilioapp.phone_provider.TwilioPhoneProvider._messages_create", return_value=MockTwilioSMSInstance()) +def test_send_notification_sms(mock_messages_create): + number = "+1234567890" + message = "Hello" + provider = TwilioPhoneProvider() + provider_sms = provider.send_notification_sms(number, message) + mock_messages_create.assert_called_once_with(number, message, with_callback=True) + assert provider_sms is not None + assert provider_sms.sid == MockTwilioCallInstance.sid + assert provider_sms.id is None # test that provider_call is returned by notification call and NOT saved + + +@pytest.mark.django_db +@mock.patch("apps.twilioapp.phone_provider.TwilioPhoneProvider._messages_create", return_value=MockTwilioSMSInstance()) +def test_send_sms(mock_messages_create): + number = "+1234567890" + message = "Hello" + provider = TwilioPhoneProvider() + provider_sms = provider.send_sms(number, message) + assert provider_sms is None # test that provider_sms is not returned from send_sms + mock_messages_create.assert_called_once_with(number, message, with_callback=False) diff --git a/engine/apps/twilioapp/twilio_client.py b/engine/apps/twilioapp/twilio_client.py deleted file mode 100644 index 75d06403..00000000 --- a/engine/apps/twilioapp/twilio_client.py +++ /dev/null @@ -1,206 +0,0 @@ -import logging -import urllib.parse - -from django.apps import apps -from django.urls import reverse -from twilio.base.exceptions import TwilioRestException -from twilio.rest import Client - -from apps.base.utils import live_settings -from apps.twilioapp.constants import TEST_CALL_TEXT, TwilioLogRecordStatus, TwilioLogRecordType -from apps.twilioapp.utils import get_calling_code, get_gather_message, get_gather_url, parse_phone_number -from common.api_helpers.utils import create_engine_url - -logger = logging.getLogger(__name__) - - -class TwilioClient: - @property - def twilio_api_client(self): - if live_settings.TWILIO_API_KEY_SID and live_settings.TWILIO_API_KEY_SECRET: - return Client( - live_settings.TWILIO_API_KEY_SID, live_settings.TWILIO_API_KEY_SECRET, live_settings.TWILIO_ACCOUNT_SID - ) - else: - return Client(live_settings.TWILIO_ACCOUNT_SID, live_settings.TWILIO_AUTH_TOKEN) - - @property - def twilio_number(self): - return live_settings.TWILIO_NUMBER - - def send_message(self, body, to): - status_callback = create_engine_url(reverse("twilioapp:sms_status_events")) - try: - return self.twilio_api_client.messages.create( - body=body, to=to, from_=self.twilio_number, status_callback=status_callback - ) - except TwilioRestException as e: - # If status callback is not valid and not accessible from public url then trying to send message without it - # https://www.twilio.com/docs/api/errors/21609 - if e.code == 21609: - logger.warning("twilio_client.send_message: Twilio error 21609. Status Callback is not public url") - return self.twilio_api_client.messages.create(body=body, to=to, from_=self.twilio_number) - raise e - - # Use responsibly - def parse_number(self, number): - try: - response = self.twilio_api_client.lookups.phone_numbers(number).fetch() - return True, response.phone_number, get_calling_code(response.country_code) - except TwilioRestException as e: - if e.code == 20404: - print("Handled exception from twilio: " + str(e)) - return False, None, None - if e.code == 20003: - raise e - except KeyError as e: - print("Handled exception from twilio: " + str(e)) - return False, None, None - - def verification_start_via_twilio(self, user, phone_number, via): - # https://www.twilio.com/docs/verify/api/verification?code-sample=code-start-a-verification-with-sms&code-language=Python&code-sdk-version=6.x - verification = None - try: - verification = self.twilio_api_client.verify.services( - live_settings.TWILIO_VERIFY_SERVICE_SID - ).verifications.create(to=phone_number, channel=via) - except TwilioRestException as e: - logger.error(f"Twilio verification start error: {e} for User: {user.pk}") - - self.create_log_record( - user=user, - phone_number=(phone_number or ""), - type=TwilioLogRecordType.VERIFICATION_START, - status=TwilioLogRecordStatus.ERROR, - succeed=False, - error_message=str(e), - ) - else: - verification_status = verification.status - logger.info(f"Verification status: {verification_status}") - - self.create_log_record( - user=user, - phone_number=phone_number, - type=TwilioLogRecordType.VERIFICATION_START, - payload=str(verification._properties), - status=TwilioLogRecordStatus.DETERMINANT[verification_status], - succeed=(verification_status != "denied"), - ) - - return verification - - def verification_check_via_twilio(self, user, phone_number, code): - # https://www.twilio.com/docs/verify/api/verification-check?code-sample=code-check-a-verification-with-a-phone-number&code-language=Python&code-sdk-version=6.x - succeed = False - try: - verification_check = self.twilio_api_client.verify.services( - live_settings.TWILIO_VERIFY_SERVICE_SID - ).verification_checks.create(to=phone_number, code=code) - except TwilioRestException as e: - logger.error(f"Twilio verification check error: {e} for User: {user.pk}") - self.create_log_record( - user=user, - phone_number=(phone_number or ""), - type=TwilioLogRecordType.VERIFICATION_CHECK, - status=TwilioLogRecordStatus.ERROR, - succeed=succeed, - error_message=str(e), - ) - else: - verification_check_status = verification_check.status - logger.info(f"Verification check status: {verification_check_status}") - succeed = verification_check_status == "approved" - - self.create_log_record( - user=user, - phone_number=phone_number, - type=TwilioLogRecordType.VERIFICATION_CHECK, - payload=str(verification_check._properties), - status=TwilioLogRecordStatus.DETERMINANT[verification_check_status], - succeed=succeed, - ) - - return succeed - - def make_test_call(self, to): - message = TEST_CALL_TEXT.format( - channel_name="Test call", - alert_group_name="Test notification", - alerts_count=2, - ) - self.make_call(message=message, to=to) - - def make_call(self, message, to, grafana_cloud=False): - try: - start_message = message.replace('"', "") - - gather_message = ( - ( - f'' - f"{get_gather_message()}" - f"" - ) - if not grafana_cloud - else "" - ) - - twiml_query = urllib.parse.quote( - f"{start_message}{gather_message}", - safe="", - ) - - url = "http://twimlets.com/echo?Twiml=" + twiml_query - status_callback = create_engine_url(reverse("twilioapp:call_status_events")) - - status_callback_events = ["initiated", "ringing", "answered", "completed"] - - return self.twilio_api_client.calls.create( - url=url, - to=to, - from_=self.twilio_number, - method="GET", - status_callback=status_callback, - status_callback_event=status_callback_events, - status_callback_method="POST", - ) - except TwilioRestException as e: - # If status callback is not valid and not accessible from public url then trying to make call without it - # https://www.twilio.com/docs/api/errors/21609 - if e.code == 21609: - logger.warning("twilio_client.make_call: Twilio error 21609. Status Callback is not public url") - return self.twilio_api_client.calls.create( - url=url, - to=to, - from_=self.twilio_number, - method="GET", - ) - - raise e - - def create_log_record(self, **kwargs): - TwilioLogRecord = apps.get_model("twilioapp", "TwilioLogRecord") - TwilioLogRecord.objects.create(**kwargs) - - def normalize_phone_number_via_twilio(self, phone_number): - phone_number = parse_phone_number(phone_number) - - # Verify and parse phone number with Twilio API - normalized_phone_number = None - country_code = None - if phone_number != "" and phone_number != "+": - try: - ok, normalized_phone_number, country_code = self.parse_number(phone_number) - if normalized_phone_number == "": - normalized_phone_number = None - country_code = None - if not ok: - normalized_phone_number = None - country_code = None - except TypeError: - return None, None - - return normalized_phone_number, country_code - - -twilio_client = TwilioClient() diff --git a/engine/apps/twilioapp/utils.py b/engine/apps/twilioapp/utils.py deleted file mode 100644 index 7b14b9bd..00000000 --- a/engine/apps/twilioapp/utils.py +++ /dev/null @@ -1,68 +0,0 @@ -import logging -import re -from string import digits - -from django.apps import apps -from django.urls import reverse -from phonenumbers import COUNTRY_CODE_TO_REGION_CODE -from twilio.twiml.voice_response import Gather, VoiceResponse - -from common.api_helpers.utils import create_engine_url - -logger = logging.getLogger(__name__) - - -def get_calling_code(iso): - for code, isos in COUNTRY_CODE_TO_REGION_CODE.items(): - if iso.upper() in isos: - return code - return None - - -def get_gather_url(): - gather_url = create_engine_url(reverse("twilioapp:gather")) - return gather_url - - -def get_gather_message(): - return "Press 1 to acknowledge, 2 to resolve, 3 to silence to 30 minutes" - - -def process_call_data(call_sid, digit): - """The function processes pressed digit at call time - - Args: - call_sid (str): - digit (str): user pressed digit - - Returns: - response (VoiceResponse) - """ - - response = VoiceResponse() - - if digit in ["1", "2", "3"]: - # Success case - response.say(f"You have pressed digit {digit}") - - PhoneCall = apps.get_model("twilioapp", "PhoneCall") - PhoneCall.objects.get_and_process_digit(call_sid=call_sid, digit=digit) - - else: - # Error wrong digit pressing - gather = Gather(action=get_gather_url(), method="POST", num_digits=1) - - response.say("Wrong digit") - gather.say(get_gather_message()) - - response.append(gather) - - return response - - -def check_phone_number_is_valid(phone_number): - return re.match(r"^\+\d{8,15}$", phone_number) is not None - - -def parse_phone_number(raw_phone_number): - return "+" + "".join(c for c in raw_phone_number if c in digits) diff --git a/engine/apps/twilioapp/views.py b/engine/apps/twilioapp/views.py index 76404bc5..7754bdd0 100644 --- a/engine/apps/twilioapp/views.py +++ b/engine/apps/twilioapp/views.py @@ -1,6 +1,5 @@ import logging -from django.apps import apps from django.http import HttpResponse from rest_framework import status from rest_framework.permissions import BasePermission @@ -9,9 +8,11 @@ from rest_framework.views import APIView from twilio.request_validator import RequestValidator from apps.base.utils import live_settings -from apps.twilioapp.utils import process_call_data from common.api_helpers.utils import create_engine_url +from .gather import process_gather_data +from .status_callback import update_twilio_call_status, update_twilio_sms_status + logger = logging.getLogger(__name__) @@ -41,13 +42,9 @@ class GatherView(APIView): permission_classes = [AllowOnlyTwilio] def post(self, request): - digit = request.POST.get("Digits") call_sid = request.POST.get("CallSid") - - logging.info(f"For CallSid: {call_sid} pressed digit: {digit}") - - response = process_call_data(call_sid=call_sid, digit=digit) - + digit = request.POST.get("Digits") + response = process_gather_data(call_sid, digit) return HttpResponse(str(response), content_type="application/xml; charset=utf-8") @@ -58,10 +55,8 @@ class SMSStatusCallback(APIView): def post(self, request): message_sid = request.POST.get("MessageSid") message_status = request.POST.get("MessageStatus") - logging.info(f"SID: {message_sid}, Status: {message_status}") - SMSMessage = apps.get_model("twilioapp", "SMSMessage") - SMSMessage.objects.update_status(message_sid=message_sid, message_status=message_status) + update_twilio_sms_status(message_sid=message_sid, message_status=message_status) return Response(data="", status=status.HTTP_204_NO_CONTENT) @@ -73,9 +68,8 @@ class CallStatusCallback(APIView): call_sid = request.POST.get("CallSid") call_status = request.POST.get("CallStatus") - logging.info(f"SID: {call_sid}, Status: {call_status}") + logging.info(f"CallStatusCallback: SID: {call_sid}, Status: {call_status}") - PhoneCall = apps.get_model("twilioapp", "PhoneCall") - PhoneCall.objects.update_status(call_sid=call_sid, call_status=call_status) + update_twilio_call_status(call_sid=call_sid, call_status=call_status) return Response(data="", status=status.HTTP_204_NO_CONTENT) diff --git a/engine/apps/user_management/subscription_strategy/free_public_beta_subscription_strategy.py b/engine/apps/user_management/subscription_strategy/free_public_beta_subscription_strategy.py index db92ce37..3dd9498f 100644 --- a/engine/apps/user_management/subscription_strategy/free_public_beta_subscription_strategy.py +++ b/engine/apps/user_management/subscription_strategy/free_public_beta_subscription_strategy.py @@ -55,11 +55,11 @@ 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, diff --git a/engine/apps/user_management/tests/test_free_public_beta_subcription_strategy.py b/engine/apps/user_management/tests/test_free_public_beta_subcription_strategy.py index b3b26e4f..c25e82dc 100644 --- a/engine/apps/user_management/tests/test_free_public_beta_subcription_strategy.py +++ b/engine/apps/user_management/tests/test_free_public_beta_subcription_strategy.py @@ -1,14 +1,13 @@ import pytest from apps.api.permissions import LegacyAccessControlRole -from apps.twilioapp.constants import TwilioCallStatuses, TwilioMessageStatuses @pytest.mark.django_db def test_phone_calls_left( make_organization, make_user_for_organization, - make_phone_call, + make_phone_call_record, make_alert_receive_channel, make_alert_group, ): @@ -17,7 +16,7 @@ def test_phone_calls_left( user = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) - make_phone_call(receiver=admin, status=TwilioCallStatuses.COMPLETED, represents_alert_group=alert_group) + make_phone_call_record(receiver=admin, represents_alert_group=alert_group) assert organization.phone_calls_left(admin) == organization.subscription_strategy._phone_notifications_limit - 1 assert organization.phone_calls_left(user) == organization.subscription_strategy._phone_notifications_limit @@ -25,14 +24,14 @@ def test_phone_calls_left( @pytest.mark.django_db def test_sms_left( - make_organization, make_user_for_organization, make_sms, make_alert_receive_channel, make_alert_group + make_organization, make_user_for_organization, make_sms_record, make_alert_receive_channel, make_alert_group ): organization = make_organization() admin = make_user_for_organization(organization) user = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) - make_sms(receiver=admin, status=TwilioMessageStatuses.SENT, represents_alert_group=alert_group) + make_sms_record(receiver=admin, represents_alert_group=alert_group) assert organization.sms_left(admin) == organization.subscription_strategy._phone_notifications_limit - 1 assert organization.sms_left(user) == organization.subscription_strategy._phone_notifications_limit @@ -42,8 +41,8 @@ def test_sms_left( def test_phone_calls_and_sms_counts_together( make_organization, make_user_for_organization, - make_phone_call, - make_sms, + make_phone_call_record, + make_sms_record, make_alert_receive_channel, make_alert_group, ): @@ -52,8 +51,8 @@ def test_phone_calls_and_sms_counts_together( user = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) - make_phone_call(receiver=admin, status=TwilioCallStatuses.COMPLETED, represents_alert_group=alert_group) - make_sms(receiver=admin, status=TwilioMessageStatuses.SENT, represents_alert_group=alert_group) + make_phone_call_record(receiver=admin, represents_alert_group=alert_group) + make_sms_record(receiver=admin, represents_alert_group=alert_group) assert organization.phone_calls_left(admin) == organization.subscription_strategy._phone_notifications_limit - 2 assert organization.sms_left(admin) == organization.subscription_strategy._phone_notifications_limit - 2 diff --git a/engine/apps/user_management/tests/test_organization.py b/engine/apps/user_management/tests/test_organization.py index ecab91df..6630f6ca 100644 --- a/engine/apps/user_management/tests/test_organization.py +++ b/engine/apps/user_management/tests/test_organization.py @@ -8,7 +8,6 @@ from apps.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, diff --git a/engine/common/api_helpers/utils.py b/engine/common/api_helpers/utils.py index cc805ccc..d81942bd 100644 --- a/engine/common/api_helpers/utils.py +++ b/engine/common/api_helpers/utils.py @@ -155,3 +155,7 @@ def get_date_range_from_request(request): raise BadRequest(detail="Invalid days format") return user_tz, starting_date, days + + +def check_phone_number_is_valid(phone_number): + return re.match(r"^\+\d{8,15}$", phone_number) is not None diff --git a/engine/common/utils.py b/engine/common/utils.py index 9477a91a..4dc313e5 100644 --- a/engine/common/utils.py +++ b/engine/common/utils.py @@ -193,14 +193,6 @@ def clean_markup(text): return cleaned -def escape_for_twilio_phone_call(text): - # https://www.twilio.com/docs/api/errors/12100 - text = text.replace("&", "&") - text = text.replace(">", ">") - text = text.replace("<", "<") - return text - - def escape_html(text): return html.escape(text) diff --git a/engine/conftest.py b/engine/conftest.py index af9559c4..c12fbec5 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -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() diff --git a/engine/engine/management/commands/verify_phone.py b/engine/engine/management/commands/verify_phone.py deleted file mode 100644 index 80e6a7d7..00000000 --- a/engine/engine/management/commands/verify_phone.py +++ /dev/null @@ -1,52 +0,0 @@ -from django.core.management.base import BaseCommand - -from apps.twilioapp.twilio_client import twilio_client -from apps.twilioapp.utils import check_phone_number_is_valid -from apps.user_management.models import User - - -class Command(BaseCommand): - """ - This command is to manually verify user's phone numbers. - """ - - def add_arguments(self, parser): - parser.add_argument("user_id", type=int, help="User id to manually verify phone number") - parser.add_argument("phone_number", type=str, help="Phone number to verify") - - parser.add_argument( - "--override", - action="store_true", - help="Override existing phone number", - ) - - def handle(self, *args, **options): - user_id = options["user_id"] - phone_number = options["phone_number"] - - if not check_phone_number_is_valid(phone_number): - self.stdout.write(self.style.ERROR('Invalid phone number "%s"' % phone_number)) - return - - try: - user = User.objects.get(pk=user_id) - except User.objects.DoesNotExists: - self.stdout.write(self.style.ERROR('Invalid user_id "%s"' % user_id)) - return - - if user.verified_phone_number and not options["override"]: - self.stdout.write(self.style.ERROR('User "%s" already has a phone number' % user_id)) - return - - normalized_phone_number, _ = twilio_client.normalize_phone_number_via_twilio(phone_number) - if normalized_phone_number: - user.save_verified_phone_number(normalized_phone_number) - user.unverified_phone_number = phone_number - user.save(update_fields=["unverified_phone_number"]) - else: - self.stdout.write(self.style.ERROR('Invalid phone number "%s"' % phone_number)) - return - - self.stdout.write( - self.style.SUCCESS('Successfully verified phone number "%s" for user "%s"' % (phone_number, user_id)) - ) diff --git a/engine/settings/base.py b/engine/settings/base.py index 12c7ee66..aa4e6d67 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -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") diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification.tsx index 8393a2c8..578b093f 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification.tsx @@ -24,7 +24,8 @@ interface PhoneVerificationProps extends HTMLAttributes { interface PhoneVerificationState { phone: string; code: string; - isCodeSent: boolean; + isCodeSent?: boolean; + isPhoneCallInitiated?: boolean; isPhoneNumberHidden: boolean; isLoading: boolean; showForgetScreen: boolean; @@ -41,7 +42,10 @@ const PhoneVerification = observer((props: PhoneVerificationProps) => { const user = userStore.items[userPk]; const isCurrentUser = userStore.currentUserPk === user.pk; - const [{ showForgetScreen, phone, code, isCodeSent, isPhoneNumberHidden, isLoading }, setState] = useReducer( + const [ + { showForgetScreen, phone, code, isCodeSent, isPhoneCallInitiated, isPhoneNumberHidden, isLoading }, + setState, + ] = useReducer( (state: PhoneVerificationState, newState: Partial) => ({ ...state, ...newState, @@ -51,6 +55,7 @@ const PhoneVerification = observer((props: PhoneVerificationProps) => { phone: user.verified_phone_number || '+', isLoading: false, isCodeSent: false, + isPhoneCallInitiated: false, showForgetScreen: false, isPhoneNumberHidden: user.hide_phone_number, } @@ -70,7 +75,7 @@ const PhoneVerification = observer((props: PhoneVerificationProps) => { ); const onChangePhoneCallback = useCallback((event: React.ChangeEvent) => { - setState({ isCodeSent: false, phone: event.target.value }); + setState({ isCodeSent: false, isPhoneCallInitiated: false, phone: event.target.value }); }, []); const onChangeCodeCallback = useCallback((event: React.ChangeEvent) => { @@ -81,51 +86,81 @@ const PhoneVerification = observer((props: PhoneVerificationProps) => { userStore.makeTestCall(userPk); }, [userPk, userStore.makeTestCall]); + const handleSendTestSmsClick = useCallback(() => { + userStore.sendTestSms(userPk); + }, [userPk, userStore.sendTestSms]); + const handleForgetNumberClick = useCallback(() => { userStore.forgetPhone(userPk).then(async () => { await userStore.loadUser(userPk); - setState({ phone: '', showForgetScreen: false, isCodeSent: false }); + setState({ phone: '', showForgetScreen: false, isCodeSent: false, isPhoneCallInitiated: false }); }); }, [userPk, userStore.forgetPhone, userStore.loadUser]); - const onSubmitCallback = useCallback(async () => { - if (isCodeSent) { - userStore.verifyPhone(userPk, code).then(() => { - userStore.loadUser(userPk); - }); - } else { - window.grecaptcha.ready(function () { - window.grecaptcha - .execute(rootStore.recaptchaSiteKey, { action: 'mobile_verification_code' }) - .then(async function (token) { - await userStore.updateUser({ - pk: userPk, - email: user.email, - unverified_phone_number: phone, - }); + const onSubmitCallback = useCallback( + async (type) => { + let codeVerification = isCodeSent; + if (type === 'verification_call') { + codeVerification = isPhoneCallInitiated; + } + if (codeVerification) { + userStore.verifyPhone(userPk, code).then(() => { + userStore.loadUser(userPk); + }); + } else { + window.grecaptcha.ready(function () { + window.grecaptcha + .execute(rootStore.recaptchaSiteKey, { action: 'mobile_verification_code' }) + .then(async function (token) { + await userStore.updateUser({ + pk: userPk, + email: user.email, + unverified_phone_number: phone, + }); - 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) && ( <> - Can't verify phone. Check ENV variables{' '} - related to Twilio. + Can't verify phone. Check ENV variables to + configure your provider. } /> @@ -185,7 +222,7 @@ const PhoneVerification = observer((props: PhoneVerificationProps) => { autoFocus id="phone" required - disabled={!isTwilioConfigured || isPhoneDisabled} + disabled={!isPhoneProviderConfigured || isPhoneDisabled} placeholder="Please enter the phone number with country code, e.g. +12451111111" // @ts-ignore prefix={} @@ -233,11 +270,14 @@ const PhoneVerification = observer((props: PhoneVerificationProps) => { setState({ showForgetScreen: true })} user={user} /> @@ -273,12 +313,20 @@ interface PhoneVerificationButtonsGroupProps { action: UserAction; isCodeSent: boolean; + isPhoneCallInitiated: boolean; isButtonDisabled: boolean; isTestCallInProgress: boolean; - isTwilioConfigured: boolean; - - onSubmitCallback(): void; + providerConfiguration: { + configured: boolean; + test_call: boolean; + test_sms: boolean; + verification_call: boolean; + verification_sms: boolean; + }; + onSubmitCallback(type: string): void; + onVerifyCallback(): void; handleMakeTestCallClick(): void; + handleSendTestSmsClick(): void; onShowForgetScreen(): void; user: User; @@ -287,25 +335,60 @@ interface PhoneVerificationButtonsGroupProps { function PhoneVerificationButtonsGroup({ action, isCodeSent, + isPhoneCallInitiated, isButtonDisabled, isTestCallInProgress, - isTwilioConfigured, + providerConfiguration, onSubmitCallback, + onVerifyCallback, handleMakeTestCallClick, + handleSendTestSmsClick, onShowForgetScreen, user, }: PhoneVerificationButtonsGroupProps) { const showForgetNumber = !!user.verified_phone_number; const showVerifyOrSendCodeButton = !user.verified_phone_number; - + const verificationStarted = isCodeSent || isPhoneCallInitiated; return ( {showVerifyOrSendCodeButton && ( - - - + + {verificationStarted ? ( + <> + + + + + ) : ( + + {' '} + {providerConfiguration.verification_sms && ( + + + + )} + {providerConfiguration.verification_call && ( + + + + )} + + )} + )} {showForgetNumber && ( @@ -321,24 +404,33 @@ function PhoneVerificationButtonsGroup({ )} {user.verified_phone_number && ( - <> - - - - - - - + + {providerConfiguration.test_sms && ( + + + + )} + {providerConfiguration.test_call && ( + + + + + + + + + )} + )} ); diff --git a/grafana-plugin/src/models/team/team.types.ts b/grafana-plugin/src/models/team/team.types.ts index cf511c96..9fdc1df6 100644 --- a/grafana-plugin/src/models/team/team.types.ts +++ b/grafana-plugin/src/models/team/team.types.ts @@ -66,5 +66,12 @@ export interface Team { env_status: { twilio_configured: boolean; telegram_configured: boolean; + phone_provider: { + configured: boolean; + test_call: boolean; + test_sms: boolean; + verification_call: boolean; + verification_sms: boolean; + }; }; } diff --git a/grafana-plugin/src/models/user/user.ts b/grafana-plugin/src/models/user/user.ts index 25d8f750..437f392b 100644 --- a/grafana-plugin/src/models/user/user.ts +++ b/grafana-plugin/src/models/user/user.ts @@ -245,6 +245,14 @@ export class UserStore extends BaseStore { }).catch(throttlingError); } + @action + async fetchVerificationCall(userPk: User['pk'], recaptchaToken: string) { + await makeRequest(`/users/${userPk}/get_verification_call/`, { + method: 'GET', + headers: { 'X-OnCall-Recaptcha': recaptchaToken }, + }).catch(throttlingError); + } + @action async verifyPhone(userPk: User['pk'], token: string) { return await makeRequest(`/users/${userPk}/verify_number/?token=${token}`, { @@ -376,6 +384,18 @@ export class UserStore extends BaseStore { }); } + async sendTestSms(userPk: User['pk']) { + this.isTestCallInProgress = true; + + return await makeRequest(`/users/${userPk}/send_test_sms/`, { + method: 'POST', + }) + .catch(this.onApiError) + .finally(() => { + this.isTestCallInProgress = false; + }); + } + async getiCalLink(userPk: User['pk']) { return await makeRequest(`/users/${userPk}/export_token/`, { method: 'GET', From dca0b1e5df62bc86db156d6422d016d2e956e62e Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Wed, 24 May 2023 15:28:14 +0800 Subject: [PATCH 11/24] Improve twilio callback logging (#1998) # 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) --- engine/apps/twilioapp/gather.py | 11 +++++++++++ engine/apps/twilioapp/status_callback.py | 22 ++++++++++++++++++++++ engine/apps/twilioapp/views.py | 3 --- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/engine/apps/twilioapp/gather.py b/engine/apps/twilioapp/gather.py index fffce9aa..54b918d7 100644 --- a/engine/apps/twilioapp/gather.py +++ b/engine/apps/twilioapp/gather.py @@ -1,3 +1,5 @@ +import logging + from django.apps import apps from django.urls import reverse from twilio.twiml.voice_response import Gather, VoiceResponse @@ -6,6 +8,8 @@ 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: """ @@ -50,10 +54,12 @@ def process_digit(call_sid, digit): """ 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") @@ -63,6 +69,11 @@ def process_digit(call_sid, digit): 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": diff --git a/engine/apps/twilioapp/status_callback.py b/engine/apps/twilioapp/status_callback.py index 067884ae..634a203a 100644 --- a/engine/apps/twilioapp/status_callback.py +++ b/engine/apps/twilioapp/status_callback.py @@ -1,3 +1,5 @@ +import logging + from django.apps import apps from django.urls import reverse @@ -5,6 +7,8 @@ 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 @@ -20,6 +24,7 @@ def update_twilio_call_status(call_sid, call_status): 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() @@ -27,6 +32,10 @@ def update_twilio_call_status(call_sid, call_status): # 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"]) @@ -36,6 +45,10 @@ def update_twilio_call_status(call_sid, call_status): 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 @@ -87,6 +100,7 @@ def update_twilio_sms_status(message_sid, message_status): 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() @@ -94,6 +108,10 @@ def update_twilio_sms_status(message_sid, message_status): # 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 @@ -102,6 +120,10 @@ def update_twilio_sms_status(message_sid, message_status): 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: diff --git a/engine/apps/twilioapp/views.py b/engine/apps/twilioapp/views.py index 7754bdd0..6df7ccac 100644 --- a/engine/apps/twilioapp/views.py +++ b/engine/apps/twilioapp/views.py @@ -68,8 +68,5 @@ class CallStatusCallback(APIView): call_sid = request.POST.get("CallSid") call_status = request.POST.get("CallStatus") - logging.info(f"CallStatusCallback: SID: {call_sid}, Status: {call_status}") - update_twilio_call_status(call_sid=call_sid, call_status=call_status) - return Response(data="", status=status.HTTP_204_NO_CONTENT) From 36f7ec2ddc12d4c16048b65c5a4c325ccb866242 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Wed, 24 May 2023 15:36:45 +0800 Subject: [PATCH 12/24] Update CHANGELOG.md --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39bc4452..d10f341b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,11 @@ 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). -## Unreleased +## v1.2.28 (2023-05-24) ### Changed -- Phone provider refactoring +- Phone provider refactoring [#1713](https://github.com/grafana/oncall/pull/1713) ### Fixed From d139fcc7e80cf70b2b39131b9b1c1ef414d27489 Mon Sep 17 00:00:00 2001 From: Ildar Iskhakov Date: Wed, 24 May 2023 16:14:48 +0800 Subject: [PATCH 13/24] Remove user defined time period from "you're going oncall" mobile push (#2001) # 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) --- CHANGELOG.md | 1 + engine/apps/mobile_app/tasks.py | 4 +++- .../mobile_app/tests/test_your_going_oncall_notification.py | 4 ++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d10f341b..7075d04b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,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) diff --git a/engine/apps/mobile_app/tasks.py b/engine/apps/mobile_app/tasks.py index 70058499..fafe06a9 100644 --- a/engine/apps/mobile_app/tasks.py +++ b/engine/apps/mobile_app/tasks.py @@ -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}") diff --git a/engine/apps/mobile_app/tests/test_your_going_oncall_notification.py b/engine/apps/mobile_app/tests/test_your_going_oncall_notification.py index fde76af7..9cd99a03 100644 --- a/engine/apps/mobile_app/tests/test_your_going_oncall_notification.py +++ b/engine/apps/mobile_app/tests/test_your_going_oncall_notification.py @@ -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, From 18390993b8e32cb0aba6b00a585f5ebaeb18213e Mon Sep 17 00:00:00 2001 From: Vadim Stepanov Date: Wed, 24 May 2023 11:42:59 +0100 Subject: [PATCH 14/24] Update CHANGELOG.md --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7075d04b..657990d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,16 +5,19 @@ 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) +## Unreleased ### Changed - Phone provider refactoring [#1713](https://github.com/grafana/oncall/pull/1713) +## v1.2.28 (2023-05-24) + ### 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)) +- Remove user defined time period from "you're going oncall" mobile push by @iskhakov ([#2001](https://github.com/grafana/oncall/pull/2001)) ## v1.2.27 (2023-05-23) From 934c8e7e64fe4870f19bb2b1b1acda6f5fdf0650 Mon Sep 17 00:00:00 2001 From: Vadim Stepanov Date: Wed, 24 May 2023 11:43:13 +0100 Subject: [PATCH 15/24] Update CHANGELOG.md --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 657990d2..796b3afa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,6 @@ 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) From e9ae4cc3e98cb6f13ea22071f3bd1d3c048eeb12 Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Wed, 24 May 2023 14:11:21 +0300 Subject: [PATCH 16/24] Docs restructure (#1703) Restructured docs. Based on: https://github.com/grafana/oncall-private/issues/1698 --- docs/sources/alert-behavior/_index.md | 24 ------ docs/sources/calendar-schedules/_index.md | 64 -------------- .../web-schedule/calendar-export/index.md | 74 ---------------- .../web-schedule/create-schedule/index.md | 77 ----------------- .../_index.md} | 54 +++++++----- docs/sources/escalation-policies/_index.md | 18 ---- .../configure-escalation-chains/index.md | 39 --------- docs/sources/get-started/_index.md | 12 +-- docs/sources/integrations/_index.md | 85 ++++++++++--------- .../index.md | 4 +- .../index.md | 2 +- .../available-integrations/_index.md | 30 ------- .../chatops-integrations/_index.md | 26 ------ .../index.md | 2 +- .../index.md | 6 +- .../configure-jira => jira}/index.md | 4 +- docs/sources/integrations/manual/index.md | 35 ++++++++ .../configure-webhook => webhook}/index.md | 32 +++---- .../configure-zabbix => zabbix}/index.md | 2 +- .../configure-zendesk => zendesk}/index.md | 2 +- .../index.md => jinja2-templating/_index.md} | 36 +++----- .../migration-from-other-tools/_index.md | 18 ++++ docs/sources/mobile-app/_index.md | 6 +- docs/sources/notify/_index.md | 51 +++++++++++ docs/sources/notify/mattermost/index.md | 23 +++++ .../ms-teams}/index.md | 2 +- docs/sources/notify/phone-calls-sms/index.md | 32 +++++++ .../configure-slack => notify/slack}/index.md | 4 +- .../telegram}/index.md | 2 +- docs/sources/on-call-schedules/_index.md | 46 ++++++++++ .../api-terraform-schedule/_index.md | 21 +++++ .../ical-schedules/index.md | 11 ++- .../web-schedule/_index.md | 21 ++--- .../oncall-api-reference/integrations.md | 2 +- docs/sources/open-source/_index.md | 4 +- .../index.md => outgoing-webhooks/_index.md} | 10 +-- .../_index.md | 52 ++---------- 37 files changed, 385 insertions(+), 548 deletions(-) delete mode 100644 docs/sources/alert-behavior/_index.md delete mode 100644 docs/sources/calendar-schedules/_index.md delete mode 100644 docs/sources/calendar-schedules/web-schedule/calendar-export/index.md delete mode 100644 docs/sources/calendar-schedules/web-schedule/create-schedule/index.md rename docs/sources/{escalation-policies/configure-routes/index.md => escalation-chains-and-routes/_index.md} (56%) delete mode 100644 docs/sources/escalation-policies/_index.md delete mode 100644 docs/sources/escalation-policies/configure-escalation-chains/index.md rename docs/sources/integrations/{available-integrations/configure-alertmanager => alertmanager}/index.md (97%) rename docs/sources/integrations/{available-integrations/configure-appdynamics => appdynamics}/index.md (96%) delete mode 100644 docs/sources/integrations/available-integrations/_index.md delete mode 100644 docs/sources/integrations/chatops-integrations/_index.md rename docs/sources/integrations/{available-integrations/configure-grafana-alerting => grafana-alerting}/index.md (98%) rename docs/sources/integrations/{available-integrations/configure-inbound-email => inbound-email}/index.md (89%) rename docs/sources/integrations/{available-integrations/configure-jira => jira}/index.md (95%) create mode 100644 docs/sources/integrations/manual/index.md rename docs/sources/integrations/{available-integrations/configure-webhook => webhook}/index.md (71%) rename docs/sources/integrations/{available-integrations/configure-zabbix => zabbix}/index.md (99%) rename docs/sources/integrations/{available-integrations/configure-zendesk => zendesk}/index.md (96%) rename docs/sources/{alert-behavior/alert-templates/index.md => jinja2-templating/_index.md} (83%) create mode 100644 docs/sources/migration-from-other-tools/_index.md create mode 100644 docs/sources/notify/_index.md create mode 100644 docs/sources/notify/mattermost/index.md rename docs/sources/{integrations/chatops-integrations/configure-teams => notify/ms-teams}/index.md (98%) create mode 100644 docs/sources/notify/phone-calls-sms/index.md rename docs/sources/{integrations/chatops-integrations/configure-slack => notify/slack}/index.md (97%) rename docs/sources/{integrations/chatops-integrations/configure-telegram => notify/telegram}/index.md (98%) create mode 100644 docs/sources/on-call-schedules/_index.md create mode 100644 docs/sources/on-call-schedules/api-terraform-schedule/_index.md rename docs/sources/{calendar-schedules => on-call-schedules}/ical-schedules/index.md (90%) rename docs/sources/{calendar-schedules => on-call-schedules}/web-schedule/_index.md (87%) rename docs/sources/{alert-behavior/outgoing-webhooks/index.md => outgoing-webhooks/_index.md} (89%) rename docs/sources/{configure-user-settings => user-and-team-management}/_index.md (66%) diff --git a/docs/sources/alert-behavior/_index.md b/docs/sources/alert-behavior/_index.md deleted file mode 100644 index 2a2ffa82..00000000 --- a/docs/sources/alert-behavior/_index.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -aliases: - - /docs/oncall/latest/alert-behavior/ -canonical: https://grafana.com/docs/oncall/latest/alert-behavior/ -title: Configure alert behavior for Grafana OnCall -weight: 900 ---- - -# Configure alert behavior for Grafana OnCall - -The available alert configurations in Grafana OnCall allow you to define how certain alerts are handled and ensure that -alerts are routed, escalated, and grouped to fit your specific alerting needs. Grafana OnCall can receive alerts from -any monitoring system that sends alerts via webhook. - -## About alert behavior - -Once Grafana OnCall receives an alert, the following occurs, based on the alert content: - -- Default or customized alert templates are applied to deliver the most useful alert fields with the most valuable information, - in a readable format. -- Alerts are grouped based on your alert grouping configurations, combining similar or related alerts to reduce alert noise. -- Alerts automatically resolve if an alert from the monitoring system matches the resolve condition for that alert. - -{{< section >}} diff --git a/docs/sources/calendar-schedules/_index.md b/docs/sources/calendar-schedules/_index.md deleted file mode 100644 index 8b7a3cb7..00000000 --- a/docs/sources/calendar-schedules/_index.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -title: On-call schedules -aliases: - - /docs/oncall/latest/calendar-schedules/ -canonical: https://grafana.com/docs/oncall/latest/calendar-schedules/ -description: "Learn more about on-call schedules" -keywords: - - Grafana - - oncall - - schedule - - calendar -weight: 1100 ---- - -# On-call schedules - -Grafana OnCall makes it easier to establish consistent and thoughtful on-call coverage while ensuring that alerts don’t -go unnoticed. Use Grafana OnCall to: - -- Define coverage needs and avoid gaps in coverage -- Automate alert escalation -- Configure on-call shift notifications - -This section provides conceptual information about Grafana OnCall schedule options. - -## About on-call schedules - -An on-call schedule consist of one or more rotations that contain on-call shifts. A schedule must be referenced in the -corresponding escalation chain for alert notifications to be sent to an on-call user. - -A fully configured on-call schedule consists of three main components: - -- **Rotations**: A recurring schedule containing a set of on-call shifts that users rotate through. -- **On-call shifts**: The period of time that an individual user is on-call for a particular rotation -- **Escalation Chains**: Automated steps that determine who to notify of an alert group. - -## Types of on-call schedules - -On-call schedules look different for different organizations and even teams. Grafana OnCall offers three different -options for managing your on-call schedules, so you can choose the option that best fits your needs. - -### Web-based schedule - -Configure and manage on-call schedules directly in the Grafana OnCall plugin. Easily configure and preview rotations, -see teammates' time zones, and add overrides. - -Learn more about [Web-based schedules]({{< relref "web-schedule" >}}) - -### iCal import - -Use any calendar service that uses the iCal format to manage and customize on-call schedules - Import rotations and -shifts from your calendar app to Grafana OnCall for widely accessible scheduling. iCal imports appear in Grafana -OnCall as read-only schedules but can be leveraged similarly to a web-based schedule. - -Learn more about [iCal import schedules]({{< relref "ical-schedules" >}}) - -### Terraform - -Use the Grafana OnCall Terraform provider to manage schedules within your “as-code” workflow. Rotations configured -via Terraform are automatically added to your schedules in Grafana OnCall. Similar to the iCal import, these schedules -read-only and cannot be edited from the UI. - -To learn more, read our [Get started with Grafana OnCall and Terraform]( -) blog post. diff --git a/docs/sources/calendar-schedules/web-schedule/calendar-export/index.md b/docs/sources/calendar-schedules/web-schedule/calendar-export/index.md deleted file mode 100644 index 08503744..00000000 --- a/docs/sources/calendar-schedules/web-schedule/calendar-export/index.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -title: Export on-call schedules -aliases: - - /docs/oncall/latest/calendar-schedules/web-schedule/calendar-export/ -canonical: https://grafana.com/docs/oncall/latest/calendar-schedules/web-schedule/calendar-export/ -description: "Learn how to export an on-call schedule from Grafana OnCall" -keywords: - - Grafana - - oncall - - on-call - - calendar - - iCal export -weight: 500 ---- - -# Export on-call schedules - -Export on-call schedules from Grafana OnCall to your preferred calendar app with a one-time secret iCal URL. -The schedule export allows you to add on-call schedules to your existing calendar to view on-call shifts alongside the -rest of your schedule. - -There are two schedule export options available: - -- **On-call schedule export** - Exports all on-call shifts for a particular schedule, including rotations, overrides, -and assigned users. -- **User-specific schedule export** - Exports assigned on-call shifts for a particular user. Use this export option to -add your assigned on-call shifts to your calendar. - -> **Note:** Calendar exports include all scheduled shifts, including those which are lower priority or overridden. - -## Export an on-call schedule - -Use this export option to add all on-call shifts associated with a schedule to a calendar. Best for a team or shared -calendars. - -To export a schedule from Grafana OnCall: - -1. In Grafana OnCall, navigate to the **Schedules** tab. -1. Open the schedule you’d like to export by clicking on the schedule name. -1. Click **Export** in the upper right corner, then click **+ Create iCal link** to generate a secret iCal URL. -1. Copy the iCal link and store it somewhere you’ll remember. Once you close the schedule export window, you won't be -able to access the iCal link. -1. Open your calendar settings to add a calendar from a URL (This step varies based on your calendar app). - -## Export a user on-call schedule - -Use this export option to add your assigned on-call shifts to your calendar. Best for personal calendars. - -To export your on-call schedule: - -1. In Grafana OnCall, navigate to the **Users** tab. -1. Click **View my profile** in the upper right corner. -1. From the **User Info** tab, navigate to the iCal link section. -1. Click **+ Create iCal link** to generate your secret iCal URL. -1. Copy the iCal link and store it somewhere you’ll remember. Once you close your user profile, you won't be able to -access the iCal link again. -1. Open your calendar settings to add a calendar from a URL (This step varies based on your calendar app). - -## Revoke an iCal export link - -iCal links are displayed upon creation, and users are advised to copy their link and store it for future reference. -To ensure the security of your and your teams' calendar data, after an iCal link is generated, the link is hidden and -cannot be accessed again. - -If you need to revoke an iCal link, you can do so anytime. By doing so, any calendar that references the revoked link -will lose access to the calendar data. - -To revoke an active iCal link: - -1. Navigate to the schedule or user profile associated with the iCal link. -1. For schedules, click **Export** to open the Schedule export window. -1. For users, navigate to the iCal link section of the **User info** tab. -1. If there is an active iCal link, click **Revoke iCal link**. -1. Once revoked, you can generate a new iCal link by clicking **+ Create iCal link**. diff --git a/docs/sources/calendar-schedules/web-schedule/create-schedule/index.md b/docs/sources/calendar-schedules/web-schedule/create-schedule/index.md deleted file mode 100644 index d2856451..00000000 --- a/docs/sources/calendar-schedules/web-schedule/create-schedule/index.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -title: Create on-call schedules -aliases: - - /docs/oncall/latest/calendar-schedules/web-schedule/create-schedule/ -canonical: https://grafana.com/docs/oncall/latest/calendar-schedules/web-schedule/create-schedule/ -description: "Create on-call schedules with Grafana OnCall" -keywords: - - Grafana - - oncall - - on-call - - schedule - - calendar -weight: 300 ---- - -# Create on-call schedules in Grafana OnCall - -Schedules allow you to map out recurring on-call coverage and automate the escalation of alert notifications to -currently on-call users. With Grafana OnCall, you can customize rotations with a live schedule preview to visualize -your schedule, add users, reorder users, and reference teammates' time zones. - -To learn more, see [On-call schedules]({{< relref "../../../calendar-schedules" >}}) which provides the fundamental -concepts for this task. - ->**Note:** User working hours are currently hardcoded and cannot be changed. Profile settings to configure this and other options will be added in a future release. - -## Before you begin - -- Users with Admin or Editor roles can create, edit and delete schedules. -- Users with Viewer role cannot receive alert notifications, therefore, cannot be on-call. - -For more information about permissions, refer to -[Manage users and teams for Grafana OnCall]({{< relref "../../../configure-user-settings" >}}) - -## Create an on-call schedule - -To create a new on-call schedule: - -1. In Grafana OnCall, navigate to the **Schedules** tab and click **+ New schedule** -1. Navigate to **Set up on-call rotation schedule** and click **+ Create** -1. Provide a name and review available schedule settings -1. When you’re done, click **Create Schedule** - ->**Note:** You can edit your schedule settings at any time. - -### Add a rotation to your on-call schedule - -After creating your schedule, you can add rotations to build out your coverage needs. -Think of a rotation as a recurring schedule containing on-call shifts that users rotate through. - -To add a rotation to an on-call schedule: - -1. From your newly created schedule, click **+ Add rotation** and select **New Layer**. -1. Complete the rotation creation form according to your rotation parameters. -1. Add users to the rotation from the dropdown. -You can separate users into user groups to rotate through individual users per shift. -User groups that contain -multiple users results in all users in the group being included in corresponding shifts. -1. When you’re satisfied with the rotation preview, click **Create**. - -### Add an on-call schedule to escalation chains - -Now that you’ve created your schedule, it must be referenced in the steps of an escalation chain for on-call users -to receive alert notifications. - -To connect a schedule to an escalation chain: - -1. In Grafana OnCall, go to the **Escalation Chains** tab. -1. Navigate to an existing escalation chain or click **+ New Escalation Chain**. -1. Select **Notify users from on-call schedule** from the **Add escalation step** dropdown. -1. Specify which notification policy to use and the appropriate schedule. -1. Click and drag the escalation steps to reorder, if needed. - -Escalation chain steps are saved automatically. - -For more information about Escalation Chains, refer to -[Configure and manage Escalation Chains]({{< relref "../../../escalation-policies/configure-escalation-chains" >}}) diff --git a/docs/sources/escalation-policies/configure-routes/index.md b/docs/sources/escalation-chains-and-routes/_index.md similarity index 56% rename from docs/sources/escalation-policies/configure-routes/index.md rename to docs/sources/escalation-chains-and-routes/_index.md index b4475fd2..5045fb28 100644 --- a/docs/sources/escalation-policies/configure-routes/index.md +++ b/docs/sources/escalation-chains-and-routes/_index.md @@ -1,22 +1,40 @@ --- aliases: - - /docs/oncall/latest/escalation-policies/configure-routes/ -canonical: https://grafana.com/docs/oncall/latest/escalation-policies/configure-routes/ -keywords: - - Grafana Cloud - - Alerts - - Notifications - - on-call - - amixr - - oncall - - integrations -title: Configure and manage routes -weight: 300 + - /docs/oncall/latest/escalation-chains-and-routes/ +canonical: https://grafana.com/docs/oncall/latest/escalation-chains-and-routes/ +title: Escalation Chains and Routes +weight: 600 --- -# Configure and manage Routes +# Escalation Chains and Routes -Set up escalation chains and routes to configure escalation behavior for alert group notifications. +Escalation chains and routes for Grafana OnCall + +Administrators can create escalation policies to automatically send alert group notifications to recipients. +These policies define how, where, and when to send notifications. + +Escalation policies dictate how users and groups are notified when an alert notification is created. They can be very +simple, or very complex. You can define as many escalation configurations for an integration as you need, and you can +send notifications for certain alerts to a designated place when certain conditions are met, or not met. + +Escalation policies have three main parts: + +- User settings, where a user sets up their preferred or required notification method. +- An **escalation chain**, which can have one or more steps that are followed in order when a notification is triggered. +- A **route**, that allows administrators to manage notifications by flagging expressions in an alert payload. + +## Escalation chains + +An escalation chain can have many steps, or only one step. For example, steps can be configured to notify multiple users +in some order, notify users that are scheduled for on-call shifts, ping groups in Slack, use outgoing webhooks to +integrate with other services, such as JIRA, and do a number of other automated notification tasks. + +## Routes + +An escalation workflow can employ **routes** that administrators can configure to filter alerts by regular expressions +(outdated) or Jinja2 templates +in their payloads. Notifications for these alerts can be sent to individuals, or they can make use of a new +or existing escalation chain. ## Configure escalation chains @@ -59,14 +77,10 @@ specify using a Jinja template that matches content in the payload body of the f specify where to send the notification for each route. For example, you can send notifications for alerts with `{{ payload.severity == "critical" and payload.service == -"database" }}` in the payload to an escalation chain called `Bob_OnCall`. You can create a different route for alerts +"database" }}` [(Check Jinja2 reference)]({{< relref "jinja2-templating" >}}) in the payload to an escalation chain +called `Bob_OnCall`. You can create a different route for alerts with the payload `{{ "synthetic-monitoring-dev-" in payload.namespace }}` and select a escalation chain called `NotifySecurity`. -Alternatively you can use regular expressions, e.g. `\"severity\": \"critical\"` or `\"namespace\" *: -*\"synthetic-monitoring-dev-.*\"` - -You can set up escalation steps for each route in a chain. - > **NOTE:** When you modify an escalation chain or a route, it will modify that escalation chain across > all integrations that use it. diff --git a/docs/sources/escalation-policies/_index.md b/docs/sources/escalation-policies/_index.md deleted file mode 100644 index 02dc9d96..00000000 --- a/docs/sources/escalation-policies/_index.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -aliases: - - /docs/oncall/latest/escalation-policies/ -canonical: https://grafana.com/docs/oncall/latest/escalation-policies/ -title: Escalation Chains and Routes -weight: 700 ---- - -# Escalation Chains and Routes - -Escalation chains and routes for Grafana OnCall - -Administrators can create escalation policies to automatically send alert group notifications to recipients. -These policies define how, where, and when to send notifications. - -See the following topics for more information: - -{{< section >}} diff --git a/docs/sources/escalation-policies/configure-escalation-chains/index.md b/docs/sources/escalation-policies/configure-escalation-chains/index.md deleted file mode 100644 index a5cb85bf..00000000 --- a/docs/sources/escalation-policies/configure-escalation-chains/index.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -aliases: - - /docs/oncall/latest/escalation-policies/configure-escalation-chains/ -canonical: https://grafana.com/docs/oncall/latest/escalation-policies/configure-escalation-chains/ -keywords: - - Grafana Cloud - - Alerts - - Notifications - - on-call - - amixr - - oncall - - integrations -title: Configure and manage Escalation Chains -weight: 100 ---- - -# Configure and manage Escalation Chains - -Escalation policies dictate how users and groups are notified when an alert notification is created. They can be very -simple, or very complex. You can define as many escalation configurations for an integration as you need, and you can -send notifications for certain alerts to a designated place when certain conditions are met, or not met. - -Escalation policies have three main parts: - -- User settings, where a user sets up their preferred or required notification method. -- An **escalation chain**, which can have one or more steps that are followed in order when a notification is triggered. -- A **route**, that allows administrators to manage notifications by flagging expressions in an alert payload. - -## Escalation chains - -An escalation chain can have many steps, or only one step. For example, steps can be configured to notify multiple users -in some order, notify users that are scheduled for on-call shifts, ping groups in Slack, use outgoing webhooks to -integrate with other services, such as JIRA, and do a number of other automated notification tasks. - -## Routes - -An escalation workflow can employ **routes** that administrators can configure to filter alerts by regular expressions -in their payloads. Notifications for these alerts can be sent to individuals, or they can make use of a new -or existing escalation chain. diff --git a/docs/sources/get-started/_index.md b/docs/sources/get-started/_index.md index 8c6987ab..6aaf394e 100644 --- a/docs/sources/get-started/_index.md +++ b/docs/sources/get-started/_index.md @@ -24,7 +24,7 @@ The following diagram details an example alert workflow with Grafana OnCall: These procedures introduce you to initial Grafana OnCall configuration steps, including monitoring system integration, -how to set up escalation chains, and how to use your calendar service for on-call scheduling. +how to set up escalation chains, and how to set up calendar for on-call scheduling. ## Before you begin @@ -68,7 +68,7 @@ For more information on Grafana OnCall integrations and further configuration gu ### Learn Alert Flow -All Alerts in OnCall are grouped to Alert Groups ([read more about Grouping ID]({{< relref "../alert-behavior/alert-templates" >}})). Alert Group could have mutually +All Alerts in OnCall are grouped to Alert Groups ([read more about Grouping ID]({{< relref "jinja2-templating" >}})). Alert Group could have mutually exclusive states: - **Firing:** Once Alert Group is registered, Escalation Policy associated with it is getting started. Escalation policy will work while Alert Group is in this status. @@ -112,7 +112,7 @@ To configure Escalation Chains: Alerts from this integration will now follow the escalation steps configured in your Escalation Chain. For more information on Escalation Chains and more ways to customize them, refer to -[Configure and manage Escalation Chains]({{< relref "../escalation-policies/configure-escalation-chains" >}}) +[Configure and manage Escalation Chains]({{< relref "escalation-chains-and-routes" >}}) ## Get notified of an alert @@ -124,7 +124,7 @@ policies, chatops integrations, and on-call schedules allow you to automate how Personal notification policies determine how a user is notified for a certain type of alert. Get notified by SMS, phone call, or Slack mentions. Administrators can configure how users receive notification for certain types of alerts. For more information on personal notification policies, refer to -[Manage users and teams for Grafana OnCall]({{< relref "../configure-user-settings" >}}) +[Manage users and teams for Grafana OnCall]({{< relref "user-and-team-management" >}}) To configure users personal notification policies: @@ -148,7 +148,7 @@ To configure Slack for Grafana OnCall: 6. Ensure users verify their Slack accounts in their user profile in Grafana OnCall. For further instruction on connecting to your Slack workspace, refer to -[Slack integration for Grafana OnCall]({{< relref "../integrations/chatops-integrations/configure-slack/" >}}) +[Slack integration for Grafana OnCall]({{< relref "../notify/slack/" >}}) ### Add your on-call schedule @@ -163,4 +163,4 @@ To integrate your on-call calendar with Grafana OnCall: 4. Configure the rest of the schedule settings and click Create Schedule For more information on on-call schedules, refer to -[Configure and manage on-call schedules]({{< relref "../calendar-schedules" >}}) +[Configure and manage on-call schedules]({{< relref "on-call-schedules" >}}) diff --git a/docs/sources/integrations/_index.md b/docs/sources/integrations/_index.md index e575df09..da9dec7d 100644 --- a/docs/sources/integrations/_index.md +++ b/docs/sources/integrations/_index.md @@ -1,7 +1,7 @@ --- aliases: - - /docs/oncall/latest/integrations/ -canonical: https://grafana.com/docs/oncall/latest/integrations/ + - /docs/oncall/latest/integration-with-alert-sources/ +canonical: https://grafana.com/docs/oncall/latest/integration-with-alert-sources/ keywords: - Grafana Cloud - Alerts @@ -10,26 +10,35 @@ keywords: - amixr - oncall - integrations -title: Grafana OnCall integrations +title: Integrations weight: 500 --- -# Grafana OnCall integrations +# Integrations -Integrations allow you to connect monitoring systems of your choice to send alerts to Grafana OnCall. Regardless of where -your alerts originate, you can configure alerts to be sent to Grafana OnCall for alert escalation and notification. -Grafana OnCall receives alerts in JSON format via a POST request, OnCall then parses alert data using preconfigured -alert templates to determine alert grouping, apply routes, and determine correct escalation. +"Integration" is a main entry point for alerts being consumed by OnCall. Rendering, grouping and routing are configured +within integrations. -There are many integrations that are directly supported by Grafana OnCall. Those that aren’t currently listed in the -Integrations menu can be connected using the webhook integration and configured alert templates. +"Integration" is a set of Jinja2 templates which is transforming alert payload to the format suitable to OnCall. +You could check pre-configured templates in the list of avaliable integrations (Integrations -> +"New integration to receive alerts"), create your own or adjust existing. + +Read more about Jinja2 templating used in OnCall [here]({{< relref "jinja2-templating" >}}). + +Alert flow within integration: + +1. Alert is registered by unique integration url (or [e-mail]({{< relref "inbound-email" >}}) in case of inbound e-mail +integration) +2. If there is a non-resolved "alert group" with the same "grouping id", alert will be added to this "alert group". +3. If there is no non-resolved "alert group" with the same "grouping id", new "alert group" will be issued. +4. New "alert group" will be routed using routing engine and escalation chain will be started (TODO: link). ## Configure and manage integrations You can configure and manage your integrations from the **Integrations** tab in Grafana OnCall. The following sections describe how to configure and customize your integrations to ensure alerts are treated appropriately. -### Connect an integration to Grafana OnCall +### Connect an integration To configure an integration for Grafana OnCall: @@ -38,40 +47,42 @@ To configure an integration for Grafana OnCall: 3. Follow the configuration steps on the integration settings page. 4. Complete any necessary configurations in your tool to send alerts to Grafana OnCall. -### Manage Grafana OnCall integrations +### Manage integrations To manage existing integrations, navigate to the **Integrations** tab in Grafana OnCall and select the integration you want to manage. -#### Customize alert templates and grouping +#### Manage integration behaviour and rendering -To customize the alert template for an integration: +"Integration templates" are Jinja2 templates which are applied to each alert to define it's rendering and behaviour. +For templates editor: -1. Select an integration from your list of enabled integrations in the **Integrations** tab. -2. Click **Change alert template and grouping**. -3. Select a template to edit from the **Edit template for** dropdown menu. -4. Edit alert templates as needed to customize the fields and content rendered for an alert. +1. Navigate to the **Integrations** tab, select an integration from the list. +2. Click the **gear icon** next to the integration name. -To customize alert grouping for an integration: +Here are a few templates responsible for alert group formation: -1. Click **Change alert template and grouping**. -2. Select **Alert Behavior** from the dropdown menu next to **Edit template for**. -3. Edit the **grouping id**, **acknowledge condition**, and **resolve condition** templates as needed to customize - your alert behavior. +- **Alert Behaviour, Grouping id** - defining how alerts will be grouped into alert groups. Alerts with the same result +- of executing of this template will be grouped together. For example: -For more information on alert templates, see -[Configure alerts templates]({{< relref "../alert-behavior/alert-templates" >}}) +Alert 1 payload:`{"name": "CPU 90%", "cluster": "EU"}` -#### Add Routes +Alert 2 payload:`{"name": "CPU 90%", "cluster": "US"}` -To add a route to an integration using regular expression: +If we want to group them together by name, we could use template `{{ payload.name }}` which will result to the equal +grouping id "CPU 90%". If we want to group them by region and end up with 2 separate alert groups, we could use such a +template: `{{ payload.region }}}` -1. Select an integration from your list of enabled integrations in the **Integrations** tab. -2. Click **+ Add Route**. -3. Use python style regex to match on your alert content. -4. Click **Create Route**. -5. Select an escalation chain for “**IF** alert payload matches regex” and “**ELSE**” to specify where to route each - type of alert. +- **Alert Behaviour, Acknowledge Condition** - If this template will be rendered as "True" or "1", containing alert +- group will change it's state to "acknowledged". + +- **Alert Behaviour, Resolve Condition** - Similar to Acknowledge Condition, will make alert group "resolved". + +- **Alert Behaviour, Source Link** - result of rendering of this template will be used in various places of the UI. +Should point to the most specific place in the alert source related to the alert group. Also rendering result will be +available in other templates as a variable `{{ source_link }}`. + +Read more about Jinja2 (TODO: link) in a specific section. #### Edit integration name @@ -81,12 +92,4 @@ To edit the name of an integration: 2. Click the **pencil icon** next to the integration name. 3. Provide a new name and click **Update**. -#### Delete integration - -To delete an integration: - -1. Select an integration from your list of enabled integrations in the **Integrations** tab. -2. Click the **trash can** icon next to the selected integration. -3. Confirm by clicking **Delete**. - {{< section >}} diff --git a/docs/sources/integrations/available-integrations/configure-alertmanager/index.md b/docs/sources/integrations/alertmanager/index.md similarity index 97% rename from docs/sources/integrations/available-integrations/configure-alertmanager/index.md rename to docs/sources/integrations/alertmanager/index.md index b43a5aa4..df0e6f5d 100644 --- a/docs/sources/integrations/available-integrations/configure-alertmanager/index.md +++ b/docs/sources/integrations/alertmanager/index.md @@ -10,7 +10,7 @@ keywords: - on-call - Alertmanager - Prometheus -title: Alertmanager integration for Grafana OnCall +title: Alertmanager weight: 300 --- @@ -69,4 +69,4 @@ Alertmanager offers three alert grouping options: - Grafana OnCall groups alerts based on the first label of each alert. - Grafana OnCall marks an alert group as resolved only when there are fewer than 500 grouped - alerts, and every `firing` alert with the same labels has a corresponding `resolved` alert + alerts, and every `firing` alert with the same labels has a corresponding `resolved` alert. diff --git a/docs/sources/integrations/available-integrations/configure-appdynamics/index.md b/docs/sources/integrations/appdynamics/index.md similarity index 96% rename from docs/sources/integrations/available-integrations/configure-appdynamics/index.md rename to docs/sources/integrations/appdynamics/index.md index acf0a8e1..6740458f 100644 --- a/docs/sources/integrations/available-integrations/configure-appdynamics/index.md +++ b/docs/sources/integrations/appdynamics/index.md @@ -9,7 +9,7 @@ keywords: - Notifications - on-call - AppDynamics -title: AppDynamics integration for Grafana OnCall +title: AppDynamics weight: 500 --- diff --git a/docs/sources/integrations/available-integrations/_index.md b/docs/sources/integrations/available-integrations/_index.md deleted file mode 100644 index 4babb8e2..00000000 --- a/docs/sources/integrations/available-integrations/_index.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -aliases: - - /docs/oncall/latest/integrations/available-integrations/ -canonical: https://grafana.com/docs/oncall/latest/integrations/available-integrations/ -keywords: - - Grafana Cloud - - Alerts - - Notifications - - on-call - - Alertmanager - - Prometheus -title: Currently available integrations for Grafana OnCall -weight: 100 ---- - -# Available integrations - -Grafana OnCall can connect directly to the monitoring services where your alerts originate. All currently available -integrations are listed in the Grafana OnCall **Create Integration** section. - -If the integration you're looking for isn't currently listed, see -[Webhook integrations for Grafana OnCall]({{< relref "../available-integrations/configure-webhook" >}}) to integration -your monitoring system with Grafana OnCall. - -> **Note:** Some integrations are available for Grafana Cloud instances only. See individual integration -> guides for more information. - -The following integrations are currently available for Grafana OnCall and have documentation: - -{{< section >}} diff --git a/docs/sources/integrations/chatops-integrations/_index.md b/docs/sources/integrations/chatops-integrations/_index.md deleted file mode 100644 index 038042aa..00000000 --- a/docs/sources/integrations/chatops-integrations/_index.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -aliases: - - ../chat-options/ - - /docs/oncall/latest/integrations/chatops-integrations/ -canonical: https://grafana.com/docs/oncall/latest/integrations/chatops-integrations/ -keywords: - - Grafana Cloud - - Alerts - - Notifications - - on-call - - amixr - - oncall - - slack -title: Available ChatOps integrations -weight: 300 ---- - -# Available ChatOps integrations - -Grafana OnCall directly supports the export of alert notifications to some popular messaging applications like Slack and -Telegram. You can use outgoing webhooks to applications that aren't directly supported. For information on configuring -outgoing webhooks, see [Send alert group notifications by webhook]({{< relref "../../alert-behavior/outgoing-webhooks/" >}}). - -To configure supported messaging apps, see the following topics: - -{{< section >}} diff --git a/docs/sources/integrations/available-integrations/configure-grafana-alerting/index.md b/docs/sources/integrations/grafana-alerting/index.md similarity index 98% rename from docs/sources/integrations/available-integrations/configure-grafana-alerting/index.md rename to docs/sources/integrations/grafana-alerting/index.md index f6beab61..776fed61 100644 --- a/docs/sources/integrations/available-integrations/configure-grafana-alerting/index.md +++ b/docs/sources/integrations/grafana-alerting/index.md @@ -9,7 +9,7 @@ keywords: - Notifications - on-call - Prometheus -title: Grafana Alerting integration for Grafana OnCall +title: Grafana Alerting weight: 100 --- diff --git a/docs/sources/integrations/available-integrations/configure-inbound-email/index.md b/docs/sources/integrations/inbound-email/index.md similarity index 89% rename from docs/sources/integrations/available-integrations/configure-inbound-email/index.md rename to docs/sources/integrations/inbound-email/index.md index 7af163d4..c96fce3c 100644 --- a/docs/sources/integrations/available-integrations/configure-inbound-email/index.md +++ b/docs/sources/integrations/inbound-email/index.md @@ -1,6 +1,6 @@ --- aliases: - - add-inbound-email/ + - inbound-email/ - /docs/oncall/latest/integrations/available-integrations/configure-inbound-email/ canonical: https://grafana.com/docs/oncall/latest/integrations/available-integrations/configure-inbound-email/ keywords: @@ -9,7 +9,7 @@ keywords: - Notifications - on-call - Email -title: Inbound Email integration for Grafana OnCall +title: Inbound Email weight: 500 --- @@ -28,7 +28,7 @@ You must have an Admin role to create integrations in Grafana OnCall. ## Grouping and auto-resolve Alert groups will be grouped by email subject and auto-resolved if the email message text equals "OK". - This behaviour can be modified via custom templates. + This behaviour can be modified via [custom templates]({{< relref "jinja2-templating" >}}). Alerts from Inbound Email integration have followng payload: diff --git a/docs/sources/integrations/available-integrations/configure-jira/index.md b/docs/sources/integrations/jira/index.md similarity index 95% rename from docs/sources/integrations/available-integrations/configure-jira/index.md rename to docs/sources/integrations/jira/index.md index 15f7025f..ad11b249 100644 --- a/docs/sources/integrations/available-integrations/configure-jira/index.md +++ b/docs/sources/integrations/jira/index.md @@ -1,6 +1,6 @@ --- aliases: - - add-jira/ + - jira/ - /docs/oncall/latest/integrations/available-integrations/configure-jira/ canonical: https://grafana.com/docs/oncall/latest/integrations/available-integrations/configure-jira/ keywords: @@ -9,7 +9,7 @@ keywords: - Notifications - on-call - Jira -title: Jira integration for Grafana OnCall +title: Jira weight: 500 --- diff --git a/docs/sources/integrations/manual/index.md b/docs/sources/integrations/manual/index.md new file mode 100644 index 00000000..a52e73af --- /dev/null +++ b/docs/sources/integrations/manual/index.md @@ -0,0 +1,35 @@ +--- +aliases: + - /docs/oncall/latest/integrations/manual/ +canonical: https://grafana.com/docs/oncall/latest/integrations/manual/ +keywords: + - Grafana Cloud + - Alerts + - Notifications + - on-call + - Alertmanager + - Prometheus +title: Raising alerts manually +weight: 300 +--- + +# Raising alerts manually + +Sometimes you need to page a specific person (following their preferred notification policy), or need help from people +in some particular team. In that case you can trigger an alert group providing some context information as well as +defining who to notify about it, a user or the person on-call in a given team's schedule. + +You can create a manual alert group using the "+ Manual alert group" button (in the Alert Groups page), and set +its escalation options to page a specific person or group of people. + +> The same feature is also available as **/escalate** slack command. + +- You need to define a title for your alert, an optional description, and select the responders which could be a +specific user in your team, a particular schedule, or multiple instances of those. +- When selecting a user, a few checks will be performed before adding them to the list of responders: user should have +a notification policy set, and ideally be on-call. +- If the user is not on-call at the time, you will get alternative users to choose instead from the OnCall schedules +that user is part of. You can still page the original user if you confirm that is what you want. +- When selecting a schedule, the user(s) on-call when the alert is triggered will be notified. + +> **NOTE:** for each responder (user or schedule) you can choose the notification policy to use: default or important. diff --git a/docs/sources/integrations/available-integrations/configure-webhook/index.md b/docs/sources/integrations/webhook/index.md similarity index 71% rename from docs/sources/integrations/available-integrations/configure-webhook/index.md rename to docs/sources/integrations/webhook/index.md index d3cf8632..3052e5cb 100644 --- a/docs/sources/integrations/available-integrations/configure-webhook/index.md +++ b/docs/sources/integrations/webhook/index.md @@ -10,11 +10,11 @@ keywords: - on-call - Alertmanager - Prometheus -title: Webhook integration for Grafana OnCall +title: Inbound Webhook weight: 700 --- -# Webhook integrations for Grafana OnCall +# Inbound Webhook integrations for Grafana OnCall Grafana OnCall directly supports many integrations, those that aren’t currently listed in the Integrations menu can be connected using the webhook integration and configured alert templates. @@ -46,19 +46,19 @@ To configure a webhook integration: For example: - ```json - curl -X POST \ - https://a-prod-us-central-0.grafana.net/integrations/v1/formatted_webhook/m12xmIjOcgwH74UF8CN4dk0Dh/ \ - -H 'Content-Type: Application/json' \ - -d '{ - "alert_uid": "08d6891a-835c-e661-39fa-96b6a9e26552", - "title": "The whole system is down", - "image_url": "https://upload.wikimedia.org/wikipedia/commons/e/ee/Grumpy_Cat_by_Gage_Skidmore.jpg", - "state": "alerting", - "link_to_upstream_details": "https://en.wikipedia.org/wiki/Downtime", - "message": "Smth happened. Oh no!" - }' - ``` +```bash +curl -X POST \ +https://a-prod-us-central-0.grafana.net/integrations/v1/formatted_webhook/m12xmIjOcgwH74UF8CN4dk0Dh/ \ +-H 'Content-Type: Application/json' \ +-d '{ + "alert_uid": "08d6891a-835c-e661-39fa-96b6a9e26552", + "title": "The whole system is down", + "image_url": "https://upload.wikimedia.org/wikipedia/commons/e/ee/Grumpy_Cat_by_Gage_Skidmore.jpg", + "state": "alerting", + "link_to_upstream_details": "https://en.wikipedia.org/wiki/Downtime", + "message": "Smth happened. Oh no!" +}' +``` To learn how to use custom alert templates for formatted webhooks, see -[Configure alerts templates]({{< relref "../../../alert-behavior/alert-templates/" >}}). +[Configure alerts templates]({{< relref "jinja2-templating" >}}). diff --git a/docs/sources/integrations/available-integrations/configure-zabbix/index.md b/docs/sources/integrations/zabbix/index.md similarity index 99% rename from docs/sources/integrations/available-integrations/configure-zabbix/index.md rename to docs/sources/integrations/zabbix/index.md index f237abae..a5359743 100644 --- a/docs/sources/integrations/available-integrations/configure-zabbix/index.md +++ b/docs/sources/integrations/zabbix/index.md @@ -9,7 +9,7 @@ keywords: - Notifications - on-call - Zabbix -title: Zabbix integration for Grafana OnCall +title: Zabbix weight: 500 --- diff --git a/docs/sources/integrations/available-integrations/configure-zendesk/index.md b/docs/sources/integrations/zendesk/index.md similarity index 96% rename from docs/sources/integrations/available-integrations/configure-zendesk/index.md rename to docs/sources/integrations/zendesk/index.md index 7ea5fa80..43321f6e 100644 --- a/docs/sources/integrations/available-integrations/configure-zendesk/index.md +++ b/docs/sources/integrations/zendesk/index.md @@ -9,7 +9,7 @@ keywords: - Notifications - on-call - Zendesk -title: Zendesk integration for Grafana OnCall +title: Zendesk weight: 500 --- diff --git a/docs/sources/alert-behavior/alert-templates/index.md b/docs/sources/jinja2-templating/_index.md similarity index 83% rename from docs/sources/alert-behavior/alert-templates/index.md rename to docs/sources/jinja2-templating/_index.md index 3cbfbf38..f710f101 100644 --- a/docs/sources/alert-behavior/alert-templates/index.md +++ b/docs/sources/jinja2-templating/_index.md @@ -1,40 +1,26 @@ --- aliases: - - ../integrations/create-custom-templates/ - - /docs/oncall/latest/alert-behavior/alert-templates/ -canonical: https://grafana.com/docs/oncall/latest/alert-behavior/alert-templates/ -keywords: - - Grafana Cloud - - Alerts - - Notifications - - on-call - - Jinja -title: Configure alert templates -weight: 300 + - /docs/oncall/latest/jinja2-templating/ +canonical: https://grafana.com/docs/oncall/latest/jinja2-templating/ +title: Jinja2 templating +weight: 1000 --- -# Configure alert templates +## Jinja2 templating Grafana OnCall can integrate with any monitoring systems that can send alerts using webhooks with JSON payloads. By default, webhooks deliver raw JSON payloads. When Grafana OnCall receives an alert and parses its payload, a default -pre configured alert template is applied to modify the alert payload to be more human readable. These alert templates +pre-configured alert template is applied to modify the alert payload to be more human-readable. These alert templates are customizable for any integration. -See Format alerts with alert templates in this document to learn more about how to customize alert templates. - -## Alert Behavior - -Once Grafana OnCall receives an alert, the following occurs, based on the alert content: - -- Default or customized alert templates are applied to deliver the most useful alert fields with the most valuable information, - in a readable format. -- Alerts are grouped based on your alert grouping configurations, combining similar or related alerts to reduce alert noise. -- Alerts automatically resolve if an alert from the monitoring system matches the resolve condition for that alert. + ## Alert payload Alerts received by Grafana OnCall contain metadata as keys and values in a JSON object. The following is an example of -an alert from Grafana OnCall: +an alert received by Grafana OnCall initiated by Grafana Alerting: ```json { @@ -177,3 +163,5 @@ Built-in functions: - `datetimeformat` - converts time from datetime to the given format (`%H:%M / %d-%m-%Y` by default) - `regex_replace` - performs a regex find and replace - `regex_match` - performs a regex match, returns `True` or `False`. Usage example: `{{ payload.ruleName | regex_match(".*") }}` + +{{< section >}} diff --git a/docs/sources/migration-from-other-tools/_index.md b/docs/sources/migration-from-other-tools/_index.md new file mode 100644 index 00000000..faeed7c8 --- /dev/null +++ b/docs/sources/migration-from-other-tools/_index.md @@ -0,0 +1,18 @@ +--- +title: Migration from other tools +aliases: + - /docs/oncall/latest/migration-from-other-tools/ +keywords: + - Mobile App + - oncall + - notification + - push notification +weight: 1400 +--- + +# Migration from other tools + +## Migration from PagerDuty to Grafana OnCall + +Migration from PagerDuty to Grafana OnCall could be performed in automated way using +[OSS Migrator](https://github.com/grafana/oncall/tree/dev/tools/pagerduty-migrator). diff --git a/docs/sources/mobile-app/_index.md b/docs/sources/mobile-app/_index.md index c9549f6e..dea6546a 100644 --- a/docs/sources/mobile-app/_index.md +++ b/docs/sources/mobile-app/_index.md @@ -1,5 +1,5 @@ --- -title: Grafana OnCall mobile app +title: Mobile App aliases: - /docs/oncall/latest/mobile-app/ keywords: @@ -7,7 +7,7 @@ keywords: - oncall - notification - push notification -weight: 1200 +weight: 1100 --- # Grafana OnCall Mobile App @@ -99,5 +99,3 @@ To receive push notifications from the Grafana OnCall mobile app, you must add t 1. From Grafana OnCall, navigate to the **Users** tab and click **View my profile** 1. In your **User Info** tab, update your Default and Important notification policies to include Mobile push notifications. - -For more information about Notification Policies, refer to [Manage users and teams for Grafana OnCall]({{< relref "../configure-user-settings" >}}) diff --git a/docs/sources/notify/_index.md b/docs/sources/notify/_index.md new file mode 100644 index 00000000..0e45fb00 --- /dev/null +++ b/docs/sources/notify/_index.md @@ -0,0 +1,51 @@ +--- +aliases: + - ../notify/ + - /docs/oncall/latest/notify/ +canonical: https://grafana.com/docs/oncall/latest/notify/ +keywords: + - Grafana Cloud + - Alerts + - Notifications + - on-call + - amixr + - oncall + - slack +title: Notify people +weight: 800 +--- + +# Notify people + +Grafana OnCall directly supports the export of alert notifications to some popular messaging applications like Slack and +Telegram. You can use [outgoing webhooks]({{< relref "outgoing-webhooks" >}}) for applications that aren't directly +supported. + +To configure supported messaging apps, see the following topics: + +{{< section >}} + +## Configure user notification policies + +Notification policies are a configurable set of notification steps that determine how you're notified of alert in OnCall. Users with the Admin or Editor role are +able to receive notifications. +Users can verify phone numbers and email addresses in the **Users** tab of Grafana OnCall. + +- **Default Notifications** dictate how a user is notified for most escalation thresholds. + +- **Important Notifications** are labeled in escalation chains. If an escalation event is marked as an important notification, +it will bypass **Default Notification** settings and notify the user by the method specified. + +> **NOTE**: You cannot add users or manage permissions in Grafana OnCall. User settings are found on the +> organizational level of your Grafana instance in **Configuration > Users**. + +To configure a users notification policy: + +1. Navigate to the **Users** tab of Grafana OnCall and search for or select a user. + +1. Click **Edit** to the right of a user to open the **User Info** window. + +1. Verify that there is a valid and verified phone number, along with ChatOps accounts in order to receive notifications via those methods. + +1. Click **Add notification step** and use the dropdowns to specify the notification method and frequency. Notification steps will be followed in the order they +are listed. diff --git a/docs/sources/notify/mattermost/index.md b/docs/sources/notify/mattermost/index.md new file mode 100644 index 00000000..e2938be4 --- /dev/null +++ b/docs/sources/notify/mattermost/index.md @@ -0,0 +1,23 @@ +--- +aliases: + - ../../notify/mattermost + - /docs/oncall/latest/notify/mattermost/ +canonical: https://grafana.com/docs/oncall/latest/notify/mattermost/ +keywords: + - Grafana Cloud + - Alerts + - Notifications + - on-call + - amixr + - oncall + - slack +title: Mattermost +weight: 100 +--- + +# Mattermost + +Mattermost support is not implemented yet. + +Please join [GitHub Issue](https://github.com/grafana/oncall/issues/96) or +check [PR](https://github.com/grafana/oncall/pull/606). diff --git a/docs/sources/integrations/chatops-integrations/configure-teams/index.md b/docs/sources/notify/ms-teams/index.md similarity index 98% rename from docs/sources/integrations/chatops-integrations/configure-teams/index.md rename to docs/sources/notify/ms-teams/index.md index cc765fb1..226705f7 100644 --- a/docs/sources/integrations/chatops-integrations/configure-teams/index.md +++ b/docs/sources/notify/ms-teams/index.md @@ -12,7 +12,7 @@ keywords: - oncall - MS Team - Microsoft -title: Microsoft Teams integration for Grafana OnCall +title: Microsoft Teams weight: 500 --- diff --git a/docs/sources/notify/phone-calls-sms/index.md b/docs/sources/notify/phone-calls-sms/index.md new file mode 100644 index 00000000..2e14868f --- /dev/null +++ b/docs/sources/notify/phone-calls-sms/index.md @@ -0,0 +1,32 @@ +--- +aliases: + - ../../notify/phone-sms + - /docs/oncall/latest/notify/phone-sms/ +canonical: https://grafana.com/docs/oncall/latest/notify/phone-sms/ +keywords: + - Grafana Cloud + - Alerts + - Notifications + - on-call + - amixr + - oncall + - slack +title: Phone calls and SMS +weight: 100 +--- + +# Phone Calls and SMS notifications + +Grafana OnCall Cloud includes SMS and Phone notifications, OSS users [could leverage]({{< relref "open-source" >}}) Grafana Cloud as a relay or +configure other providers like Twilio. + +## Is there a list of pre-defined phone numbers? + +In order to learn the phone number used by OnCall, make a test call at the "Phone Verification" tab. + +## Phone calls or SMS does not work for me + +There are cases when OnCall is not able to make phone calls or send SMS to certain regions or specific phone numbers. +We're working hard to fix such cases, but kindly asking to test your personal notification chain to make sure OnCall +is able to notify you. Also we suggest to back up Phone Calls and SMS with other notification methods such as +[Mobile App]({{< relref "mobile-app" >}}). diff --git a/docs/sources/integrations/chatops-integrations/configure-slack/index.md b/docs/sources/notify/slack/index.md similarity index 97% rename from docs/sources/integrations/chatops-integrations/configure-slack/index.md rename to docs/sources/notify/slack/index.md index 7e00d3db..925b2209 100644 --- a/docs/sources/integrations/chatops-integrations/configure-slack/index.md +++ b/docs/sources/notify/slack/index.md @@ -11,7 +11,7 @@ keywords: - amixr - oncall - slack -title: Slack integration for Grafana OnCall +title: Slack weight: 100 --- @@ -30,7 +30,7 @@ To install the Slack integration, you must have Admin permissions in your Grafan that you’d like to integrate with. For Open Source Grafana OnCall Slack installation guidance, refer to -[Open Source Grafana OnCall]({{< relref "../../../open-source/" >}}). +[Open Source Grafana OnCall]({{< relref "open-source" >}}). ## Install Slack integration for Grafana OnCall diff --git a/docs/sources/integrations/chatops-integrations/configure-telegram/index.md b/docs/sources/notify/telegram/index.md similarity index 98% rename from docs/sources/integrations/chatops-integrations/configure-telegram/index.md rename to docs/sources/notify/telegram/index.md index e24d87e7..17080b2c 100644 --- a/docs/sources/integrations/chatops-integrations/configure-telegram/index.md +++ b/docs/sources/notify/telegram/index.md @@ -11,7 +11,7 @@ keywords: - amixr - oncall - telegram -title: Telegram integration for Grafana OnCall +title: Telegram weight: 300 --- diff --git a/docs/sources/on-call-schedules/_index.md b/docs/sources/on-call-schedules/_index.md new file mode 100644 index 00000000..65850392 --- /dev/null +++ b/docs/sources/on-call-schedules/_index.md @@ -0,0 +1,46 @@ +--- +title: On-call schedules +aliases: + - /docs/oncall/latest/on-call-schedules/ +canonical: https://grafana.com/docs/oncall/latest/on-call-schedules/ +description: "Learn more about on-call schedules" +keywords: + - Grafana + - oncall + - on-call + - schedule + - calendar +weight: 700 +--- + +## Before you begin + +- Users with Admin or Editor roles can create, edit and delete schedules. +- Users with Viewer role cannot receive alert notifications, therefore, cannot be on-call. + +For more information about permissions, refer to +[Manage users and teams for Grafana OnCall]({{< relref "user-and-team-management" >}}) + +### Web-based schedule + +Configure and manage on-call schedules directly in the Grafana OnCall plugin. Easily configure and preview rotations, +see teammates' time zones, and add overrides. + +Learn more about [Web-based schedules]({{< relref "web-schedule" >}}) + +### iCal import + +Use any calendar service that uses the iCal format to manage and customize on-call schedules - Import rotations and +shifts from your calendar app to Grafana OnCall for widely accessible scheduling. iCal imports appear in Grafana +OnCall as read-only schedules but can be leveraged similarly to a web-based schedule. + +Learn more about [iCal import schedules]({{< relref "ical-schedules" >}}) + +### Terraform + +Use the Grafana OnCall Terraform provider to manage schedules within your “as-code” workflow. Rotations configured +via Terraform are automatically added to your schedules in Grafana OnCall. Similar to the iCal import, these schedules +read-only and cannot be edited from the UI. + +To learn more, read our [Get started with Grafana OnCall and Terraform]( +) blog post. diff --git a/docs/sources/on-call-schedules/api-terraform-schedule/_index.md b/docs/sources/on-call-schedules/api-terraform-schedule/_index.md new file mode 100644 index 00000000..e008838e --- /dev/null +++ b/docs/sources/on-call-schedules/api-terraform-schedule/_index.md @@ -0,0 +1,21 @@ +--- +title: API & Terraform schedules +aliases: + - /docs/oncall/latest/on-call-schedules/api-terraform-schedules/ +canonical: https://grafana.com/docs/oncall/latest/on-call-schedules/api-terraform-schedules/ +keywords: + - Grafana + - oncall + - schedule + - calendar +weight: 100 +--- + +# API & Terraform schedules + +If your schedules became comprehensive, or you would like to distribute the same scheduling patterns through multiple +teams in the org, we suggest considering storing schedules as code. + +- [Get started with Grafana OnCall and Terraform (blogpost)](https://grafana.com/blog/2022/08/29/get-started-with-grafana-oncall-and-terraform/) +- [Grafana Terraform provider reference (OnCall resources are managed using this provider)](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/oncall_schedule) +- [OnCall API]({{< relref "oncall-api-reference" >}}) diff --git a/docs/sources/calendar-schedules/ical-schedules/index.md b/docs/sources/on-call-schedules/ical-schedules/index.md similarity index 90% rename from docs/sources/calendar-schedules/ical-schedules/index.md rename to docs/sources/on-call-schedules/ical-schedules/index.md index cf4c3aeb..79b9dd75 100644 --- a/docs/sources/calendar-schedules/ical-schedules/index.md +++ b/docs/sources/on-call-schedules/ical-schedules/index.md @@ -1,8 +1,8 @@ --- -title: Import on-call schedules +title: iCal on-call schedules aliases: - - /docs/oncall/latest/calendar-schedules/ical-schedules/ -canonical: https://grafana.com/docs/oncall/latest/calendar-schedules/ical-schedules/ + - /docs/oncall/latest/on-call-schedules/ical-schedules/ +canonical: https://grafana.com/docs/oncall/latest/on-call-schedules/ical-schedules/ description: "Learn how to manage on-call schedules with iCal import" keywords: - Grafana @@ -18,6 +18,11 @@ Use your existing calendar app with iCal format to manage and customize on-call from your calendar app to Grafana OnCall for widely accessible scheduling. iCal imported schedules appear in Grafana OnCall as read-only schedules but can be leveraged similarly to a web-based schedule. +> Unfortunately there is a known limitation with Google Calendar import and export. +> Google may take up to 24h to import OnCall's calendar (OnCall -> Google) and sometimes our customers report delay in +> exporting (Google Calendar -> OnCall). If actual calendar is critical for you, we suggest checking +> [web-based scheduling]({{< relref "web-schedule" >}}). + ## Before you begin - Verify that your calendar app supports iCal format diff --git a/docs/sources/calendar-schedules/web-schedule/_index.md b/docs/sources/on-call-schedules/web-schedule/_index.md similarity index 87% rename from docs/sources/calendar-schedules/web-schedule/_index.md rename to docs/sources/on-call-schedules/web-schedule/_index.md index 0f5500e3..582e1579 100644 --- a/docs/sources/calendar-schedules/web-schedule/_index.md +++ b/docs/sources/on-call-schedules/web-schedule/_index.md @@ -1,8 +1,8 @@ --- -title: Web-based schedules +title: Web-based on-call schedules aliases: - - /docs/oncall/latest/calendar-schedules/web-schedule/ -canonical: https://grafana.com/docs/oncall/latest/calendar-schedules/web-schedule/ + - /docs/oncall/latest/on-call-schedules/web-schedule/ +canonical: https://grafana.com/docs/oncall/latest/on-call-schedules/web-schedule/ description: "Learn more about Grafana OnCalls built in schedule tool" keywords: - Grafana @@ -12,19 +12,18 @@ keywords: weight: 100 --- -# About web-based schedules +# Web-based on-call schedules Grafana OnCall allows you to map out recurring on-call coverage and automate the escalation of alert notifications to on-call users. Configure and manage on-call schedules directly in the Grafana OnCall plugin to easily customize rotations with a live schedule preview, reference teammates' time zones, and add overrides. + + This topic provides an overview of key components and features. -For information on how to create a schedule in Grafana OnCall, refer to -[Create an on-call schedule]({{< relref "create-schedule" >}}) - ->**Note**: User permissions determine which components of Grafana OnCall are available to you. - ## Schedule settings Schedule settings are initially configured when a new schedule is created and can be updated at any time by clicking @@ -86,6 +85,4 @@ A perfectly balanced schedule is considered ideal, so reducing this number will ## Schedule export Export on-call schedules from Grafana OnCall to your preferred calendar app with a one-time secret iCal URL. The -schedule export allows you to view on-call shifts alongside the rest of your schedule. - -For more information, refer to [Export on-call schedules]({{< relref "calendar-export" >}}) +schedule export allows you to view on-call shifts alongside the rest of your schedule. diff --git a/docs/sources/oncall-api-reference/integrations.md b/docs/sources/oncall-api-reference/integrations.md index 507841db..a4af73b8 100644 --- a/docs/sources/oncall-api-reference/integrations.md +++ b/docs/sources/oncall-api-reference/integrations.md @@ -75,7 +75,7 @@ The above command returns JSON structured in the following way: Integrations are sources of alerts and alert groups for Grafana OnCall. For example, to learn how to integrate Grafana OnCall with Alertmanager see -[Alertmanager]({{< relref "../integrations/available-integrations/configure-alertmanager/" >}}). +[Alertmanager]({{< relref "../integrations/alertmanager" >}}). **HTTP request** diff --git a/docs/sources/open-source/_index.md b/docs/sources/open-source/_index.md index 9059b9f1..1674e9be 100644 --- a/docs/sources/open-source/_index.md +++ b/docs/sources/open-source/_index.md @@ -4,7 +4,7 @@ aliases: keywords: - Open Source title: Open Source -weight: 300 +weight: 400 --- # Grafana OnCall open source guide @@ -227,7 +227,7 @@ After enabling the email integration, it will be possible to use the `Notify by ## Inbound Email Setup Grafana OnCall is capable of creating alert groups from -[Inbound Email integration]({{< relref "../integrations/available-integrations/configure-inbound-email" >}}). +[Inbound Email integration]({{< relref "../integrations/inbound-email" >}}). To configure Inbound Email integration for Grafana OnCall OSS populate env variables with your Email Service Provider data: diff --git a/docs/sources/alert-behavior/outgoing-webhooks/index.md b/docs/sources/outgoing-webhooks/_index.md similarity index 89% rename from docs/sources/alert-behavior/outgoing-webhooks/index.md rename to docs/sources/outgoing-webhooks/_index.md index 74237bbf..7b80a8da 100644 --- a/docs/sources/alert-behavior/outgoing-webhooks/index.md +++ b/docs/sources/outgoing-webhooks/_index.md @@ -1,8 +1,8 @@ --- aliases: - - ../integrations/configure-outgoing-webhooks/ - - /docs/oncall/latest/alert-behavior/outgoing-webhooks/ -canonical: https://grafana.com/docs/oncall/latest/alert-behavior/outgoing-webhooks/ + - ../outgoing-webhooks/ + - /docs/oncall/latest/outgoing-webhooks/ +canonical: https://grafana.com/docs/oncall/latest/outgoing-webhooks/ keywords: - Grafana Cloud - Alerts @@ -10,8 +10,8 @@ keywords: - on-call - amixr - webhooks -title: Configure outgoing webhooks for Grafana OnCall -weight: 500 +title: Outgoing Webhooks +weight: 900 --- # Configure outgoing webhooks for Grafana OnCall diff --git a/docs/sources/configure-user-settings/_index.md b/docs/sources/user-and-team-management/_index.md similarity index 66% rename from docs/sources/configure-user-settings/_index.md rename to docs/sources/user-and-team-management/_index.md index 4993db89..75682e86 100644 --- a/docs/sources/configure-user-settings/_index.md +++ b/docs/sources/user-and-team-management/_index.md @@ -1,15 +1,12 @@ --- +title: User and team management aliases: - - /docs/oncall/latest/configure-user-settings/ -canonical: https://grafana.com/docs/oncall/latest/configure-user-setting/ + - /docs/oncall/latest/user-and-team-management/ keywords: - - Grafana Cloud - - Permission - - Notifications - - RBAC - - amixr + - Mobile App - oncall -title: Manage users and teams for Grafana OnCall + - notification + - push notification weight: 1300 --- @@ -73,42 +70,3 @@ team, set up multiple routes for the integration, and utilize escalation chains and outgoing webhooks from other teams can also be included in the escalation chain. If a user only has access to the first team and not others, they will be unable to view the resource, which will display as `🔒 Private resource`. This feature enables the distribution of escalations across various teams. - -## Configure user notification policies - -Notification policies are a configurable set of notification steps that determine how you're notified of alert in OnCall. Users with the Admin or Editor role are -able to receive notifications. -Users can verify phone numbers and email addresses in the **Users** tab of Grafana OnCall. - -- **Default Notifications** dictate how a user is notified for most escalation thresholds. - -- **Important Notifications** are labeled in escalation chains. If an escalation event is marked as an important notification, -it will bypass **Default Notification** settings and notify the user by the method specified. - -> **NOTE**: You cannot add users or manage permissions in Grafana OnCall. User settings are found on the -> organizational level of your Grafana instance in **Configuration > Users**. - -To configure a users notification policy: - -1. Navigate to the **Users** tab of Grafana OnCall and search for or select a user. - -1. Click **Edit** to the right of a user to open the **User Info** window. - -1. Verify that there is a valid and verified phone number, along with ChatOps accounts in order to receive notifications via those methods. - -1. Click **Add notification step** and use the dropdowns to specify the notification method and frequency. Notification steps will be followed in the order they -are listed. - -## Configure Telegram user settings in OnCall - -1. In your profile, navigate to Telegram setting and click **Connect**. -1. Click **Connect automatically** for the bot to message you and to bring up your telegram account. -1. Click **Start** when the OnCall bot messages you. - -To connect manually, you can click the URL provided and then **SEND MESSAGE**. In your Telegram account, -click **Start**. - -## Configure Slack user settings in OnCall - -1. In your profile, find the Slack setting and click **Connect**. -1. Follow the instructions to verify your account. From bb4c817a33a76cfc38107108355a76bf33a0a932 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Wed, 24 May 2023 19:18:09 +0800 Subject: [PATCH 17/24] Correct flags of twilio phone provider --- engine/apps/twilioapp/phone_provider.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/engine/apps/twilioapp/phone_provider.py b/engine/apps/twilioapp/phone_provider.py index 09b2480d..b5569622 100644 --- a/engine/apps/twilioapp/phone_provider.py +++ b/engine/apps/twilioapp/phone_provider.py @@ -249,8 +249,8 @@ class TwilioPhoneProvider(PhoneProvider): def flags(self) -> ProviderFlags: return ProviderFlags( configured=not LiveSetting.objects.filter(name__startswith="TWILIO", error__isnull=False).exists(), - test_sms=True, + test_sms=False, test_call=True, - verification_call=True, + verification_call=False, verification_sms=True, ) From a8a0f67db83e67fd61802fc4604bc680ed7e1773 Mon Sep 17 00:00:00 2001 From: Alfredo <109958902+alfredo-d@users.noreply.github.com> Date: Wed, 24 May 2023 19:28:55 +0800 Subject: [PATCH 18/24] Docs: Update user notification setting (#1839) # What this PR does Add more information on: - configure ms teams users - update slack user setting instruction with new UI Co-authored-by: Matvey Kukuy --- docs/sources/user-and-team-management/_index.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/sources/user-and-team-management/_index.md b/docs/sources/user-and-team-management/_index.md index 75682e86..07a78dc1 100644 --- a/docs/sources/user-and-team-management/_index.md +++ b/docs/sources/user-and-team-management/_index.md @@ -3,10 +3,10 @@ title: User and team management aliases: - /docs/oncall/latest/user-and-team-management/ keywords: - - Mobile App - oncall + - RBAC + - permissions - notification - - push notification weight: 1300 --- @@ -17,13 +17,13 @@ users, configure teams, and manage user permissions at [Grafana.com](https://gra ## User roles and permissions ->**Note:** User roles and teams cannot be managed directly from Grafana OnCall. +> **Note:** User roles and teams cannot be managed directly from Grafana OnCall. User roles and permissions are assigned and managed at the Grafana organization or Cloud portal level. There are two ways to manage user roles and permissions for Grafana OnCall: 1. Basic role authorization - + By default, authorization within Grafana OnCall relies on the basic user roles configured at the organization level. All users are assigned a basic role by the organization administrator. There are three available roles: `Viewer`, `Editor`, and `Admin`. From 9d9a8b960959c01d3803c4be1d7ffad40899affa Mon Sep 17 00:00:00 2001 From: Alfredo <109958902+alfredo-d@users.noreply.github.com> Date: Wed, 24 May 2023 19:45:11 +0800 Subject: [PATCH 19/24] Docs: update slack setup (#1837) # What this PR does Update slack setup: - New UI navigation under 'settings' - add instruction on connecting slack user to oncall Note: This is as per oncall r124-v1.2.15 --------- Co-authored-by: Joey Orlando Co-authored-by: Matvey Kukuy --- docs/sources/notify/slack/index.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/sources/notify/slack/index.md b/docs/sources/notify/slack/index.md index 925b2209..e3f10238 100644 --- a/docs/sources/notify/slack/index.md +++ b/docs/sources/notify/slack/index.md @@ -34,11 +34,12 @@ For Open Source Grafana OnCall Slack installation guidance, refer to ## Install Slack integration for Grafana OnCall -1. From the **ChatOps** tab in Grafana OnCall, select **Slack** in the side menu. -2. Click **Install Slack integration**. -3. Read the notice and agree to proceed to the Slack website. -4. Provide your Slack workspace URL and sign with your Slack credentials. -5. Click **Allow** to give Grafana OnCall permission to access your Slack workspace. +1. Navigate to **Settings** tab in Grafana OnCall. +1. From the **Chat Ops** tab, select **Slack** in the side menu. +1. Click **Install Slack integration**. +1. Read the notice and agree to proceed to the Slack website. +1. Provide your Slack workspace URL and sign with your Slack credentials. +1. Click **Allow** to give Grafana OnCall permission to access your Slack workspace. ## Post-install configuration for Slack integration @@ -51,6 +52,12 @@ and users: amount of time. 3. Ensure all users verify their slack account in their Grafana OnCall **users info**. +### Connect Slack user to Grafana OnCall + +1. From the **Users** tab in Grafana OnCall, click **View my profile**. +1. In the **User Info** tab, navigate to **Slack username**, click **Connect**. +1. Follow the instructions to verify your account. + ### Configure Escalation Chains with Slack notifications Once your Slack integration is configured you can configure Escalation Chains to notify via Slack messages for alerts @@ -105,7 +112,7 @@ a schedule. Use message shortcuts to add resolution notes directly from Slack. Message shortcuts are available in the More actions menu from any message. ->**Note:** In order to associate the resolution note to an alert group, this message shortcut can only be applied to messages in the thread of an alert group. +> **Note:** In order to associate the resolution note to an alert group, this message shortcut can only be applied to messages in the thread of an alert group. 1. From an alert group thread, navigate to the Slack message that you wish to add as a resolution note. 1. Hover over the message and select **More actions** from the menu options. From 216fc8503f184f0c608bf34d040d0765cfd71277 Mon Sep 17 00:00:00 2001 From: Alfredo <109958902+alfredo-d@users.noreply.github.com> Date: Wed, 24 May 2023 19:47:32 +0800 Subject: [PATCH 20/24] Docs: Update teams setup (#1836) # What this PR does Update ms teams setup: - New UI navigation under 'settings' - Update wording for adding new channel Note: This is as per oncall r124-v1.2.15 --------- Co-authored-by: Matvey Kukuy --- docs/sources/notify/ms-teams/index.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/sources/notify/ms-teams/index.md b/docs/sources/notify/ms-teams/index.md index 226705f7..602ef4d4 100644 --- a/docs/sources/notify/ms-teams/index.md +++ b/docs/sources/notify/ms-teams/index.md @@ -37,10 +37,10 @@ The following is required to connect to Microsoft Teams to Grafana OnCall: ## Install Microsoft Teams integration for Grafana OnCall -1. From the **ChatOps** tab in Grafana OnCall, select **Microsoft Teams** in the side menu. -1. Click **+Connect Microsoft Teams channel**. +1. Navigate to **Settings** tab in Grafana OnCall. +1. From the **Chat Ops** tab, select **Microsoft Teams** in the side menu. 1. Follow the steps provided to connect to your Teams channels, then click **Done**. -1. To add additional teams and channels click **+Connect Microsoft Teams channel** again and repeat step 3 as needed. +1. To add additional teams and channels click **+Add MS Teams channel** again and repeat step 3 as needed. ## Post-install configuration for Microsoft Teams integration @@ -53,7 +53,7 @@ Configure the following settings to ensure Grafana OnCall alerts are routed to t ### Connect Microsoft Teams user to Grafana OnCall 1. From the **Users** tab in Grafana OnCall, click **View my profile**. -1. Navigate to **Microsoft Teams username**, click **Connect**. +1. In the **User Info** tab, navigate to **Microsoft Teams username**, click **Connect**. 1. Follow the steps provided to connect your Teams user. 1. Navigate back to your Grafana OnCall profile and verify that your Microsoft Teams account is linked to your Grafana OnCall user. From 7f5adb5eab3b455af205ea3f4bf4c5c2ea98e30a Mon Sep 17 00:00:00 2001 From: Alfredo <109958902+alfredo-d@users.noreply.github.com> Date: Wed, 24 May 2023 19:54:12 +0800 Subject: [PATCH 21/24] Docs: Oncall getting started (#1835) # What this PR does Adding information around other capabilities of oncall thats not mentioned. - Personal notification: add mobile push notification - Chatops: add mention of other chatops other than slack with link - Schedule: add mention of web schedule Since getting started doc would naturally be the place for users to read first, it would be great if we could let users know other features available on top of those mentioned in the getting started steps. --------- Co-authored-by: Matvey Kukuy Co-authored-by: Matvey Kukuy --- docs/sources/get-started/_index.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/sources/get-started/_index.md b/docs/sources/get-started/_index.md index 6aaf394e..89b71b38 100644 --- a/docs/sources/get-started/_index.md +++ b/docs/sources/get-started/_index.md @@ -122,7 +122,7 @@ policies, chatops integrations, and on-call schedules allow you to automate how ### Configure personal notification policies Personal notification policies determine how a user is notified for a certain type of alert. Get notified by SMS, -phone call, or Slack mentions. Administrators can configure how users receive notification for certain types of alerts. +phone call, Slack mentions, or mobile push notification. Administrators can configure how users receive notification for certain types of alerts. For more information on personal notification policies, refer to [Manage users and teams for Grafana OnCall]({{< relref "user-and-team-management" >}}) @@ -150,6 +150,8 @@ To configure Slack for Grafana OnCall: For further instruction on connecting to your Slack workspace, refer to [Slack integration for Grafana OnCall]({{< relref "../notify/slack/" >}}) +Grafana OnCall also supports other ChatOps integration like [Microsoft Teams and Telegram]({{< relref "../notify" >}}). + ### Add your on-call schedule Grafana OnCall allows you to manage your on-call schedule in your preferred calendar app such as Google Calendar or @@ -159,8 +161,6 @@ To integrate your on-call calendar with Grafana OnCall: 1. In the **Schedules** tab of Grafana OnCall, click **+ Add team schedule for on-call rotation**. 2. Provide a schedule name. -3. Copy the iCal URL associated with your on-call calendar from your calendar integration settings. -4. Configure the rest of the schedule settings and click Create Schedule +3. Configure the rest of the schedule settings and click Create Schedule -For more information on on-call schedules, refer to -[Configure and manage on-call schedules]({{< relref "on-call-schedules" >}}) +[More information on on-call schedules.]({{< relref "on-call-schedules" >}}) From 61741867b49605296eb2c047e89ec8c70c5c0976 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Wed, 24 May 2023 21:28:50 +0800 Subject: [PATCH 22/24] Insight logs docs (#1716) # What this PR does Brings insight logs docs and polish format of insight logs --------- Co-authored-by: Matvey Kukuy Co-authored-by: Matvey Kukuy --- docs/sources/insights-and-metrics/_index.md | 167 ++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 docs/sources/insights-and-metrics/_index.md diff --git a/docs/sources/insights-and-metrics/_index.md b/docs/sources/insights-and-metrics/_index.md new file mode 100644 index 00000000..0fd38f71 --- /dev/null +++ b/docs/sources/insights-and-metrics/_index.md @@ -0,0 +1,167 @@ +--- +canonical: https://grafana.com/docs/oncall/latest/insights-and-metrics/ +keywords: + - Audit Logs + - Insight Logs + - Metrics + - Loki + - Prometheus +title: Insight Logs +weight: 1400 +--- + +# Insight Logs + +> **Note:** Grafana OnCall insight logs are available in Grafana Cloud only. +We're in the process of rolling out Insight Logs to all customers, +if you don't see insight logs in your Grafana Cloud stack, please reach out to support. + +Grafana OnCall Insights Logs represents certain activities, such as when: + +- A user creates, updates, or deletes a resource. +- A Maintenance mode is started or finished for an integration. +- A user configures a ChatOps integration. + +This configuration is done for you in Grafana Cloud with [Usage Insights Loki data source](https://grafana.com/docs/grafana-cloud/billing-and-usage/usage-insights/#usage-insights-loki-data-source). +You can use this query to retrieve all logs related to your OnCall instance. + +```logql +{instance_type="oncall"} | logfmt | __error__=`` +``` + +## Resource insight logs + +Logs are created each time a user modifies any resource in Grafana OnCall. + +These logs will have `action_type=resource` field and can be retrieved with following query: + +```logql +{instance_type="oncall"} | logfmt | __error__=`` | action_type = `resource` +``` + +### Format + +Logs contain the following fields, where the fields followed by * are always available, and the others depend on the logged event: + +| Field Name | Description | +|-----------------------|:----------------------------------------------------------------------------:| +| `action_name`* | Type of the resource action, which can be `created`, `updated` or `deleted`. | +| `action_type`* | Insight Log type. For resource insight logs it will be `resource`. | +| `author`* | Username of user who performed action. | +| `author_id`* | ID of user who performed action. | +| `prev_state` | JSON representation of resource before update. | +| `new_state` | JSON representation of resource after update. | +| `resource_id`* | ID of target resource. | +| `resource_name`* | Name of target resource. | +| `resource_type`* | Type of target resource (See available types below). | +| `team`* | Name of team to which resource belongs. | +| `team_id` | ID of team to which resource belongs. | +| `integration` | Name of integration to which resource belongs. | +| `integration_id` | ID of integration to which resource belongs. | +| `escalation_chain` | Name of team to which resource belongs. | +| `escalation_chain_id` | ID of team to which resource belongs. | +| `schedule` | Name of schedule to which resource belongs. | +| `schedule_id` | ID of schedule to which resource belongs . | + +resource types are: `integration_heartbeat`, `escalation_chain`, `integration`, `outgoing_webhook`, +`escalation_policy`, `public_api_token`, `schedule_export_token`,`user_schedule_export_token`, +`oncall_shift`, `web_schedule`, `ical_schedule`, `calendar_schedule`, `organization`, `user`, `webhook`. + +## Maintenance insight logs + +Logs are created every time when a maintenance mode is started or finished for an integration. + +These logs will have `action_type=maintenace` field and can be retrieved with following query: + +```logql +{instance_type="oncall"} | logfmt | __error__=`` | action_type = `maintenance` +``` + +### Format + +Logs of maintenance insights contain the following fields, where the fields followed by * are always available, and the others depend on the logged event: + +| Field Name | Description | +|---------------------|:------------------------------------------------------------------------:| +| `action_name`* | Name of the maintenance action, which can be `started` or `finised`. | +| `action_type`* | Insight Log type. For Maintenance Insight logs it will be `maintenance`. | +| `author` | Username of user who performed action. | +| `author_id` | Grafana OnCall ID of user who performed action. | +| `maintenance_mode`* | Type of the maintenance, which can be `maintenance` or `debug` | +| `resource_id`* | ID of target integration. | +| `resource_name`* | Name of target integration. | +| `team`* | Name of team to which integration belongs. | +| `team_id` | ID of team to which integration belongs. | + +## ChatOps insight logs + +Logs are created when user modifies ChatOps settings. + +These log lines will have `action_type=chat_ops` field and can be retrieved with following query: + +```logql +{instance_type="oncall"} | logfmt | __error__=`` | action_type = `chat_ops` +``` + +### Format + +Logs of chatops insight logs contain the following fields, where the fields followed by * are always available, and the others depend on the logged event: + +| Field Name | Description | +|------------------|:--------------------------------------------------------------------------------:| +| `action_name`* | Name of the chatops action (See available names below). | +| `action_type`* | Insight Log type. For Chatops Insight logs it always will be `chat_ops`. | +| `author`* | Username of user who performed action | +| `author_id`* | Grafana OnCall ID of user who performed action | +| `сhat_ops_type`* | Type of chatops integration. Can be `telegram`, `slack`, `msteams`, `mobile_app` | +| `linked_user` | Username of user linked to chatops integration | +| `linked_user_id` | Grafana OnCall ID of user linked to chatops integration | +| `channel_name` | Name of the channel linked to chatops integration | +| `prev_channel` | Name of team to which resource belongs | +| `new_channel` | Grafana OnCall ID of team to which resource belongs | + +chatops action names: `workspace_connected`, `workspace_disconnected`, `channel_connected`, `channel_disconnected`, `user_linked`, `used_unlinked`, `default_channel_changed`. + +## Examples + +Here is some examples of practical queries to Grafana OnCall insight logs. +LogQL is used to retrieve them. If you are not familiar with LogQL check this [documentation](https://grafana.com/docs/loki/latest/logql/). + +Resource IDs are used a lot in insight logs. You can find them in web ui (example for integration): + +1. Open Grafana OnCall. +2. Navigate to resource. +3. The URL looks like `https:///a/grafana-oncall-app/integrations/C5VXMIFKKP67K`. +4. Integration ID is `C5VXMIFKKP67K`. + +Alternatively you can find resource ID using public [API](https://grafana.com/docs/oncall/latest/oncall-api-reference/) or browser dev tools. + +Actions performed by user: + +```logql +{instance_type="oncall"} | logfmt | __error__=`` | action_type = `resource` and author="" +``` + +Actions performed with all schedules: + +```logql +{instance_type="oncall"} | logfmt | __error__=`` | action_type = `resource` and (resource_type=`web_schedule` or resource_type=`calendar_schedule` or resource_type=`ical_schedule`) +``` + +Changes of escalation policies for escalation chain: + +```logql +{instance_type="oncall"} | logfmt | __error__=`` | action_type = `resource` and resource_type=`escalation_policy` and escalation_chain_id=`` +``` + +Maintenance events for integration: + +```logql +{instance_type="oncall"} | logfmt | __error__=`` | action_type = `maintenance` and resource_id=`CSA67IQW2NMVL` +``` + +Actions performed with slack chatops integration: + +```logql +{instance_type="oncall"} | logfmt | __error__=`` | action_type = `chat_ops` and chat_ops_type=`slack` +``` From db00528c75d6f27b7d477ebb8794e17ea933d98d Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Wed, 24 May 2023 14:49:08 -0600 Subject: [PATCH 23/24] Remove spammy log message for device not setup (#2006) This message appears very frequently in our logs and does not seem to be useful. Please comment and close if this message is useful to you. --- engine/apps/mobile_app/tasks.py | 1 - 1 file changed, 1 deletion(-) diff --git a/engine/apps/mobile_app/tasks.py b/engine/apps/mobile_app/tasks.py index fafe06a9..7daf2c48 100644 --- a/engine/apps/mobile_app/tasks.py +++ b/engine/apps/mobile_app/tasks.py @@ -444,7 +444,6 @@ def conditionally_send_going_oncall_push_notifications_for_schedule(schedule_pk) device_to_notify = FCMDevice.objects.filter(user=user).first() if not device_to_notify: - logger.info(f"User {user_pk} has no device set up") continue else: device_cache[user_pk] = device_to_notify From 2e79c2301d891e5c485cfa47504027fdb481beec Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Wed, 24 May 2023 17:56:20 -0400 Subject: [PATCH 24/24] fix import issue which affected going oncall push notification (#2009) # Which issue(s) this PR fixes when running the mobile app (emulator) + OnCall locally and trying to trigger "You're Going OnCall" push notifications, I was seeing this in the `celery` logs: ```bash 2023-05-24 21:39:54,032 source=engine:celery worker=ForkPoolWorker-3 task_id=cf9e5b52-a213-430a-8e3c-d6c3bed53318 task_name=apps.mobile_app.tasks.conditionally_send_going_oncall_push_notifications_for_schedule name=celery.app.trace level=INFO Task apps.mobile_app.tasks.conditionally_send_going_oncall_push_notifications_for_schedule[cf9e5b52-a213-430a-8e3c-d6c3bed53318] retry: Retry in 2s: NameError("name 'MobileAppUserSettings' is not defined") ``` This PR patches that by adding the import (inside the relevant function, to avoid circular imports). After adding this import, I am seeing push notifications being sent successfully: ```bash 2023-05-24 21:44:08,910 source=engine:celery worker=ForkPoolWorker-3 task_id=71a708b5-9982-4b71-b719-17ed5867dfe1 task_name=apps.mobile_app.tasks.conditionally_send_going_oncall_push_notifications_for_schedule name=apps.mobile_app.tasks level=INFO Evaluating if we should send push notification for schedule 1 for user UWZ6FR5T2KG7U 2023-05-24 21:44:08,912 source=engine:celery worker=ForkPoolWorker-3 task_id=71a708b5-9982-4b71-b719-17ed5867dfe1 task_name=apps.mobile_app.tasks.conditionally_send_going_oncall_push_notifications_for_schedule name=apps.mobile_app.tasks level=INFO timing is right to send going oncall push notification seconds_until_shift_starts: 476 user_notification_timing_preference: 43200 timing_window_lower: 42780 timing_window_upper: 43620 shift_starts_within_users_notification_timing_preference: False shift_starts_within_fifteen_minutes: True 2023-05-24 21:44:08,916 source=engine:celery worker=ForkPoolWorker-3 task_id=71a708b5-9982-4b71-b719-17ed5867dfe1 task_name=apps.mobile_app.tasks.conditionally_send_going_oncall_push_notifications_for_schedule name=apps.mobile_app.tasks level=DEBUG Sending push notification with message: {"android": {"priority": "high"}, "apns": {"headers": {"apns-priority": "10"}, "payload": {"aps": {"alert": {"title": "You are going on call in 7 minutes for schedule joey test"}, "interruption-level": "time-sensitive", "sound": {"name": "default_sound.aiff"}, "thread-id": "SZM7GDPI2VI3F:UWZ6FR5T2KG7U:going-oncall"}}}, "data": {"info_notification_sound_name": "default_sound.mp3", "info_notification_volume": "0.8", "info_notification_volume_override": "false", "info_notification_volume_type": "constant", "thread_id": "SZM7GDPI2VI3F:UWZ6FR5T2KG7U:going-oncall", "title": "You are going on call in 7 minutes for schedule joey test", "type": "oncall.info"}, "token": "dqWWqPS8SvOno1TEE_ZBlX:APA91bHW3hB2sXfKHxxrZ6BITyju3gzBfOHyh1drqndc1U8_b-F89JIfPEsaZvXL-uQd0vpJA8LHifEUCZKb_frk-wbTAwbgk92_0a1DvUKdgNcntK-O85MUDRuf6bWhE9NRGIv58tt5"} ``` ## 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) --- engine/apps/mobile_app/tasks.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/engine/apps/mobile_app/tasks.py b/engine/apps/mobile_app/tasks.py index 7daf2c48..91e390c4 100644 --- a/engine/apps/mobile_app/tasks.py +++ b/engine/apps/mobile_app/tasks.py @@ -242,6 +242,9 @@ def _get_alert_group_escalation_fcm_message( def _get_youre_going_oncall_fcm_message( user: User, schedule: OnCallSchedule, device_to_notify: FCMDevice, seconds_until_going_oncall: int ) -> Message: + # avoid circular import + from apps.mobile_app.models import MobileAppUserSettings + thread_id = f"{schedule.public_primary_key}:{user.public_primary_key}:going-oncall" mobile_app_user_settings, _ = MobileAppUserSettings.objects.get_or_create(user=user)