From 67fc52d56abc9af727d420edb3ae745f96deb864 Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Thu, 15 Aug 2024 14:31:35 -0400 Subject: [PATCH] add `POST /escalation` public API endpoint + add public API docs for teams/organization endpoints (#4815) # What this PR does - Adds a `POST /escalation` public endpoint (equivalent to the internal direct paging API endpoint) - Adds public API documentation for teams and organization endpoints Screenshot 2024-08-15 at 12 49 40 ## Which issue(s) this PR closes Closes https://github.com/grafana/oncall-private/issues/2859 Closes https://github.com/grafana/oncall/issues/2448 ## 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] Added the relevant release notes label (see labels prefixed w/ `release:`). These labels dictate how your PR will show up in the autogenerated release notes. --- .../oncall-api-reference/escalation.md | 190 +++++++++++ .../oncall-api-reference/organizations.md | 73 +++++ docs/sources/oncall-api-reference/teams.md | 86 +++++ .../{paging.py => direct_paging.py} | 19 +- .../{test_paging.py => test_direct_paging.py} | 35 +++ engine/apps/api/urls.py | 2 +- .../api/views/{paging.py => direct_paging.py} | 2 +- .../apps/public_api/serializers/__init__.py | 1 + .../apps/public_api/serializers/escalation.py | 8 + .../apps/public_api/tests/test_escalation.py | 294 ++++++++++++++++++ engine/apps/public_api/urls.py | 1 + engine/apps/public_api/views/__init__.py | 1 + engine/apps/public_api/views/escalation.py | 46 +++ 13 files changed, 752 insertions(+), 6 deletions(-) create mode 100644 docs/sources/oncall-api-reference/escalation.md create mode 100644 docs/sources/oncall-api-reference/organizations.md create mode 100644 docs/sources/oncall-api-reference/teams.md rename engine/apps/api/serializers/{paging.py => direct_paging.py} (80%) rename engine/apps/api/tests/{test_paging.py => test_direct_paging.py} (88%) rename engine/apps/api/views/{paging.py => direct_paging.py} (96%) create mode 100644 engine/apps/public_api/serializers/escalation.py create mode 100644 engine/apps/public_api/tests/test_escalation.py create mode 100644 engine/apps/public_api/views/escalation.py diff --git a/docs/sources/oncall-api-reference/escalation.md b/docs/sources/oncall-api-reference/escalation.md new file mode 100644 index 0000000000..a37e84fbcb --- /dev/null +++ b/docs/sources/oncall-api-reference/escalation.md @@ -0,0 +1,190 @@ +--- +canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/escalation/ +title: Escalation HTTP API +weight: 1200 +refs: + users: + - pattern: /docs/oncall/ + destination: /docs/oncall//oncall-api-reference/users + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/users + teams: + - pattern: /docs/oncall/ + destination: /docs/oncall//oncall-api-reference/teams + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/teams + manual-paging: + - pattern: /docs/oncall/ + destination: /docs/oncall//configure/integrations/references/manual + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/configure/integrations/references/manual +--- + +# Escalation HTTP API + +See [Manual paging integration](ref:manual-paging) for more background on how escalating to a team or user(s) works. + +## Escalate to a set of users + +For more details about how to fetch a user's Grafana OnCall ID, refer to the [Users](ref:users) public API documentation. + +```shell +curl "{{API_URL}}/api/v1/escalation/" \ + --request POST \ + --header "Authorization: meowmeowmeow" \ + --header "Content-Type: application/json" \ + --data '{ + "title": "We are seeing a network outage in the datacenter", + "message": "I need help investigating, can you join the investigation?", + "source_url": "https://github.com/myorg/myrepo/issues/123", + "users": [ + { + "id": "U281SN24AVVJX", + "important": false + }, + { + "id": "U5AKCVNDEDUE7", + "important": true + } + ] + }' +``` + +The above command returns JSON structured in the following way: + +```json +{ + "id": "IZHCC4GTNPZ93", + "integration_id": "CC3GZYZNIIEH5", + "route_id": "RDN8LITALJXCJ", + "alerts_count": 1, + "state": "firing", + "created_at": "2024-08-15T18:05:36.801215Z", + "resolved_at": null, + "resolved_by": null, + "acknowledged_at": null, + "acknowledged_by": null, + "title": "We're seeing a network outage in the datacenter", + "permalinks": { + "slack": null, + "slack_app": null, + "telegram": null, + "web": "http:///a/grafana-oncall-app/alert-groups/I5LAZ2MXGPUAH" + }, + "silenced_at": null +} +``` + +## Escalate to a team + +For more details about how to fetch a team's Grafana OnCall ID, refer to the [Teams](ref:teams) public API documentation. + +```shell +curl "{{API_URL}}/api/v1/escalation/" \ + --request POST \ + --header "Authorization: meowmeowmeow" \ + --header "Content-Type: application/json" \ + --data '{ + "title": "We are seeing a network outage in the datacenter", + "message": "I need help investigating, can you join the investigation?", + "source_url": "https://github.com/myorg/myrepo/issues/123", + "team": "TI73TDU19W48J" + }' +``` + +The above command returns JSON structured in the following way: + +```json +{ + "id": "IZHCC4GTNPZ93", + "integration_id": "CC3GZYZNIIEH5", + "route_id": "RDN8LITALJXCJ", + "alerts_count": 1, + "state": "firing", + "created_at": "2024-08-15T18:05:36.801215Z", + "resolved_at": null, + "resolved_by": null, + "acknowledged_at": null, + "acknowledged_by": null, + "title": "We're seeing a network outage in the datacenter", + "permalinks": { + "slack": null, + "slack_app": null, + "telegram": null, + "web": "http:///a/grafana-oncall-app/alert-groups/I5LAZ2MXGPUAH" + }, + "silenced_at": null +} +``` + +## Escalate to a set of user(s) for an existing Alert Group + +The following shows how you can escalate to a set of user(s) for an existing Alert Group. + +```shell +curl "{{API_URL}}/api/v1/escalation/" \ + --request POST \ + --header "Authorization: meowmeowmeow" \ + --header "Content-Type: application/json" \ + --data '{ + "alert_group_id": "IZMRNNY8RFS94", + "users": [ + { + "id": "U281SN24AVVJX", + "important": false + }, + { + "id": "U5AKCVNDEDUE7", + "important": true + } + ] + }' +``` + +The above command returns JSON structured in the following way: + +```json +{ + "id": "IZHCC4GTNPZ93", + "integration_id": "CC3GZYZNIIEH5", + "route_id": "RDN8LITALJXCJ", + "alerts_count": 1, + "state": "firing", + "created_at": "2024-08-15T18:05:36.801215Z", + "resolved_at": null, + "resolved_by": null, + "acknowledged_at": null, + "acknowledged_by": null, + "title": "We're seeing a network outage in the datacenter", + "permalinks": { + "slack": null, + "slack_app": null, + "telegram": null, + "web": "http:///a/grafana-oncall-app/alert-groups/I5LAZ2MXGPUAH" + }, + "silenced_at": null +} +``` + +| Parameter | Unique | Required | Description | +| -------------------- | :----: | :--------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `title` | No | No | Name of the Alert Group that will be created | +| `message` | No | No | Content of the Alert Group that will be created | +| `source_url` | No | No | Value that will be added in the Alert's payload as `oncall.permalink`. This can be useful to have the source URL/button autopopulated with a URL of interest. | +| `team` | No | Yes (see [Things to Note](#things-to-note)) | Grafana OnCall team ID. If specified, will use the "Direct Paging" Integration associated with this Grafana OnCall team, to create the Alert Group. | +| `users` | No | Yes (see [Things to Note](#things-to-note)) | List of user(s) to escalate to. See above request example for object schema. `id` represents the Grafana OnCall user's ID. `important` is a boolean representing whether to escalate the Alert Group using this user's default or important personal notification policy. | +| `alert_group_id` | No | No | If specified, will escalate the specified users for this Alert Group. | + +## Things to note + +- `team` and `users` are mutually exclusive in the request payload. If you would like to escalate to a team AND user(s), +first escalate to a team, then using the Alert Group ID returned in the response payload, add the required users to the +existing Alert Group +- `alert_group_id` is mutually exclusive with `title`, `message`, and `source_url`. Practically speaking this means that +if you are trying to escalate to a set of users on an existing Alert Group, you cannot update the `title`, `message`, or +`source_url` of that Alert Group +- If escalating to a set of users for an existing Alert Group, the Alert Group cannot be in a resolved state + +**HTTP request** + +`POST {{API_URL}}/api/v1/escalation/` diff --git a/docs/sources/oncall-api-reference/organizations.md b/docs/sources/oncall-api-reference/organizations.md new file mode 100644 index 0000000000..7c7abcae47 --- /dev/null +++ b/docs/sources/oncall-api-reference/organizations.md @@ -0,0 +1,73 @@ +--- +canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/organizations/ +title: Grafana OnCall organizations HTTP API +weight: 1500 +refs: + pagination: + - pattern: /docs/oncall/ + destination: /docs/oncall//oncall-api-reference/#pagination + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination +--- + +# Grafana OnCall organizations HTTP API + +## Get an organization + +This endpoint retrieves the organization object. + +```shell +curl "{{API_URL}}/api/v1/organizations/O53AAGWFBPE5W/" \ + --request GET \ + --header "Authorization: meowmeowmeow" \ + --header "Content-Type: application/json" +```` + +The above command returns JSON structured in the following way: + +```json +{ + "id": "O53AAGWFBPE5W" +} +``` + +**HTTP request** + +`GET {{API_URL}}/api/v1/organizations//` + +| Parameter | Unique | Description | +| ---------- | :-----: | :----------------------------------------------------------------- | +| `id` | Yes | Organization ID | + +## List Organizations + +```shell +curl "{{API_URL}}/api/v1/organizations/" \ + --request GET \ + --header "Authorization: meowmeowmeow" \ + --header "Content-Type: application/json" +``` + +The above command returns JSON structured in the following way: + +```json +{ + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": "O53AAGWFBPE5W" + } + ], + "page_size": 25, + "current_page_number": 1, + "total_pages": 1 +} +``` + +> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records. + +**HTTP request** + +`GET {{API_URL}}/api/v1/organizations/` diff --git a/docs/sources/oncall-api-reference/teams.md b/docs/sources/oncall-api-reference/teams.md new file mode 100644 index 0000000000..15c61e39ce --- /dev/null +++ b/docs/sources/oncall-api-reference/teams.md @@ -0,0 +1,86 @@ +--- +canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/teams/ +title: Grafana OnCall teams HTTP API +weight: 1500 +refs: + pagination: + - pattern: /docs/oncall/ + destination: /docs/oncall//oncall-api-reference/#pagination + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination +--- + +# Grafana OnCall teams HTTP API + +## Get a team + +This endpoint retrieves the team object. + +```shell +curl "{{API_URL}}/api/v1/teams/TI73TDU19W48J/" \ + --request GET \ + --header "Authorization: meowmeowmeow" \ + --header "Content-Type: application/json" +```` + +The above command returns JSON structured in the following way: + +```json +{ + "id": "TI73TDU19W48J", + "name": "my test team", + "email": "", + "avatar_url": "/avatar/3f49c15916554246daa714b9bd0ee398" +} +``` + +**HTTP request** + +`GET {{API_URL}}/api/v1/teams//` + +| Parameter | Unique | Description | +| ---------- | :-----: | :----------------------------------------------------------------- | +| `id` | Yes/org | Team ID | +| `name` | Yes/org | Team name | +| `email` | Yes/org | Team e-mail | +| `avatar_url` | Yes | Avatar URL of the Grafana team | + +## List Teams + +```shell +curl "{{API_URL}}/api/v1/teams/" \ + --request GET \ + --header "Authorization: meowmeowmeow" \ + --header "Content-Type: application/json" +``` + +The above command returns JSON structured in the following way: + +```json +{ + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": "TI73TDU19W48J", + "name": "my test team", + "email": "", + "avatar_url": "/avatar/3f49c15916554246daa714b9bd0ee398" + } + ], + "page_size": 50, + "current_page_number": 1, + "total_pages": 1 +} +``` + +> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records. + +The following available filter parameter should be provided as a `GET` argument: + +- `name` (Exact match) + +**HTTP request** + +`GET {{API_URL}}/api/v1/teams/` diff --git a/engine/apps/api/serializers/paging.py b/engine/apps/api/serializers/direct_paging.py similarity index 80% rename from engine/apps/api/serializers/paging.py rename to engine/apps/api/serializers/direct_paging.py index ab3584bb3e..4cb1e646e6 100644 --- a/engine/apps/api/serializers/paging.py +++ b/engine/apps/api/serializers/direct_paging.py @@ -32,9 +32,11 @@ def validate(self, attrs): return attrs -class DirectPagingSerializer(serializers.Serializer): +class BasePagingSerializer(serializers.Serializer): context: SerializerContext + ALLOWS_GRAFANA_INCIDENT_ID = False + users = UserReferenceSerializer(many=True, required=False, default=list) team = TeamPrimaryKeyRelatedField(allow_null=True, default=CurrentTeamDefault()) @@ -44,7 +46,6 @@ class DirectPagingSerializer(serializers.Serializer): title = serializers.CharField(required=False, default=None) message = serializers.CharField(required=False, default=None, allow_null=True) source_url = serializers.URLField(required=False, default=None, allow_null=True) - grafana_incident_id = serializers.CharField(required=False, default=None, allow_null=True) def validate(self, attrs): organization = self.context["organization"] @@ -52,13 +53,17 @@ def validate(self, attrs): title = attrs["title"] message = attrs["message"] source_url = attrs["source_url"] - grafana_incident_id = attrs["grafana_incident_id"] + grafana_incident_id = self.ALLOWS_GRAFANA_INCIDENT_ID and attrs.get("grafana_incident_id") if alert_group_id and (title or message or source_url or grafana_incident_id): raise serializers.ValidationError( - "alert_group_id and (title, message, source_url, grafana_incident_id) are mutually exclusive" + f"alert_group_id and (title, message, source_url{', grafana_incident_id' if self.ALLOWS_GRAFANA_INCIDENT_ID else ''}) " + "are mutually exclusive" ) + if attrs["users"] and attrs["team"]: + raise serializers.ValidationError("users and team are mutually exclusive") + if alert_group_id: try: attrs["alert_group"] = AlertGroup.objects.get( @@ -68,3 +73,9 @@ def validate(self, attrs): raise serializers.ValidationError("Alert group {} does not exist".format(alert_group_id)) return attrs + + +class DirectPagingSerializer(BasePagingSerializer): + ALLOWS_GRAFANA_INCIDENT_ID = True + + grafana_incident_id = serializers.CharField(required=False, default=None, allow_null=True) diff --git a/engine/apps/api/tests/test_paging.py b/engine/apps/api/tests/test_direct_paging.py similarity index 88% rename from engine/apps/api/tests/test_paging.py rename to engine/apps/api/tests/test_direct_paging.py index 24e5f32c8e..be52906526 100644 --- a/engine/apps/api/tests/test_paging.py +++ b/engine/apps/api/tests/test_direct_paging.py @@ -224,6 +224,41 @@ def test_direct_paging_no_user_or_team_specified( assert response.json()["detail"] == DirectPagingUserTeamValidationError.DETAIL +@pytest.mark.django_db +def test_direct_paging_both_team_and_users_specified( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_user, + make_team, +): + organization, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + team = make_team(organization=organization) + + # user must be part of the team + user.teams.add(team) + + client = APIClient() + url = reverse("api-internal:direct_paging") + + response = client.post( + url, + data={ + "team": team.public_primary_key, + "users": [ + { + "id": make_user(organization=organization).public_primary_key, + "important": False, + }, + ], + }, + format="json", + **make_user_auth_headers(user, token), + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["non_field_errors"] == ["users and team are mutually exclusive"] + + @pytest.mark.parametrize( "field_name,field_value", [ diff --git a/engine/apps/api/urls.py b/engine/apps/api/urls.py index 639d109434..5be32db025 100644 --- a/engine/apps/api/urls.py +++ b/engine/apps/api/urls.py @@ -9,6 +9,7 @@ from .views.alert_receive_channel_template import AlertReceiveChannelTemplateView from .views.alerts import AlertDetailView from .views.channel_filter import ChannelFilterView +from .views.direct_paging import DirectPagingAPIView from .views.escalation_chain import EscalationChainViewSet from .views.escalation_policy import EscalationPolicyView from .views.features import FeaturesAPIView @@ -23,7 +24,6 @@ OrganizationConfigChecksView, SetGeneralChannel, ) -from .views.paging import DirectPagingAPIView from .views.preview_template_options import PreviewTemplateOptionsView from .views.public_api_tokens import PublicApiTokenView from .views.resolution_note import ResolutionNoteView diff --git a/engine/apps/api/views/paging.py b/engine/apps/api/views/direct_paging.py similarity index 96% rename from engine/apps/api/views/paging.py rename to engine/apps/api/views/direct_paging.py index 537c6f4595..b5d7a9eb0a 100644 --- a/engine/apps/api/views/paging.py +++ b/engine/apps/api/views/direct_paging.py @@ -5,7 +5,7 @@ from apps.alerts.paging import DirectPagingAlertGroupResolvedError, DirectPagingUserTeamValidationError, direct_paging from apps.api.permissions import RBACPermission -from apps.api.serializers.paging import DirectPagingSerializer +from apps.api.serializers.direct_paging import DirectPagingSerializer from apps.auth_token.auth import PluginAuthentication from apps.mobile_app.auth import MobileAppAuthTokenAuthentication from common.api_helpers.exceptions import BadRequest diff --git a/engine/apps/public_api/serializers/__init__.py b/engine/apps/public_api/serializers/__init__.py index d01a7f2e35..21f9b08f84 100644 --- a/engine/apps/public_api/serializers/__init__.py +++ b/engine/apps/public_api/serializers/__init__.py @@ -1,4 +1,5 @@ from .alerts import AlertSerializer # noqa: F401 +from .escalation import EscalationSerializer # noqa: F401 from .escalation_chains import EscalationChainSerializer # noqa: F401 from .escalation_policies import EscalationPolicySerializer, EscalationPolicyUpdateSerializer # noqa: F401 from .incidents import IncidentSerializer # noqa: F401 diff --git a/engine/apps/public_api/serializers/escalation.py b/engine/apps/public_api/serializers/escalation.py new file mode 100644 index 0000000000..f2b41882fc --- /dev/null +++ b/engine/apps/public_api/serializers/escalation.py @@ -0,0 +1,8 @@ +from apps.api.serializers.direct_paging import BasePagingSerializer + + +class EscalationSerializer(BasePagingSerializer): + """ + Very similar to `apps.api.serializers.direct_paging.DirectPagingSerializer` except that + there is no `grafana_incident_id` attribute + """ diff --git a/engine/apps/public_api/tests/test_escalation.py b/engine/apps/public_api/tests/test_escalation.py new file mode 100644 index 0000000000..ebf8c4a0ce --- /dev/null +++ b/engine/apps/public_api/tests/test_escalation.py @@ -0,0 +1,294 @@ +from unittest import mock + +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from apps.alerts.models import AlertGroup +from apps.alerts.paging import DirectPagingAlertGroupResolvedError, DirectPagingUserTeamValidationError + +title = "Custom title" +message = "Testing escalation with new alert group" +source_url = "https://www.example.com" + + +@pytest.mark.django_db +def test_escalation_new_alert_group( + make_organization_and_user_with_token, + make_user, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_token() + + users_to_page = [ + { + "id": make_user(organization=organization).public_primary_key, + "important": False, + }, + { + "id": make_user(organization=organization).public_primary_key, + "important": True, + }, + ] + + client = APIClient() + url = reverse("api-public:escalation") + + response = client.post( + url, + data={ + "users": users_to_page, + "title": title, + "message": message, + }, + format="json", + **make_user_auth_headers(user, token), + ) + + assert response.status_code == status.HTTP_200_OK + + alert_groups = AlertGroup.objects.all() + assert alert_groups.count() == 1 + ag = alert_groups.get() + + assert response.json() == { + "id": ag.public_primary_key, + "integration_id": ag.channel.public_primary_key, + "route_id": ag.channel_filter.public_primary_key, + "alerts_count": 1, + "state": "firing", + "created_at": mock.ANY, + "resolved_at": None, + "resolved_by": None, + "acknowledged_at": None, + "acknowledged_by": None, + "title": title, + "permalinks": { + "slack": None, + "slack_app": None, + "telegram": None, + "web": f"a/grafana-oncall-app/alert-groups/{ag.public_primary_key}", + }, + "silenced_at": None, + } + + alert = ag.alerts.get() + + assert ag.web_title_cache == title + assert alert.title == title + assert alert.message == message + + +@pytest.mark.django_db +def test_escalation_team( + make_organization_and_user_with_token, + make_team, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_token() + team = make_team(organization=organization) + + # user must be part of the team + user.teams.add(team) + + client = APIClient() + url = reverse("api-public:escalation") + + response = client.post( + url, + data={ + "team": team.public_primary_key, + "message": message, + "source_url": source_url, + }, + format="json", + **make_user_auth_headers(user, token), + ) + + assert response.status_code == status.HTTP_200_OK + + alert_group = AlertGroup.objects.get(public_primary_key=response.json()["id"]) + alert = alert_group.alerts.first() + + assert alert.raw_request_data["oncall"]["permalink"] == source_url + + +@pytest.mark.django_db +def test_escalation_existing_alert_group( + make_organization_and_user_with_token, + make_user, + make_alert_receive_channel, + make_alert_group, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_token() + + users_to_page = [ + { + "id": make_user(organization=organization).public_primary_key, + "important": False, + }, + { + "id": make_user( + organization=organization, + ).public_primary_key, + "important": True, + }, + ] + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + + client = APIClient() + url = reverse("api-public:escalation") + + response = client.post( + url, + data={"users": users_to_page, "alert_group_id": alert_group.public_primary_key}, + format="json", + **make_user_auth_headers(user, token), + ) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["id"] == alert_group.public_primary_key + + +@pytest.mark.django_db +def test_escalation_existing_alert_group_resolved( + make_organization_and_user_with_token, + make_user, + make_alert_receive_channel, + make_alert_group, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_token() + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel, resolved=True) + + users_to_page = [ + { + "id": make_user(organization=organization).public_primary_key, + "important": False, + }, + ] + + client = APIClient() + url = reverse("api-public:escalation") + + response = client.post( + url, + data={ + "alert_group_id": alert_group.public_primary_key, + "users": users_to_page, + }, + format="json", + **make_user_auth_headers(user, token), + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["detail"] == DirectPagingAlertGroupResolvedError.DETAIL + + +@pytest.mark.django_db +def test_escalation_no_user_or_team_specified( + make_organization_and_user_with_token, + make_user_auth_headers, +): + _, user, token = make_organization_and_user_with_token() + + client = APIClient() + url = reverse("api-public:escalation") + + response = client.post( + url, + data={ + "team": None, + "users": [], + }, + format="json", + **make_user_auth_headers(user, token), + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["detail"] == DirectPagingUserTeamValidationError.DETAIL + + +@pytest.mark.django_db +def test_escalation_both_team_and_users_specified( + make_organization_and_user_with_token, + make_user_auth_headers, + make_user, + make_team, +): + organization, user, token = make_organization_and_user_with_token() + team = make_team(organization=organization) + + client = APIClient() + url = reverse("api-public:escalation") + + response = client.post( + url, + data={ + "team": team.public_primary_key, + "users": [ + { + "id": make_user(organization=organization).public_primary_key, + "important": False, + }, + ], + }, + format="json", + **make_user_auth_headers(user, token), + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["non_field_errors"] == ["users and team are mutually exclusive"] + + +@pytest.mark.parametrize( + "field_name,field_value", + [ + ("title", title), + ("message", message), + ("source_url", source_url), + ], +) +@pytest.mark.django_db +def test_escalation_alert_group_id_and_other_fields_are_mutually_exclusive( + make_organization_and_user_with_token, + make_team, + make_user_auth_headers, + make_alert_receive_channel, + make_alert_group, + field_name, + field_value, +): + error_msg = "alert_group_id and (title, message, source_url) are mutually exclusive" + + organization, user, token = make_organization_and_user_with_token() + team = make_team(organization=organization) + + # user must be part of the team + user.teams.add(team) + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel, resolved=True) + + client = APIClient() + url = reverse("api-public:escalation") + + response = client.post( + url, + data={ + "team": team.public_primary_key, + "alert_group_id": alert_group.public_primary_key, + field_name: field_value, + }, + format="json", + **make_user_auth_headers(user, token), + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["non_field_errors"] == [error_msg] diff --git a/engine/apps/public_api/urls.py b/engine/apps/public_api/urls.py index 873b0e1071..7f36170bcd 100644 --- a/engine/apps/public_api/urls.py +++ b/engine/apps/public_api/urls.py @@ -34,4 +34,5 @@ optional_slash_path("info", views.InfoView.as_view(), name="info"), optional_slash_path("make_call", views.MakeCallView.as_view(), name="make_call"), optional_slash_path("send_sms", views.SendSMSView.as_view(), name="send_sms"), + optional_slash_path("escalation", views.EscalationView.as_view(), name="escalation"), ] diff --git a/engine/apps/public_api/views/__init__.py b/engine/apps/public_api/views/__init__.py index 47fad290dd..49a5f7281c 100644 --- a/engine/apps/public_api/views/__init__.py +++ b/engine/apps/public_api/views/__init__.py @@ -1,5 +1,6 @@ from .action import ActionView # noqa: F401 from .alerts import AlertView # noqa: F401 +from .escalation import EscalationView # noqa: F401 from .escalation_chains import EscalationChainView # noqa: F401 from .escalation_policies import EscalationPolicyView # noqa: F401 from .incidents import IncidentView # noqa: F401 diff --git a/engine/apps/public_api/views/escalation.py b/engine/apps/public_api/views/escalation.py new file mode 100644 index 0000000000..c7da1fe19a --- /dev/null +++ b/engine/apps/public_api/views/escalation.py @@ -0,0 +1,46 @@ +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.alerts.paging import DirectPagingAlertGroupResolvedError, DirectPagingUserTeamValidationError, direct_paging +from apps.auth_token.auth import ApiTokenAuthentication +from apps.public_api.serializers import EscalationSerializer, IncidentSerializer +from apps.public_api.throttlers import UserThrottle +from common.api_helpers.exceptions import BadRequest + + +class EscalationView(APIView): + """ + aka "Direct Paging" + """ + + authentication_classes = (ApiTokenAuthentication,) + permission_classes = (IsAuthenticated,) + + throttle_classes = [UserThrottle] + + def post(self, request): + user = request.user + organization = user.organization + + serializer = EscalationSerializer(data=request.data, context={"organization": organization, "request": request}) + serializer.is_valid(raise_exception=True) + validated_data = serializer.validated_data + + try: + alert_group = direct_paging( + organization=organization, + from_user=user, + message=validated_data["message"], + title=validated_data["title"], + source_url=validated_data["source_url"], + team=validated_data["team"], + users=[(user["instance"], user["important"]) for user in validated_data["users"]], + alert_group=validated_data["alert_group"], + ) + except DirectPagingAlertGroupResolvedError: + raise BadRequest(detail=DirectPagingAlertGroupResolvedError.DETAIL) + except DirectPagingUserTeamValidationError: + raise BadRequest(detail=DirectPagingUserTeamValidationError.DETAIL) + return Response(IncidentSerializer(alert_group).data, status=status.HTTP_200_OK)