diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a7e4b7e..32f1e8cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 + - Add additional shift info in schedule filter_events internal API ([#3110](https://github.com/grafana/oncall/pull/3110)) ## v1.3.41 (2023-10-04) diff --git a/docs/sources/integrations/_index.md b/docs/sources/integrations/_index.md index 6ceddd3c..fe9ed57a 100644 --- a/docs/sources/integrations/_index.md +++ b/docs/sources/integrations/_index.md @@ -1,5 +1,5 @@ --- -canonical: https://grafana.com/docs/oncall/latest/integration-with-alert-sources/ +canonical: https://grafana.com/docs/oncall/latest/integrations/ keywords: - Grafana Cloud - Alerts diff --git a/docs/sources/integrations/alertmanager/index.md b/docs/sources/integrations/alertmanager/index.md index cae15096..81574049 100644 --- a/docs/sources/integrations/alertmanager/index.md +++ b/docs/sources/integrations/alertmanager/index.md @@ -1,7 +1,7 @@ --- aliases: - add-alertmanager/ -canonical: https://grafana.com/docs/oncall/latest/integrations/available-integrations/configure-alertmanager/ +canonical: https://grafana.com/docs/oncall/latest/integrations/alertmanager/ keywords: - Grafana Cloud - Alerts diff --git a/docs/sources/integrations/grafana-alerting/index.md b/docs/sources/integrations/grafana-alerting/index.md index 42b3baee..af793b19 100644 --- a/docs/sources/integrations/grafana-alerting/index.md +++ b/docs/sources/integrations/grafana-alerting/index.md @@ -1,7 +1,7 @@ --- aliases: - add-grafana-alerting/ -canonical: https://grafana.com/docs/oncall/latest/integrations/available-integrations/configure-grafana-alerting/ +canonical: https://grafana.com/docs/oncall/latest/integrations/grafana-alerting/ keywords: - Grafana Cloud - Alerts diff --git a/docs/sources/integrations/inbound-email/index.md b/docs/sources/integrations/inbound-email/index.md index 24cd06be..ef6add71 100644 --- a/docs/sources/integrations/inbound-email/index.md +++ b/docs/sources/integrations/inbound-email/index.md @@ -1,7 +1,7 @@ --- aliases: - inbound-email/ -canonical: https://grafana.com/docs/oncall/latest/integrations/available-integrations/configure-inbound-email/ +canonical: https://grafana.com/docs/oncall/latest/integrations/inbound-email/ keywords: - Grafana Cloud - Alerts diff --git a/docs/sources/integrations/sentry/index.md b/docs/sources/integrations/sentry/index.md index a924651d..56d8a780 100644 --- a/docs/sources/integrations/sentry/index.md +++ b/docs/sources/integrations/sentry/index.md @@ -2,7 +2,7 @@ aliases: - add-sentry/ - /docs/oncall/latest/integrations/available-integrations/configure-Sentry/ -canonical: https://grafana.com/docs/oncall/latest/integrations/available-integrations/configure-sentry/ +canonical: https://grafana.com/docs/oncall/latest/integrations/sentry/ keywords: - Grafana Cloud - Alerts diff --git a/docs/sources/integrations/webhook/index.md b/docs/sources/integrations/webhook/index.md index 1a559f2d..a6698e1a 100644 --- a/docs/sources/integrations/webhook/index.md +++ b/docs/sources/integrations/webhook/index.md @@ -1,7 +1,7 @@ --- aliases: - ../add-webhook-integration/ -canonical: https://grafana.com/docs/oncall/latest/integrations/available-integrations/configure-webhook/ +canonical: https://grafana.com/docs/oncall/latest/integrations/webhook/ keywords: - Grafana Cloud - Alerts diff --git a/docs/sources/integrations/zabbix/index.md b/docs/sources/integrations/zabbix/index.md index f281e606..70b49c5d 100644 --- a/docs/sources/integrations/zabbix/index.md +++ b/docs/sources/integrations/zabbix/index.md @@ -1,7 +1,7 @@ --- aliases: - add-zabbix/ -canonical: https://grafana.com/docs/oncall/latest/integrations/available-integrations/configure-zabbix/ +canonical: https://grafana.com/docs/oncall/latest/integrations/zabbix/ keywords: - Grafana Cloud - Alerts diff --git a/docs/sources/notify/ms-teams/index.md b/docs/sources/notify/ms-teams/index.md index 94821845..7cf5b176 100644 --- a/docs/sources/notify/ms-teams/index.md +++ b/docs/sources/notify/ms-teams/index.md @@ -1,7 +1,7 @@ --- aliases: - ../../chat-options/configure-teams/ -canonical: https://grafana.com/docs/oncall/latest/integrations/chatops-integrations/configure-teams/ +canonical: https://grafana.com/docs/oncall/latest/notify/ms-teams/ keywords: - Grafana Cloud - Alerts diff --git a/docs/sources/notify/slack/index.md b/docs/sources/notify/slack/index.md index 8407620c..4850ba28 100644 --- a/docs/sources/notify/slack/index.md +++ b/docs/sources/notify/slack/index.md @@ -1,7 +1,7 @@ --- aliases: - ../../chat-options/configure-slack/ -canonical: https://grafana.com/docs/oncall/latest/integrations/chatops-integrations/configure-slack/ +canonical: https://grafana.com/docs/oncall/latest/notify/slack/ keywords: - Grafana Cloud - Alerts diff --git a/docs/sources/notify/telegram/index.md b/docs/sources/notify/telegram/index.md index 5b8dbcd2..49a38ddd 100644 --- a/docs/sources/notify/telegram/index.md +++ b/docs/sources/notify/telegram/index.md @@ -1,7 +1,7 @@ --- aliases: - ../../chat-options/configure-telegram/ -canonical: https://grafana.com/docs/oncall/latest/integrations/chatops-integrations/configure-telegram/ +canonical: https://grafana.com/docs/oncall/latest/notify/telegram/ keywords: - Grafana Cloud - Alerts diff --git a/docs/sources/oncall-api-reference/alertgroups.md b/docs/sources/oncall-api-reference/alertgroups.md index dd5efbbd..9d30c31a 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/docs/sources/oncall-api-reference/outgoing_webhooks.md b/docs/sources/oncall-api-reference/outgoing_webhooks.md index f7f2d4f2..88bed53a 100644 --- a/docs/sources/oncall-api-reference/outgoing_webhooks.md +++ b/docs/sources/oncall-api-reference/outgoing_webhooks.md @@ -10,7 +10,7 @@ weight: 700 > endpoint remains available and is compatible with previous callers but under the hood it will interact with the > new webhooks objects. It is recommended to use the /webhooks endpoint going forward which has more features. -For more details about specific fields of a webhook see [outgoing webhooks][outgoing-webhooks] documentation. +For more details about specific fields of a webhook see [outgoing webhooks](../../outgoing-webhooks) documentation. ## List webhooks @@ -105,7 +105,7 @@ curl "{{API_URL}}/api/v1/webhooks/" \ ### Trigger Types -See [here](outgoing-webhooks#event-types) for details +For more detail, refer to [Event types](../../outgoing-webhooks#event-types). - `escalation` - `alert group created` diff --git a/engine/apps/alerts/constants.py b/engine/apps/alerts/constants.py index 714312f2..aa5adca1 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 00000000..578e0359 --- /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 53d3f65c..0a29a776 100644 --- a/engine/apps/alerts/models/alert_group.py +++ b/engine/apps/alerts/models/alert_group.py @@ -16,7 +16,7 @@ from django.dispatch import receiver 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 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. 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 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. 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 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. 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 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. 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 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. 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 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. 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 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. 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 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. 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 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. # 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 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. 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 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. author=user, root_alert_group=root_alert_group, reason="Attach dropdown", + action_source=action_source, ) logger.debug( @@ -850,6 +866,7 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. author=user, dependent_alert_group=self, reason="Attach dropdown", + action_source=action_source, ) logger.debug( @@ -870,6 +887,7 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. author=user, root_alert_group=root_alert_group, reason="Failed to attach dropdown", + action_source=action_source, ) logger.debug( @@ -884,7 +902,7 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. 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 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. author=user, root_alert_group=root_alert_group, reason="Unattach button", + action_source=action_source, ) logger.debug( @@ -917,6 +936,7 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. author=user, dependent_alert_group=self, reason="Unattach dropdown", + action_source=action_source, ) logger.debug( @@ -957,7 +977,7 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. ) 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 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. 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 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. author=user, silence_delay=None, reason="Silence button", + action_source=action_source, ) now = timezone.now() @@ -1006,6 +1034,7 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. author=user, silence_delay=silence_delay_timedelta, reason="Silence button", + action_source=action_source, ) logger.debug( @@ -1022,7 +1051,7 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. 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 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. 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 c8fc5e7c..628de058 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 django.dispatch import receiver 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 @@ class AlertGroupLogRecord(models.Model): 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 @@ class AlertGroupLogRecord(models.Model): 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 @@ class AlertGroupLogRecord(models.Model): 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 5748c44f..bf0f4a6f 100644 --- a/engine/apps/alerts/tests/test_alert_group.py +++ b/engine/apps/alerts/tests/test_alert_group.py @@ -2,6 +2,7 @@ from unittest.mock import call, patch 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 68020951..024c8e58 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 import status 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 0918eea3..8adb7866 100644 --- a/engine/apps/public_api/tests/test_incidents.py +++ b/engine/apps/public_api/tests/test_incidents.py @@ -6,6 +6,7 @@ from django.urls import reverse 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 573a95bc..05fc916c 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 @@ class IncidentView(RateLimitHeadersMixin, mixins.ListModelMixin, mixins.DestroyM 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)