From a416863a282f00a8c22300c6b68e73e7ea04c459 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Thu, 15 Aug 2024 16:58:25 -0300 Subject: [PATCH] Update alert groups public API filters support (#4832) Related to https://github.com/grafana/oncall/issues/4747 - include labels in response - allow filtering by labels - allow filtering by started_at - update docs --- .../oncall-api-reference/alertgroups.md | 11 +- .../apps/public_api/serializers/__init__.py | 2 +- .../{incidents.py => alert_groups.py} | 12 +- .../public_api/tests/test_alert_groups.py | 107 +++++++++++++++++- .../apps/public_api/tests/test_escalation.py | 2 + engine/apps/public_api/urls.py | 2 +- engine/apps/public_api/views/__init__.py | 2 +- .../views/{incidents.py => alert_groups.py} | 40 +++++-- engine/apps/public_api/views/escalation.py | 4 +- .../webhooks/tests/test_trigger_webhook.py | 6 +- engine/apps/webhooks/utils.py | 4 +- engine/common/api_helpers/filters.py | 6 +- 12 files changed, 168 insertions(+), 30 deletions(-) rename engine/apps/public_api/serializers/{incidents.py => alert_groups.py} (75%) rename engine/apps/public_api/views/{incidents.py => alert_groups.py} (87%) diff --git a/docs/sources/oncall-api-reference/alertgroups.md b/docs/sources/oncall-api-reference/alertgroups.md index ad3b58b0f7..bb8d6f660d 100644 --- a/docs/sources/oncall-api-reference/alertgroups.md +++ b/docs/sources/oncall-api-reference/alertgroups.md @@ -58,10 +58,13 @@ The above command returns JSON structured in the following way: These available filter parameters should be provided as `GET` arguments: -- `id` -- `route_id` -- `integration_id` -- `state` +- `id` (Exact match, alert group ID) +- `route_id` (Exact match, route ID) +- `integration_id` (Exact match, integration ID) +- `label` (Matching labels, can be passed multiple times; expected format: `key1:value1`) +- `team_id` (Exact match, team ID) +- `started_at` (A "{start}_{end}" ISO 8601 timestamp range; expected format: `%Y-%m-%dT%H:%M:%S_%Y-%m-%dT%H:%M:%S`) +- `state` (Possible values: `new`, `acknowledged`, `resolved` or `silenced`) **HTTP request** diff --git a/engine/apps/public_api/serializers/__init__.py b/engine/apps/public_api/serializers/__init__.py index 21f9b08f84..baf4d4c392 100644 --- a/engine/apps/public_api/serializers/__init__.py +++ b/engine/apps/public_api/serializers/__init__.py @@ -1,8 +1,8 @@ +from .alert_groups import AlertGroupSerializer # noqa: F401 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 from .integrations import IntegrationSerializer, IntegrationUpdateSerializer # noqa: F401 from .maintenance import MaintainableObjectSerializerMixin # noqa: F401 from .on_call_shifts import CustomOnCallShiftSerializer, CustomOnCallShiftUpdateSerializer # noqa: F401 diff --git a/engine/apps/public_api/serializers/incidents.py b/engine/apps/public_api/serializers/alert_groups.py similarity index 75% rename from engine/apps/public_api/serializers/incidents.py rename to engine/apps/public_api/serializers/alert_groups.py index 1633b98430..07f1d0fbc6 100644 --- a/engine/apps/public_api/serializers/incidents.py +++ b/engine/apps/public_api/serializers/alert_groups.py @@ -1,13 +1,15 @@ from rest_framework import serializers from apps.alerts.models import AlertGroup -from common.api_helpers.custom_fields import UserIdField +from apps.api.serializers.alert_group import AlertGroupLabelSerializer +from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField, UserIdField from common.api_helpers.mixins import EagerLoadingMixin -class IncidentSerializer(EagerLoadingMixin, serializers.ModelSerializer): +class AlertGroupSerializer(EagerLoadingMixin, serializers.ModelSerializer): id = serializers.CharField(read_only=True, source="public_primary_key") integration_id = serializers.CharField(source="channel.public_primary_key") + team_id = TeamPrimaryKeyRelatedField(source="channel.team", allow_null=True) route_id = serializers.SerializerMethodField() created_at = serializers.DateTimeField(source="started_at") alerts_count = serializers.SerializerMethodField() @@ -15,14 +17,17 @@ class IncidentSerializer(EagerLoadingMixin, serializers.ModelSerializer): state = serializers.SerializerMethodField() acknowledged_by = UserIdField(read_only=True, source="acknowledged_by_user") resolved_by = UserIdField(read_only=True, source="resolved_by_user") + labels = AlertGroupLabelSerializer(many=True, read_only=True) - SELECT_RELATED = ["channel", "channel_filter", "slack_message", "channel__organization"] + SELECT_RELATED = ["channel", "channel_filter", "slack_message", "channel__organization", "channel__team"] + PREFETCH_RELATED = ["labels"] class Meta: model = AlertGroup fields = [ "id", "integration_id", + "team_id", "route_id", "alerts_count", "state", @@ -31,6 +36,7 @@ class Meta: "resolved_by", "acknowledged_at", "acknowledged_by", + "labels", "title", "permalinks", "silenced_at", diff --git a/engine/apps/public_api/tests/test_alert_groups.py b/engine/apps/public_api/tests/test_alert_groups.py index 2b128a49d9..51d074211d 100644 --- a/engine/apps/public_api/tests/test_alert_groups.py +++ b/engine/apps/public_api/tests/test_alert_groups.py @@ -2,6 +2,7 @@ import pytest from django.urls import reverse +from django.utils import timezone from rest_framework import status from rest_framework.test import APIClient @@ -39,10 +40,20 @@ def user_pk_or_none(alert_group, user_field): if u is not None: return u.public_primary_key + labels = [] + for label in alert_group.labels.all(): + labels.append( + { + "key": {"id": label.key_name, "name": label.key_name}, + "value": {"id": label.value_name, "name": label.value_name}, + } + ) + results.append( { "id": alert_group.public_primary_key, "integration_id": alert_group.channel.public_primary_key, + "team_id": alert_group.channel.team.public_primary_key if alert_group.channel.team else None, "route_id": alert_group.channel_filter.public_primary_key, "alerts_count": alert_group.alerts.count(), "state": alert_group.state, @@ -52,6 +63,7 @@ def user_pk_or_none(alert_group, user_field): "acknowledged_by": user_pk_or_none(alert_group, "acknowledged_by_user"), "resolved_by": user_pk_or_none(alert_group, "resolved_by_user"), "title": None, + "labels": labels, "permalinks": { "slack": None, "slack_app": None, @@ -62,7 +74,7 @@ def user_pk_or_none(alert_group, user_field): } ) return { - "count": alert_groups.count(), + "count": len(alert_groups), "next": None, "previous": None, "results": results, @@ -75,15 +87,17 @@ def user_pk_or_none(alert_group, user_field): @pytest.fixture() def alert_group_public_api_setup( make_organization_and_user_with_token, + make_team, make_alert_receive_channel, make_channel_filter, make_alert_group, make_alert, ): organization, user, token = make_organization_and_user_with_token() + team = make_team(organization) grafana = make_alert_receive_channel(organization, integration=AlertReceiveChannel.INTEGRATION_GRAFANA) formatted_webhook = make_alert_receive_channel( - organization, integration=AlertReceiveChannel.INTEGRATION_FORMATTED_WEBHOOK + organization, integration=AlertReceiveChannel.INTEGRATION_FORMATTED_WEBHOOK, team=team ) grafana_default_route = make_channel_filter(grafana, is_default=True) @@ -166,6 +180,25 @@ def test_get_alert_groups(alert_group_public_api_setup): assert response.json() == expected_response +@pytest.mark.django_db +def test_get_alert_groups_include_labels(alert_group_public_api_setup, make_alert_group_label_association): + token, _, _, _ = alert_group_public_api_setup + alert_groups = AlertGroup.objects.all().order_by("-started_at") + alert_group_0 = alert_groups[0] + organization = alert_group_0.channel.organization + # set labels for the first alert group + make_alert_group_label_association(organization, alert_group_0, key_name="a", value_name="b") + + client = APIClient() + expected_response = construct_expected_response_from_alert_groups(alert_groups) + + url = reverse("api-public:alert_groups-list") + response = client.get(url, format="json", HTTP_AUTHORIZATION=token) + + assert response.status_code == status.HTTP_200_OK + assert response.json() == expected_response + + @pytest.mark.django_db def test_get_alert_groups_filter_by_integration( alert_group_public_api_setup, @@ -185,6 +218,54 @@ def test_get_alert_groups_filter_by_integration( assert response.json() == expected_response +@pytest.mark.django_db +def test_get_alert_groups_filter_by_team(alert_group_public_api_setup): + token, alert_groups, integrations, _ = alert_group_public_api_setup + + for integration in integrations: + team_id = integration.team.public_primary_key if integration.team else "null" + alert_groups = AlertGroup.objects.filter(channel=integration).order_by("-started_at") + expected_response = construct_expected_response_from_alert_groups(alert_groups) + + client = APIClient() + url = reverse("api-public:alert_groups-list") + response = client.get(url + f"?team_id={team_id}", format="json", HTTP_AUTHORIZATION=token) + + assert response.status_code == status.HTTP_200_OK + assert response.json() == expected_response + + +@pytest.mark.django_db +def test_get_alert_groups_filter_by_started_at(alert_group_public_api_setup): + token, alert_groups, _, _ = alert_group_public_api_setup + now = timezone.now() + # set custom started_at dates + for i, alert_group in enumerate(alert_groups): + # alert groups starting every 10 days going back + alert_group.started_at = now - timezone.timedelta(days=10 * i + 1) + alert_group.save(update_fields=["started_at"]) + + client = APIClient() + url = reverse("api-public:alert_groups-list") + ranges = ( + # start, end, expected + (now - timezone.timedelta(days=1), now, [alert_groups[0]]), + (now - timezone.timedelta(days=12), now, [alert_groups[0], alert_groups[1]]), + (now - timezone.timedelta(days=12), now - timezone.timedelta(days=5), [alert_groups[1]]), + (now - timezone.timedelta(days=32), now, alert_groups), + ) + + for range_start, range_end, expected_alert_groups in ranges: + started_at_q = "?started_at={}_{}".format( + range_start.strftime("%Y-%m-%dT%H:%M:%S"), range_end.strftime("%Y-%m-%dT%H:%M:%S") + ) + response = client.get(url + started_at_q, format="json", HTTP_AUTHORIZATION=token) + + expected_response = construct_expected_response_from_alert_groups(expected_alert_groups) + assert response.status_code == status.HTTP_200_OK + assert response.json() == expected_response + + @pytest.mark.django_db def test_get_alert_groups_filter_by_state_new( alert_group_public_api_setup, @@ -309,6 +390,28 @@ def test_get_alert_groups_filter_by_route_no_result( assert response.json()["results"] == [] +@pytest.mark.django_db +def test_get_alert_groups_filter_by_labels( + alert_group_public_api_setup, + make_alert_group_label_association, +): + token, alert_groups, _, _ = alert_group_public_api_setup + + organization = alert_groups[0].channel.organization + make_alert_group_label_association(organization, alert_groups[0], key_name="a", value_name="b") + make_alert_group_label_association(organization, alert_groups[0], key_name="c", value_name="d") + make_alert_group_label_association(organization, alert_groups[1], key_name="a", value_name="b") + make_alert_group_label_association(organization, alert_groups[2], key_name="c", value_name="d") + expected_response = construct_expected_response_from_alert_groups([alert_groups[0]]) + + client = APIClient() + url = reverse("api-public:alert_groups-list") + response = client.get(url + "?label=a:b&label=c:d", format="json", HTTP_AUTHORIZATION=token) + + assert response.status_code == status.HTTP_200_OK + assert response.json() == expected_response + + @pytest.mark.parametrize( "data,task,status_code", [ diff --git a/engine/apps/public_api/tests/test_escalation.py b/engine/apps/public_api/tests/test_escalation.py index ebf8c4a0ce..0fad2f71ee 100644 --- a/engine/apps/public_api/tests/test_escalation.py +++ b/engine/apps/public_api/tests/test_escalation.py @@ -56,6 +56,8 @@ def test_escalation_new_alert_group( "id": ag.public_primary_key, "integration_id": ag.channel.public_primary_key, "route_id": ag.channel_filter.public_primary_key, + "team_id": None, + "labels": [], "alerts_count": 1, "state": "firing", "created_at": mock.ANY, diff --git a/engine/apps/public_api/urls.py b/engine/apps/public_api/urls.py index 7f36170bcd..b1fc491ae4 100644 --- a/engine/apps/public_api/urls.py +++ b/engine/apps/public_api/urls.py @@ -17,7 +17,7 @@ router.register(r"escalation_chains", views.EscalationChainView, basename="escalation_chains") router.register(r"escalation_policies", views.EscalationPolicyView, basename="escalation_policies") router.register(r"alerts", views.AlertView, basename="alerts") -router.register(r"alert_groups", views.IncidentView, basename="alert_groups") +router.register(r"alert_groups", views.AlertGroupView, basename="alert_groups") router.register(r"slack_channels", views.SlackChannelView, basename="slack_channels") router.register(r"personal_notification_rules", views.PersonalNotificationView, basename="personal_notification_rules") router.register(r"resolution_notes", views.ResolutionNoteView, basename="resolution_notes") diff --git a/engine/apps/public_api/views/__init__.py b/engine/apps/public_api/views/__init__.py index 49a5f7281c..3bd42729aa 100644 --- a/engine/apps/public_api/views/__init__.py +++ b/engine/apps/public_api/views/__init__.py @@ -1,9 +1,9 @@ from .action import ActionView # noqa: F401 +from .alert_groups import AlertGroupView # 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 from .info import InfoView # noqa: F401 from .integrations import IntegrationView # noqa: F401 from .on_call_shifts import CustomOnCallShiftView # noqa: F401 diff --git a/engine/apps/public_api/views/incidents.py b/engine/apps/public_api/views/alert_groups.py similarity index 87% rename from engine/apps/public_api/views/incidents.py rename to engine/apps/public_api/views/alert_groups.py index 76f25dc8b4..0db6f34932 100644 --- a/engine/apps/public_api/views/incidents.py +++ b/engine/apps/public_api/views/alert_groups.py @@ -10,19 +10,30 @@ from apps.alerts.constants import ActionSource from apps.alerts.models import AlertGroup from apps.alerts.tasks import delete_alert_group, wipe +from apps.api.label_filtering import parse_label_query from apps.auth_token.auth import ApiTokenAuthentication from apps.public_api.constants import VALID_DATE_FOR_DELETE_INCIDENT from apps.public_api.helpers import is_valid_group_creation_date, team_has_slack_token_for_deleting -from apps.public_api.serializers import IncidentSerializer +from apps.public_api.serializers import AlertGroupSerializer from apps.public_api.throttlers.user_throttle import UserThrottle from common.api_helpers.exceptions import BadRequest -from common.api_helpers.filters import NO_TEAM_VALUE, ByTeamModelFieldFilterMixin, get_team_queryset +from common.api_helpers.filters import ( + NO_TEAM_VALUE, + ByTeamModelFieldFilterMixin, + DateRangeFilterMixin, + get_team_queryset, +) from common.api_helpers.mixins import RateLimitHeadersMixin from common.api_helpers.paginators import FiftyPageSizePaginator -class IncidentByTeamFilter(ByTeamModelFieldFilterMixin, filters.FilterSet): - team = filters.ModelChoiceFilter( +class AlertGroupFilters(ByTeamModelFieldFilterMixin, DateRangeFilterMixin, filters.FilterSet): + # query field param name to filter by team + TEAM_FILTER_FIELD_NAME = "team_id" + + id = filters.CharFilter(field_name="public_primary_key") + + team_id = filters.ModelChoiceFilter( field_name="channel__team", queryset=get_team_queryset, to_field_name="public_primary_key", @@ -31,10 +42,13 @@ class IncidentByTeamFilter(ByTeamModelFieldFilterMixin, filters.FilterSet): method=ByTeamModelFieldFilterMixin.filter_model_field_with_single_value.__name__, ) - id = filters.CharFilter(field_name="public_primary_key") + started_at = filters.CharFilter( + field_name="started_at", + method=DateRangeFilterMixin.filter_date_range.__name__, + ) -class IncidentView( +class AlertGroupView( RateLimitHeadersMixin, mixins.ListModelMixin, mixins.RetrieveModelMixin, mixins.DestroyModelMixin, GenericViewSet ): authentication_classes = (ApiTokenAuthentication,) @@ -43,11 +57,11 @@ class IncidentView( throttle_classes = [UserThrottle] model = AlertGroup - serializer_class = IncidentSerializer + serializer_class = AlertGroupSerializer pagination_class = FiftyPageSizePaginator filter_backends = (filters.DjangoFilterBackend,) - filterset_class = IncidentByTeamFilter + filterset_class = AlertGroupFilters def get_queryset(self): route_id = self.request.query_params.get("route_id", None) @@ -82,6 +96,16 @@ def get_queryset(self): ) raise BadRequest(detail={"state": f"Must be one of the following: {valid_choices_text}"}) + # filter by alert group (static, applied) labels + label_query = self.request.query_params.getlist("label", []) + kv_pairs = parse_label_query(label_query) + for key, value in kv_pairs: + queryset = queryset.filter( + labels__organization=self.request.auth.organization, + labels__key_name=key, + labels__value_name=value, + ) + return queryset def get_object(self): diff --git a/engine/apps/public_api/views/escalation.py b/engine/apps/public_api/views/escalation.py index c7da1fe19a..ae3b5717df 100644 --- a/engine/apps/public_api/views/escalation.py +++ b/engine/apps/public_api/views/escalation.py @@ -5,7 +5,7 @@ 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.serializers import AlertGroupSerializer, EscalationSerializer from apps.public_api.throttlers import UserThrottle from common.api_helpers.exceptions import BadRequest @@ -43,4 +43,4 @@ def post(self, request): raise BadRequest(detail=DirectPagingAlertGroupResolvedError.DETAIL) except DirectPagingUserTeamValidationError: raise BadRequest(detail=DirectPagingUserTeamValidationError.DETAIL) - return Response(IncidentSerializer(alert_group).data, status=status.HTTP_200_OK) + return Response(AlertGroupSerializer(alert_group).data, status=status.HTTP_200_OK) diff --git a/engine/apps/webhooks/tests/test_trigger_webhook.py b/engine/apps/webhooks/tests/test_trigger_webhook.py index 38445a8a73..85b3966ae1 100644 --- a/engine/apps/webhooks/tests/test_trigger_webhook.py +++ b/engine/apps/webhooks/tests/test_trigger_webhook.py @@ -9,7 +9,7 @@ from apps.alerts.models import AlertGroupExternalID, AlertGroupLogRecord, EscalationPolicy from apps.base.models import UserNotificationPolicyLogRecord -from apps.public_api.serializers import IncidentSerializer +from apps.public_api.serializers import AlertGroupSerializer from apps.webhooks.models import Webhook from apps.webhooks.models.webhook import WebhookSession from apps.webhooks.tasks import execute_webhook, send_webhook_event @@ -408,7 +408,7 @@ def test_execute_webhook_ok_forward_all( "email": notified_user.email, } ], - "alert_group": {**IncidentSerializer(alert_group).data, "labels": {}}, + "alert_group": {**AlertGroupSerializer(alert_group).data, "labels": {}}, "alert_group_id": alert_group.public_primary_key, "alert_payload": "", "users_to_be_notified": [], @@ -516,7 +516,7 @@ def test_execute_webhook_ok_forward_all_resolved( "email": notified_user.email, } ], - "alert_group": {**IncidentSerializer(alert_group).data, "labels": {}}, + "alert_group": {**AlertGroupSerializer(alert_group).data, "labels": {}}, "alert_group_id": alert_group.public_primary_key, "alert_payload": "", "users_to_be_notified": [], diff --git a/engine/apps/webhooks/utils.py b/engine/apps/webhooks/utils.py index 93ee42f265..f3b90e5593 100644 --- a/engine/apps/webhooks/utils.py +++ b/engine/apps/webhooks/utils.py @@ -151,7 +151,7 @@ def _extract_users_from_escalation_snapshot(escalation_snapshot): def serialize_event(event, alert_group, user, webhook, responses=None): from apps.alerts.models import AlertGroupExternalID - from apps.public_api.serializers import IncidentSerializer + from apps.public_api.serializers import AlertGroupSerializer alert_payload = alert_group.alerts.first() alert_payload_raw = "" @@ -161,7 +161,7 @@ def serialize_event(event, alert_group, user, webhook, responses=None): data = { "event": event, "user": _serialize_event_user(user), - "alert_group": IncidentSerializer(alert_group).data, + "alert_group": AlertGroupSerializer(alert_group).data, "alert_group_id": alert_group.public_primary_key, "alert_payload": alert_payload_raw, "integration": { diff --git a/engine/common/api_helpers/filters.py b/engine/common/api_helpers/filters.py index f3423e0cb0..851ff6f6a8 100644 --- a/engine/common/api_helpers/filters.py +++ b/engine/common/api_helpers/filters.py @@ -76,13 +76,13 @@ def filter_model_field(self, queryset, name, value): class ByTeamModelFieldFilterMixin: - FILTER_FIELD_NAME = "team" + TEAM_FILTER_FIELD_NAME = "team" def filter_model_field_with_single_value(self, queryset, name, value): if not value: return queryset # ModelChoiceFilter - filter = self.filters[ByTeamModelFieldFilterMixin.FILTER_FIELD_NAME] + filter = self.filters[self.TEAM_FILTER_FIELD_NAME] if filter.null_value == value: lookup_kwargs = {f"{name}__isnull": True} else: @@ -93,7 +93,7 @@ def filter_model_field_with_single_value(self, queryset, name, value): def filter_model_field_with_multiple_values(self, queryset, name, values): if not values: return queryset - filter = self.filters[ByTeamModelFieldFilterMixin.FILTER_FIELD_NAME] + filter = self.filters[self.TEAM_FILTER_FIELD_NAME] null_team_lookup = None if filter.null_value in values: null_team_lookup = Q(**{f"{name}__isnull": True})