From a727450d4996d5e26612b956565d6725288aeb9b Mon Sep 17 00:00:00 2001 From: Vadim Stepanov Date: Thu, 5 Oct 2023 09:46:48 +0100 Subject: [PATCH] Public API: Acknowledge & Resolve actions (#3108) # What this PR does Makes it possible to acknowledge/unacknowledge and resolve/unresolve alert groups via public API, and makes sure these actions are reflected properly in the alert group timeline. ## Demo ```bash curl --request POST \ --header "Authorization: TOKEN" \ http://localhost:8080/api/v1/alert_groups/IQMHLV8INB24N/resolve ``` Screenshot 2023-10-04 at 16 05 27 ## Which issue(s) this PR fixes https://github.com/grafana/oncall/issues/3051 ## 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 + .../oncall-api-reference/alertgroups.md | 48 +++++ engine/apps/alerts/constants.py | 15 +- .../0033_alertgrouplogrecord_action_source.py | 18 ++ engine/apps/alerts/models/alert_group.py | 62 ++++-- .../alerts/models/alert_group_log_record.py | 14 +- engine/apps/alerts/tests/test_alert_group.py | 57 ++++++ engine/apps/api/tests/test_alert_group.py | 29 +++ .../apps/public_api/tests/test_incidents.py | 176 ++++++++++++++++-- engine/apps/public_api/views/incidents.py | 73 ++++++++ 10 files changed, 461 insertions(+), 37 deletions(-) create mode 100644 engine/apps/alerts/migrations/0033_alertgrouplogrecord_action_source.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b4a052ee1..32f1e8cd43 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 + +### Added + +- Make it possible to acknowledge/unacknowledge and resolve/unresolve alert groups via API by @vadimkerr ([#3108](https://github.com/grafana/oncall/pull/3108)) + ## v1.3.42 (2023-10-04) ### Added diff --git a/docs/sources/oncall-api-reference/alertgroups.md b/docs/sources/oncall-api-reference/alertgroups.md index dd5efbbd5f..9d30c31af8 100644 --- a/docs/sources/oncall-api-reference/alertgroups.md +++ b/docs/sources/oncall-api-reference/alertgroups.md @@ -54,6 +54,54 @@ These available filter parameters should be provided as `GET` arguments: `GET {{API_URL}}/api/v1/alert_groups/` +# Acknowledge alert groups + +```shell +curl "{{API_URL}}/api/v1/alert_groups/I68T24C13IFW1/acknowledge" \ + --request POST \ + --header "Authorization: meowmeowmeow" +``` + +**HTTP request** + +`POST {{API_URL}}/api/v1/alert_groups//acknowledge` + +# Unacknowledge alert groups + +```shell +curl "{{API_URL}}/api/v1/alert_groups/I68T24C13IFW1/unacknowledge" \ + --request POST \ + --header "Authorization: meowmeowmeow" +``` + +**HTTP request** + +`POST {{API_URL}}/api/v1/alert_groups//unacknowledge` + +# Resolve alert groups + +```shell +curl "{{API_URL}}/api/v1/alert_groups/I68T24C13IFW1/resolve" \ + --request POST \ + --header "Authorization: meowmeowmeow" +``` + +**HTTP request** + +`POST {{API_URL}}/api/v1/alert_groups//resolve` + +# Unresolve alert groups + +```shell +curl "{{API_URL}}/api/v1/alert_groups/I68T24C13IFW1/unresolve" \ + --request POST \ + --header "Authorization: meowmeowmeow" +``` + +**HTTP request** + +`POST {{API_URL}}/api/v1/alert_groups//unresolve` + # Delete alert groups ```shell diff --git a/engine/apps/alerts/constants.py b/engine/apps/alerts/constants.py index 714312f201..aa5adca16e 100644 --- a/engine/apps/alerts/constants.py +++ b/engine/apps/alerts/constants.py @@ -1,13 +1,14 @@ from enum import Enum +from django.db.models import IntegerChoices -class ActionSource: - ( - SLACK, - WEB, - PHONE, - TELEGRAM, - ) = range(4) + +class ActionSource(IntegerChoices): + SLACK = 0, "Slack" + WEB = 1, "Web" + PHONE = 2, "Phone" + TELEGRAM = 3, "Telegram" + API = 4, "API" TASK_DELAY_SECONDS = 1 diff --git a/engine/apps/alerts/migrations/0033_alertgrouplogrecord_action_source.py b/engine/apps/alerts/migrations/0033_alertgrouplogrecord_action_source.py new file mode 100644 index 0000000000..578e0359e9 --- /dev/null +++ b/engine/apps/alerts/migrations/0033_alertgrouplogrecord_action_source.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.20 on 2023-10-04 10:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('alerts', '0032_remove_alertgroup_slack_message_state'), + ] + + operations = [ + migrations.AddField( + model_name='alertgrouplogrecord', + name='action_source', + field=models.SmallIntegerField(default=None, null=True, verbose_name=[(0, 'Slack'), (1, 'Web'), (2, 'Phone'), (3, 'Telegram'), (4, 'API')]), + ), + ] diff --git a/engine/apps/alerts/models/alert_group.py b/engine/apps/alerts/models/alert_group.py index 53d3f65cd5..0a29a776e8 100644 --- a/engine/apps/alerts/models/alert_group.py +++ b/engine/apps/alerts/models/alert_group.py @@ -16,7 +16,7 @@ from django.utils import timezone from django.utils.functional import cached_property -from apps.alerts.constants import AlertGroupState +from apps.alerts.constants import ActionSource, AlertGroupState from apps.alerts.escalation_snapshot import EscalationSnapshotMixin from apps.alerts.escalation_snapshot.escalation_snapshot_mixin import START_ESCALATION_DELAY from apps.alerts.incident_appearance.renderers.constants import DEFAULT_BACKUP_TITLE @@ -550,7 +550,7 @@ def _update_metrics(self, organization_id, previous_state, state): started_at=self.started_at, ) - def acknowledge_by_user(self, user: User, action_source: typing.Optional[str] = None) -> None: + def acknowledge_by_user(self, user: User, action_source: typing.Optional[ActionSource] = None) -> None: from apps.alerts.models import AlertGroupLogRecord initial_state = self.state @@ -564,10 +564,16 @@ def acknowledge_by_user(self, user: User, action_source: typing.Optional[str] = author=user, silence_delay=None, reason="Acknowledge button", + action_source=action_source, ) if self.resolved: self.unresolve() - self.log_records.create(type=AlertGroupLogRecord.TYPE_UN_RESOLVED, author=user, reason="Acknowledge button") + self.log_records.create( + type=AlertGroupLogRecord.TYPE_UN_RESOLVED, + author=user, + reason="Acknowledge button", + action_source=action_source, + ) self.acknowledge(acknowledged_by_user=user, acknowledged_by=AlertGroup.USER) # Update alert group state and response time metrics cache @@ -576,7 +582,9 @@ def acknowledge_by_user(self, user: User, action_source: typing.Optional[str] = self.stop_escalation() self.start_ack_reminder_if_needed() - log_record = self.log_records.create(type=AlertGroupLogRecord.TYPE_ACK, author=user) + log_record = self.log_records.create( + type=AlertGroupLogRecord.TYPE_ACK, author=user, action_source=action_source + ) logger.debug( f"send alert_group_action_triggered_signal for alert_group {self.pk}, " @@ -630,7 +638,7 @@ def acknowledge_by_source(self): for dependent_alert_group in self.dependent_alert_groups.all(): dependent_alert_group.acknowledge_by_source() - def un_acknowledge_by_user(self, user: User, action_source: typing.Optional[str] = None) -> None: + def un_acknowledge_by_user(self, user: User, action_source: typing.Optional[ActionSource] = None) -> None: from apps.alerts.models import AlertGroupLogRecord initial_state = self.state @@ -642,7 +650,9 @@ def un_acknowledge_by_user(self, user: User, action_source: typing.Optional[str] if self.is_root_alert_group: self.start_escalation_if_needed() - log_record = self.log_records.create(type=AlertGroupLogRecord.TYPE_UN_ACK, author=user) + log_record = self.log_records.create( + type=AlertGroupLogRecord.TYPE_UN_ACK, author=user, action_source=action_source + ) logger.debug( f"send alert_group_action_triggered_signal for alert_group {self.pk}, " @@ -659,7 +669,7 @@ def un_acknowledge_by_user(self, user: User, action_source: typing.Optional[str] dependent_alert_group.un_acknowledge_by_user(user, action_source=action_source) logger.debug(f"Finished un_acknowledge_by_user for alert_group {self.pk}") - def resolve_by_user(self, user: User, action_source: typing.Optional[str] = None) -> None: + def resolve_by_user(self, user: User, action_source: typing.Optional[ActionSource] = None) -> None: from apps.alerts.models import AlertGroupLogRecord initial_state = self.state @@ -672,12 +682,15 @@ def resolve_by_user(self, user: User, action_source: typing.Optional[str] = None author=user, silence_delay=None, reason="Resolve button", + action_source=action_source, ) self.resolve(resolved_by=AlertGroup.USER, resolved_by_user=user) # Update alert group state and response time metrics cache self._update_metrics(organization_id=user.organization_id, previous_state=initial_state, state=self.state) self.stop_escalation() - log_record = self.log_records.create(type=AlertGroupLogRecord.TYPE_RESOLVED, author=user) + log_record = self.log_records.create( + type=AlertGroupLogRecord.TYPE_RESOLVED, author=user, action_source=action_source + ) logger.debug( f"send alert_group_action_triggered_signal for alert_group {self.pk}, " @@ -777,7 +790,7 @@ def resolve_by_disable_maintenance(self): for dependent_alert_group in self.dependent_alert_groups.all(): dependent_alert_group.resolve_by_disable_maintenance() - def un_resolve_by_user(self, user: User, action_source: typing.Optional[str] = None) -> None: + def un_resolve_by_user(self, user: User, action_source: typing.Optional[ActionSource] = None) -> None: from apps.alerts.models import AlertGroupLogRecord if self.wiped_at is None: @@ -786,7 +799,9 @@ def un_resolve_by_user(self, user: User, action_source: typing.Optional[str] = N # Update alert group state metric cache self._update_metrics(organization_id=user.organization_id, previous_state=initial_state, state=self.state) - log_record = self.log_records.create(type=AlertGroupLogRecord.TYPE_UN_RESOLVED, author=user) + log_record = self.log_records.create( + type=AlertGroupLogRecord.TYPE_UN_RESOLVED, author=user, action_source=action_source + ) if self.is_root_alert_group: self.start_escalation_if_needed() @@ -807,7 +822,7 @@ def un_resolve_by_user(self, user: User, action_source: typing.Optional[str] = N dependent_alert_group.un_resolve_by_user(user, action_source=action_source) def attach_by_user( - self, user: User, root_alert_group: "AlertGroup", action_source: typing.Optional[str] = None + self, user: User, root_alert_group: "AlertGroup", action_source: typing.Optional[ActionSource] = None ) -> None: from apps.alerts.models import AlertGroupLogRecord @@ -831,6 +846,7 @@ def attach_by_user( author=user, root_alert_group=root_alert_group, reason="Attach dropdown", + action_source=action_source, ) logger.debug( @@ -850,6 +866,7 @@ def attach_by_user( author=user, dependent_alert_group=self, reason="Attach dropdown", + action_source=action_source, ) logger.debug( @@ -870,6 +887,7 @@ def attach_by_user( author=user, root_alert_group=root_alert_group, reason="Failed to attach dropdown", + action_source=action_source, ) logger.debug( @@ -884,7 +902,7 @@ def attach_by_user( action_source=action_source, ) - def un_attach_by_user(self, user: User, action_source: typing.Optional[str] = None) -> None: + def un_attach_by_user(self, user: User, action_source: typing.Optional[ActionSource] = None) -> None: from apps.alerts.models import AlertGroupLogRecord root_alert_group: AlertGroup = self.root_alert_group @@ -898,6 +916,7 @@ def un_attach_by_user(self, user: User, action_source: typing.Optional[str] = No author=user, root_alert_group=root_alert_group, reason="Unattach button", + action_source=action_source, ) logger.debug( @@ -917,6 +936,7 @@ def un_attach_by_user(self, user: User, action_source: typing.Optional[str] = No author=user, dependent_alert_group=self, reason="Unattach dropdown", + action_source=action_source, ) logger.debug( @@ -957,7 +977,7 @@ def un_attach_by_delete(self): ) def silence_by_user( - self, user: User, silence_delay: typing.Optional[int], action_source: typing.Optional[str] = None + self, user: User, silence_delay: typing.Optional[int], action_source: typing.Optional[ActionSource] = None ) -> None: from apps.alerts.models import AlertGroupLogRecord @@ -965,11 +985,18 @@ def silence_by_user( if self.resolved: self.unresolve() - self.log_records.create(type=AlertGroupLogRecord.TYPE_UN_RESOLVED, author=user, reason="Silence button") + self.log_records.create( + type=AlertGroupLogRecord.TYPE_UN_RESOLVED, + author=user, + reason="Silence button", + action_source=action_source, + ) if self.acknowledged: self.unacknowledge() - self.log_records.create(type=AlertGroupLogRecord.TYPE_UN_ACK, author=user, reason="Silence button") + self.log_records.create( + type=AlertGroupLogRecord.TYPE_UN_ACK, author=user, reason="Silence button", action_source=action_source + ) if self.silenced: self.un_silence() @@ -978,6 +1005,7 @@ def silence_by_user( author=user, silence_delay=None, reason="Silence button", + action_source=action_source, ) now = timezone.now() @@ -1006,6 +1034,7 @@ def silence_by_user( author=user, silence_delay=silence_delay_timedelta, reason="Silence button", + action_source=action_source, ) logger.debug( @@ -1022,7 +1051,7 @@ def silence_by_user( for dependent_alert_group in self.dependent_alert_groups.all(): dependent_alert_group.silence_by_user(user, silence_delay, action_source) - def un_silence_by_user(self, user: User, action_source: typing.Optional[str] = None) -> None: + def un_silence_by_user(self, user: User, action_source: typing.Optional[ActionSource] = None) -> None: from apps.alerts.models import AlertGroupLogRecord initial_state = self.state @@ -1040,6 +1069,7 @@ def un_silence_by_user(self, user: User, action_source: typing.Optional[str] = N silence_delay=None, # 2.Look like some time ago there was no TYPE_UN_SILENCE reason="Unsilence button", + action_source=action_source, ) logger.debug( diff --git a/engine/apps/alerts/models/alert_group_log_record.py b/engine/apps/alerts/models/alert_group_log_record.py index c8fc5e7cfd..628de058ce 100644 --- a/engine/apps/alerts/models/alert_group_log_record.py +++ b/engine/apps/alerts/models/alert_group_log_record.py @@ -10,6 +10,7 @@ from rest_framework.fields import DateTimeField from apps.alerts import tasks +from apps.alerts.constants import ActionSource from apps.alerts.utils import render_relative_timeline from apps.slack.slack_formatter import SlackFormatter from common.utils import clean_markup @@ -155,6 +156,9 @@ class AlertGroupLogRecord(models.Model): type = models.IntegerField(choices=TYPE_CHOICES) + # Where the action was performed (e.g. web UI, Slack, API, etc.) + action_source = models.SmallIntegerField(ActionSource.choices, null=True, default=None) + author = models.ForeignKey( "user_management.User", on_delete=models.SET_NULL, @@ -248,7 +252,6 @@ def rendered_log_line_action(self, for_slack=False, html=False, substitute_autho from apps.alerts.models import EscalationPolicy result = "" - author_name = None invitee_name = None escalation_policy_step = None step_specific_info = self.get_step_specific_info() @@ -258,13 +261,18 @@ def rendered_log_line_action(self, for_slack=False, html=False, substitute_autho elif self.escalation_policy is not None: escalation_policy_step = self.escalation_policy.step - if self.author is not None: + if self.action_source == ActionSource.API: + author_name = "API" + elif self.author: if substitute_author_with_tag: author_name = "{{author}}" elif for_slack: author_name = self.author.get_username_with_slack_verbal() else: author_name = self.author.username + else: + author_name = None + if self.invitation is not None: if for_slack: invitee_name = self.invitation.invitee.get_username_with_slack_verbal() @@ -479,7 +487,7 @@ def rendered_log_line_action(self, for_slack=False, html=False, substitute_autho f"because it is already attached or resolved." ) elif self.type == AlertGroupLogRecord.TYPE_RESOLVED: - result += f"alert group resolved {f'by {author_name}'if author_name else ''}" + result += f"resolved {f'by {author_name}'if author_name else ''}" elif self.type == AlertGroupLogRecord.TYPE_UN_RESOLVED: result += f"unresolved by {author_name}" elif self.type == AlertGroupLogRecord.TYPE_WIPED: diff --git a/engine/apps/alerts/tests/test_alert_group.py b/engine/apps/alerts/tests/test_alert_group.py index 5748c44f72..bf0f4a6fde 100644 --- a/engine/apps/alerts/tests/test_alert_group.py +++ b/engine/apps/alerts/tests/test_alert_group.py @@ -2,6 +2,7 @@ import pytest +from apps.alerts.constants import ActionSource from apps.alerts.incident_appearance.renderers.phone_call_renderer import AlertGroupPhoneCallRenderer from apps.alerts.models import AlertGroup, AlertGroupLogRecord from apps.alerts.tasks.delete_alert_group import delete_alert_group @@ -403,3 +404,59 @@ def test_bulk_silence_forever( assert alert_group.silenced assert alert_group.raw_escalation_snapshot["next_step_eta"] == raw_next_step_eta assert not mocked_start_unsilence_task.called + + +@pytest.mark.parametrize("action_source", ActionSource) +@pytest.mark.django_db +def test_alert_group_log_record_action_source( + make_organization_and_user, + make_alert_receive_channel, + make_alert_group, + action_source, +): + """Test that action source is saved in alert group log record""" + organization, user = make_organization_and_user() + alert_receive_channel = make_alert_receive_channel(organization) + + alert_group = make_alert_group(alert_receive_channel) + root_alert_group = make_alert_group(alert_receive_channel) + + # Silence alert group + alert_group.silence_by_user(user, 42, action_source=action_source) + log_record = alert_group.log_records.last() + assert (log_record.type, log_record.action_source) == (AlertGroupLogRecord.TYPE_SILENCE, action_source) + + # Unsilence alert group + alert_group.un_silence_by_user(user, action_source=action_source) + log_record = alert_group.log_records.last() + assert (log_record.type, log_record.action_source) == (AlertGroupLogRecord.TYPE_UN_SILENCE, action_source) + + # Acknowledge alert group + alert_group.acknowledge_by_user(user, action_source=action_source) + log_record = alert_group.log_records.last() + assert (log_record.type, log_record.action_source) == (AlertGroupLogRecord.TYPE_ACK, action_source) + + # Unacknowledge alert group + alert_group.un_acknowledge_by_user(user, action_source=action_source) + log_record = alert_group.log_records.last() + assert (log_record.type, log_record.action_source) == (AlertGroupLogRecord.TYPE_UN_ACK, action_source) + + # Resolve alert group + alert_group.resolve_by_user(user, action_source=action_source) + log_record = alert_group.log_records.last() + assert (log_record.type, log_record.action_source) == (AlertGroupLogRecord.TYPE_RESOLVED, action_source) + + # Unresolve alert group + alert_group.un_resolve_by_user(user, action_source=action_source) + log_record = alert_group.log_records.last() + assert (log_record.type, log_record.action_source) == (AlertGroupLogRecord.TYPE_UN_RESOLVED, action_source) + + # Attach alert group + alert_group.attach_by_user(user, root_alert_group, action_source=action_source) + log_record = alert_group.log_records.last() + assert (log_record.type, log_record.action_source) == (AlertGroupLogRecord.TYPE_ATTACHED, action_source) + + # Unattach alert group + alert_group.un_attach_by_user(user, action_source=action_source) + log_record = alert_group.log_records.last() + assert (log_record.type, log_record.action_source) == (AlertGroupLogRecord.TYPE_UNATTACHED, action_source) diff --git a/engine/apps/api/tests/test_alert_group.py b/engine/apps/api/tests/test_alert_group.py index 680209514d..024c8e5877 100644 --- a/engine/apps/api/tests/test_alert_group.py +++ b/engine/apps/api/tests/test_alert_group.py @@ -8,6 +8,7 @@ from rest_framework.response import Response from rest_framework.test import APIClient +from apps.alerts.constants import ActionSource from apps.alerts.models import AlertGroup, AlertGroupLogRecord from apps.api.errors import AlertGroupAPIError from apps.api.permissions import LegacyAccessControlRole @@ -1862,3 +1863,31 @@ def test_alert_group_resolve_resolution_note( assert new_alert_group.has_resolution_notes assert mock_signal.called + + +@pytest.mark.django_db +def test_timeline_api_action( + make_organization_and_user_with_plugin_token, + make_alert_receive_channel, + make_channel_filter, + make_alert_group, + make_alert, + make_user_auth_headers, +): + """Check that the timeline API returns the correct actions when using AlertSource.WEB vs ActionSource.API""" + organization, user, token = make_organization_and_user_with_plugin_token() + alert_receive_channel = make_alert_receive_channel(organization) + channel_filter = make_channel_filter(alert_receive_channel, is_default=True) + alert_group = make_alert_group(alert_receive_channel, channel_filter=channel_filter) + make_alert(alert_group=alert_group, raw_request_data=alert_raw_request_data) + + alert_group.acknowledge_by_user(user, action_source=ActionSource.WEB) + alert_group.resolve_by_user(user, action_source=ActionSource.API) + + client = APIClient() + url = reverse("api-internal:alertgroup-detail", kwargs={"pk": alert_group.public_primary_key}) + response = client.get(url, **make_user_auth_headers(user, token)) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["render_after_resolve_report_json"][0]["action"] == "acknowledged by {{author}}" + assert response.json()["render_after_resolve_report_json"][1]["action"] == "resolved by API" diff --git a/engine/apps/public_api/tests/test_incidents.py b/engine/apps/public_api/tests/test_incidents.py index 0918eea3f8..8adb7866b1 100644 --- a/engine/apps/public_api/tests/test_incidents.py +++ b/engine/apps/public_api/tests/test_incidents.py @@ -6,6 +6,7 @@ from rest_framework import status from rest_framework.test import APIClient +from apps.alerts.constants import ActionSource from apps.alerts.models import AlertGroup, AlertReceiveChannel @@ -291,14 +292,167 @@ def test_pagination(settings, incident_public_api_setup): assert result["next"].startswith("https://test.com/test/prefixed/urls") -# This is test from old django-based tests -# TODO: uncomment with date checking in delete mode -# def test_delete_incident_invalid_date(self): -# not_valid_creation_date = VALID_DATE_FOR_DELETE_INCIDENT - timezone.timedelta(days=1) -# self.grafana_second_alert_group.started_at = not_valid_creation_date -# self.grafana_second_alert_group.save() -# -# url = reverse("api-public:alert_groups-detail", kwargs={'pk': self.grafana_second_alert_group.public_primary_key}) -# data = {"mode": "delete"} -# response = self.client.delete(url, data=data, format="json", HTTP_AUTHORIZATION=f"{self.token}") -# self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) +@pytest.mark.parametrize( + "acknowledged,resolved,attached,maintenance,status_code", + [ + (False, False, False, False, status.HTTP_200_OK), + (True, False, False, False, status.HTTP_400_BAD_REQUEST), + (False, True, False, False, status.HTTP_400_BAD_REQUEST), + (False, False, True, False, status.HTTP_400_BAD_REQUEST), + (False, False, False, True, status.HTTP_400_BAD_REQUEST), + ], +) +@pytest.mark.django_db +def test_alert_group_acknowledge( + make_organization_and_user_with_token, + make_alert_receive_channel, + make_alert_group, + acknowledged, + resolved, + attached, + maintenance, + status_code, +): + organization, _, token = make_organization_and_user_with_token() + alert_receive_channel = make_alert_receive_channel(organization) + root_alert_group = make_alert_group(alert_receive_channel) + alert_group = make_alert_group( + alert_receive_channel, + acknowledged=acknowledged, + resolved=resolved, + root_alert_group=root_alert_group if attached else None, + maintenance_uuid="test_maintenance_uuid" if maintenance else None, + ) + + client = APIClient() + url = reverse("api-public:alert_groups-acknowledge", kwargs={"pk": alert_group.public_primary_key}) + response = client.post(url, HTTP_AUTHORIZATION=token) + assert response.status_code == status_code + + if status_code == status.HTTP_200_OK: + alert_group.refresh_from_db() + assert alert_group.acknowledged is True + assert alert_group.log_records.last().action_source == ActionSource.API + + +@pytest.mark.parametrize( + "acknowledged,resolved,attached,maintenance,status_code", + [ + (True, False, False, False, status.HTTP_200_OK), + (True, True, False, False, status.HTTP_400_BAD_REQUEST), + (True, False, True, False, status.HTTP_400_BAD_REQUEST), + (True, False, False, True, status.HTTP_400_BAD_REQUEST), + (False, False, False, False, status.HTTP_400_BAD_REQUEST), + ], +) +@pytest.mark.django_db +def test_alert_group_unacknowledge( + make_organization_and_user_with_token, + make_alert_receive_channel, + make_alert_group, + acknowledged, + resolved, + attached, + maintenance, + status_code, +): + organization, _, token = make_organization_and_user_with_token() + alert_receive_channel = make_alert_receive_channel(organization) + root_alert_group = make_alert_group(alert_receive_channel) + alert_group = make_alert_group( + alert_receive_channel, + acknowledged=acknowledged, + resolved=resolved, + root_alert_group=root_alert_group if attached else None, + maintenance_uuid="test_maintenance_uuid" if maintenance else None, + ) + + client = APIClient() + url = reverse("api-public:alert_groups-unacknowledge", kwargs={"pk": alert_group.public_primary_key}) + response = client.post(url, HTTP_AUTHORIZATION=token) + assert response.status_code == status_code + + if status_code == status.HTTP_200_OK: + alert_group.refresh_from_db() + assert alert_group.acknowledged is False + assert alert_group.log_records.last().action_source == ActionSource.API + + +@pytest.mark.parametrize( + "resolved,attached,maintenance,status_code", + [ + (False, False, False, status.HTTP_200_OK), + (False, False, True, status.HTTP_200_OK), + (True, False, False, status.HTTP_400_BAD_REQUEST), + (False, True, False, status.HTTP_400_BAD_REQUEST), + ], +) +@pytest.mark.django_db +def test_alert_group_resolve( + make_organization_and_user_with_token, + make_alert_receive_channel, + make_alert_group, + resolved, + attached, + maintenance, + status_code, +): + organization, _, token = make_organization_and_user_with_token() + alert_receive_channel = make_alert_receive_channel(organization) + root_alert_group = make_alert_group(alert_receive_channel) + alert_group = make_alert_group( + alert_receive_channel, + resolved=resolved, + root_alert_group=root_alert_group if attached else None, + maintenance_uuid="test_maintenance_uuid" if maintenance else None, + ) + + client = APIClient() + url = reverse("api-public:alert_groups-resolve", kwargs={"pk": alert_group.public_primary_key}) + response = client.post(url, HTTP_AUTHORIZATION=token) + assert response.status_code == status_code + + if status_code == status.HTTP_200_OK and not maintenance: + alert_group.refresh_from_db() + assert alert_group.resolved is True + assert alert_group.log_records.last().action_source == ActionSource.API + + +@pytest.mark.parametrize( + "resolved,attached,maintenance,status_code", + [ + (True, False, False, status.HTTP_200_OK), + (True, True, False, status.HTTP_400_BAD_REQUEST), + (True, False, True, status.HTTP_400_BAD_REQUEST), + (False, False, False, status.HTTP_400_BAD_REQUEST), + ], +) +@pytest.mark.django_db +def test_alert_group_unresolve( + make_organization_and_user_with_token, + make_alert_receive_channel, + make_alert_group, + resolved, + attached, + maintenance, + status_code, +): + organization, _, token = make_organization_and_user_with_token() + alert_receive_channel = make_alert_receive_channel(organization) + root_alert_group = make_alert_group(alert_receive_channel) + alert_group = make_alert_group( + alert_receive_channel, + resolved=resolved, + root_alert_group=root_alert_group if attached else None, + maintenance_uuid="test_maintenance_uuid" if maintenance else None, + ) + + client = APIClient() + url = reverse("api-public:alert_groups-unresolve", kwargs={"pk": alert_group.public_primary_key}) + response = client.post(url, HTTP_AUTHORIZATION=token) + assert response.status_code == status_code + + if status_code == status.HTTP_200_OK: + alert_group.refresh_from_db() + assert alert_group.resolved is False + assert alert_group.log_records.last().action_source == ActionSource.API diff --git a/engine/apps/public_api/views/incidents.py b/engine/apps/public_api/views/incidents.py index 573a95bc36..05fc916c40 100644 --- a/engine/apps/public_api/views/incidents.py +++ b/engine/apps/public_api/views/incidents.py @@ -1,11 +1,13 @@ from django.db.models import Q from django_filters import rest_framework as filters from rest_framework import mixins, status +from rest_framework.decorators import action from rest_framework.exceptions import NotFound from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet +from apps.alerts.constants import ActionSource from apps.alerts.models import AlertGroup from apps.alerts.tasks import delete_alert_group, wipe from apps.auth_token.auth import ApiTokenAuthentication @@ -112,3 +114,74 @@ def destroy(self, request, *args, **kwargs): wipe.apply_async((instance.pk, request.user.pk)) return Response(status=status.HTTP_204_NO_CONTENT) + + @action(methods=["post"], detail=True) + def acknowledge(self, request, pk): + alert_group = self.get_object() + + if alert_group.acknowledged: + raise BadRequest(detail="Can't acknowledge an acknowledged alert group") + + if alert_group.resolved: + raise BadRequest(detail="Can't acknowledge a resolved alert group") + + if alert_group.root_alert_group: + raise BadRequest(detail="Can't acknowledge an attached alert group") + + if alert_group.is_maintenance_incident: + raise BadRequest(detail="Can't acknowledge a maintenance alert group") + + alert_group.acknowledge_by_user(self.request.user, action_source=ActionSource.API) + return Response(status=status.HTTP_200_OK) + + @action(methods=["post"], detail=True) + def unacknowledge(self, request, pk): + alert_group = self.get_object() + + if not alert_group.acknowledged: + raise BadRequest(detail="Can't unacknowledge an unacknowledged alert group") + + if alert_group.resolved: + raise BadRequest(detail="Can't unacknowledge a resolved alert group") + + if alert_group.root_alert_group: + raise BadRequest(detail="Can't unacknowledge an attached alert group") + + if alert_group.is_maintenance_incident: + raise BadRequest(detail="Can't unacknowledge a maintenance alert group") + + alert_group.un_acknowledge_by_user(self.request.user, action_source=ActionSource.API) + return Response(status=status.HTTP_200_OK) + + @action(methods=["post"], detail=True) + def resolve(self, request, pk): + alert_group = self.get_object() + + if alert_group.resolved: + raise BadRequest(detail="Can't resolve a resolved alert group") + + if alert_group.root_alert_group: + raise BadRequest(detail="Can't resolve an attached alert group") + + if alert_group.is_maintenance_incident: + alert_group.stop_maintenance(self.request.user) + else: + alert_group.resolve_by_user(self.request.user, action_source=ActionSource.API) + + return Response(status=status.HTTP_200_OK) + + @action(methods=["post"], detail=True) + def unresolve(self, request, pk): + alert_group = self.get_object() + + if not alert_group.resolved: + raise BadRequest(detail="Can't unresolve an unresolved alert group") + + if alert_group.root_alert_group: + raise BadRequest(detail="Can't unresolve an attached alert group") + + if alert_group.is_maintenance_incident: + raise BadRequest(detail="Can't unresolve a maintenance alert group") + + alert_group.un_resolve_by_user(self.request.user, action_source=ActionSource.API) + return Response(status=status.HTTP_200_OK)