From 61b7c2ec48f691f1217b44981a3afcfd8eaf43f1 Mon Sep 17 00:00:00 2001 From: Vadim Stepanov Date: Tue, 14 Mar 2023 14:38:18 +0000 Subject: [PATCH] Add alert group filter by escalation chain (#1535) # What this PR does Adds a new filter on alert groups page that allows to filter alert groups by escalation chain. Screenshot 2023-03-13 at 22 42 00 ## Which issue(s) this PR fixes This should be useful on it's own as it's giving more filtering capabilities, but it also could be useful for https://github.com/grafana/oncall/issues/1300, if PD rulesets are migrated to a single integration with multiple escalation chains. ## Checklist - [x] Tests updated - [x] `CHANGELOG.md` updated --- CHANGELOG.md | 6 ++ .../apps/api/serializers/escalation_chain.py | 9 +++ engine/apps/api/tests/test_alert_group.py | 47 ++++++++++++++ .../apps/api/tests/test_escalation_chain.py | 37 +++++++++++ engine/apps/api/views/alert_group.py | 16 ++++- engine/apps/api/views/escalation_chain.py | 61 +++++++++++++------ .../utils/escalationChain.ts | 8 ++- 7 files changed, 161 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 459abec6b5..1d1887cc27 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 + +- Add filtering by escalation chain to alert groups page ([1535](https://github.com/grafana/oncall/pull/1535)) + ## v1.1.37 (2023-03-14) ### Fixed diff --git a/engine/apps/api/serializers/escalation_chain.py b/engine/apps/api/serializers/escalation_chain.py index d856bcb004..6b07c5bf54 100644 --- a/engine/apps/api/serializers/escalation_chain.py +++ b/engine/apps/api/serializers/escalation_chain.py @@ -29,3 +29,12 @@ def get_number_of_integrations(self, obj): def get_number_of_routes(self, obj): # num_routes param added in queryset via annotate. Check EscalationChainViewSet.get_queryset return getattr(obj, "num_routes") + + +class FilterEscalationChainSerializer(serializers.ModelSerializer): + value = serializers.CharField(source="public_primary_key") + display_name = serializers.CharField(source="name") + + class Meta: + model = EscalationChain + fields = ["value", "display_name"] diff --git a/engine/apps/api/tests/test_alert_group.py b/engine/apps/api/tests/test_alert_group.py index 2c51202971..5fc8215c2c 100644 --- a/engine/apps/api/tests/test_alert_group.py +++ b/engine/apps/api/tests/test_alert_group.py @@ -761,6 +761,53 @@ def test_get_filter_with_resolution_note_after_delete_resolution_note( assert len(response.data["results"]) == 1 +@pytest.mark.django_db +def test_get_filter_escalation_chain( + make_organization_and_user_with_plugin_token, + make_alert_receive_channel, + make_channel_filter, + make_escalation_chain, + make_alert_group, + make_alert, + make_user_auth_headers, +): + client = APIClient() + organization, user, token = make_organization_and_user_with_plugin_token() + + alert_receive_channel = make_alert_receive_channel(organization) + + escalation_chain_1 = make_escalation_chain(organization=organization) + escalation_chain_2 = make_escalation_chain(organization=organization) + + channel_filter_1 = make_channel_filter(alert_receive_channel, escalation_chain=escalation_chain_1, is_default=True) + channel_filter_2 = make_channel_filter(alert_receive_channel, escalation_chain=escalation_chain_2, is_default=False) + + alert_group_1 = make_alert_group(alert_receive_channel, channel_filter=channel_filter_1) + make_alert(alert_group=alert_group_1, raw_request_data=alert_raw_request_data) + + alert_group_2 = make_alert_group(alert_receive_channel, channel_filter=channel_filter_2) + make_alert(alert_group=alert_group_2, raw_request_data=alert_raw_request_data) + + url = reverse("api-internal:alertgroup-list") + + # check when a single escalation chain is passed + response = client.get( + url + f"?escalation_chain={escalation_chain_1.public_primary_key}", **make_user_auth_headers(user, token) + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.data["results"]) == 1 + assert response.data["results"][0]["pk"] == alert_group_1.public_primary_key + + # check when multiple escalation chains are passed + response = client.get( + url + + f"?escalation_chain={escalation_chain_1.public_primary_key}&escalation_chain={escalation_chain_2.public_primary_key}", + **make_user_auth_headers(user, token), + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.data["results"]) == 2 + + @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", diff --git a/engine/apps/api/tests/test_escalation_chain.py b/engine/apps/api/tests/test_escalation_chain.py index abe925f88d..a9dcec1d4a 100644 --- a/engine/apps/api/tests/test_escalation_chain.py +++ b/engine/apps/api/tests/test_escalation_chain.py @@ -37,3 +37,40 @@ def test_update_escalation_chain(escalation_chain_internal_api_setup, make_user_ url, data=json.dumps(data), content_type="application/json", **make_user_auth_headers(user, token) ) assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_list_escalation_chains(escalation_chain_internal_api_setup, make_user_auth_headers): + user, token, escalation_chain = escalation_chain_internal_api_setup + client = APIClient() + + url = reverse("api-internal:escalation_chain-list") + response = client.get(url, **make_user_auth_headers(user, token)) + + assert response.status_code == status.HTTP_200_OK + assert response.json() == [ + { + "id": escalation_chain.public_primary_key, + "name": escalation_chain.name, + "number_of_integrations": 0, + "number_of_routes": 0, + "team": None, + } + ] + + +@pytest.mark.django_db +def test_list_escalation_chains_filters(escalation_chain_internal_api_setup, make_user_auth_headers): + user, token, escalation_chain = escalation_chain_internal_api_setup + client = APIClient() + + url = reverse("api-internal:escalation_chain-list") + "?filters=true" + response = client.get(url, **make_user_auth_headers(user, token)) + + assert response.status_code == status.HTTP_200_OK + assert response.json() == [ + { + "value": escalation_chain.public_primary_key, + "display_name": escalation_chain.name, + } + ] diff --git a/engine/apps/api/views/alert_group.py b/engine/apps/api/views/alert_group.py index 24db0f7e0e..4ee1819866 100644 --- a/engine/apps/api/views/alert_group.py +++ b/engine/apps/api/views/alert_group.py @@ -13,7 +13,7 @@ from rest_framework.response import Response from apps.alerts.constants import ActionSource -from apps.alerts.models import Alert, AlertGroup, AlertReceiveChannel +from apps.alerts.models import Alert, AlertGroup, AlertReceiveChannel, EscalationChain from apps.alerts.paging import unpage_user from apps.api.permissions import RBACPermission from apps.api.serializers.alert_group import AlertGroupListSerializer, AlertGroupSerializer @@ -34,6 +34,13 @@ def get_integration_queryset(request): return AlertReceiveChannel.objects_with_maintenance.filter(organization=request.user.organization) +def get_escalation_chain_queryset(request): + if request is None: + return EscalationChain.objects.none() + + return EscalationChain.objects.filter(organization=request.user.organization) + + def get_user_queryset(request): if request is None: return User.objects.none() @@ -67,6 +74,12 @@ class AlertGroupFilter(DateRangeFilterMixin, ModelFieldFilterMixin, filters.Filt to_field_name="public_primary_key", method=ModelFieldFilterMixin.filter_model_field.__name__, ) + escalation_chain = filters.ModelMultipleChoiceFilter( + field_name="channel_filter__escalation_chain", + queryset=get_escalation_chain_queryset, + to_field_name="public_primary_key", + method=ModelFieldFilterMixin.filter_model_field.__name__, + ) started_at_range = filters.DateFromToRangeFilter( field_name="started_at", widget=RangeWidget(attrs={"type": "date"}) ) @@ -527,6 +540,7 @@ def filters(self, request): filter_options = [ {"name": "search", "type": "search"}, {"name": "integration", "type": "options", "href": api_root + "alert_receive_channels/?filters=true"}, + {"name": "escalation_chain", "type": "options", "href": api_root + "escalation_chains/?filters=true"}, { "name": "acknowledged_by", "type": "options", diff --git a/engine/apps/api/views/escalation_chain.py b/engine/apps/api/views/escalation_chain.py index 05cdc216f1..81cd1ecbef 100644 --- a/engine/apps/api/views/escalation_chain.py +++ b/engine/apps/api/views/escalation_chain.py @@ -8,14 +8,29 @@ from apps.alerts.models import EscalationChain from apps.api.permissions import RBACPermission -from apps.api.serializers.escalation_chain import EscalationChainListSerializer, EscalationChainSerializer +from apps.api.serializers.escalation_chain import ( + EscalationChainListSerializer, + EscalationChainSerializer, + FilterEscalationChainSerializer, +) from apps.auth_token.auth import PluginAuthentication from common.api_helpers.exceptions import BadRequest -from common.api_helpers.mixins import ListSerializerMixin, PublicPrimaryKeyMixin, TeamFilteringMixin +from common.api_helpers.mixins import ( + FilterSerializerMixin, + ListSerializerMixin, + PublicPrimaryKeyMixin, + TeamFilteringMixin, +) from common.insight_log import EntityEvent, write_resource_insight_log -class EscalationChainViewSet(TeamFilteringMixin, PublicPrimaryKeyMixin, ListSerializerMixin, viewsets.ModelViewSet): +class EscalationChainViewSet( + TeamFilteringMixin, + PublicPrimaryKeyMixin, + FilterSerializerMixin, + ListSerializerMixin, + viewsets.ModelViewSet, +): authentication_classes = (PluginAuthentication,) permission_classes = (IsAuthenticated, RBACPermission) @@ -35,26 +50,32 @@ class EscalationChainViewSet(TeamFilteringMixin, PublicPrimaryKeyMixin, ListSeri serializer_class = EscalationChainSerializer list_serializer_class = EscalationChainListSerializer + filter_serializer_class = FilterEscalationChainSerializer def get_queryset(self): - queryset = ( - EscalationChain.objects.filter( - organization=self.request.auth.organization, - team=self.request.user.current_team, - ) - .annotate( - num_integrations=Count( - "channel_filters__alert_receive_channel", - distinct=True, - filter=Q(channel_filters__alert_receive_channel__deleted_at__isnull=True), - ) + is_filters_request = self.request.query_params.get("filters", "false") == "true" + + queryset = EscalationChain.objects.filter( + organization=self.request.auth.organization, + team=self.request.user.current_team, + ) + + if is_filters_request: + # Do not annotate num_integrations and num_routes for filters request, + # only fetch public_primary_key and name fields needed by FilterEscalationChainSerializer + return queryset.only("public_primary_key", "name") + + queryset = queryset.annotate( + num_integrations=Count( + "channel_filters__alert_receive_channel", + distinct=True, + filter=Q(channel_filters__alert_receive_channel__deleted_at__isnull=True), ) - .annotate( - num_routes=Count( - "channel_filters", - distinct=True, - filter=Q(channel_filters__alert_receive_channel__deleted_at__isnull=True), - ) + ).annotate( + num_routes=Count( + "channel_filters", + distinct=True, + filter=Q(channel_filters__alert_receive_channel__deleted_at__isnull=True), ) ) diff --git a/grafana-plugin/integration-tests/utils/escalationChain.ts b/grafana-plugin/integration-tests/utils/escalationChain.ts index e8553f86cf..3a4263ce56 100644 --- a/grafana-plugin/integration-tests/utils/escalationChain.ts +++ b/grafana-plugin/integration-tests/utils/escalationChain.ts @@ -16,8 +16,8 @@ const escalationStepValuePlaceholder: Record = { export const createEscalationChain = async ( page: Page, escalationChainName: string, - escalationStep: EscalationStep, - escalationStepValue: string + escalationStep: EscalationStep | null, + escalationStepValue: string | null ): Promise => { // go to the escalation chains page await goToOnCallPage(page, 'escalations'); @@ -32,6 +32,10 @@ export const createEscalationChain = async ( await clickButton({ page, buttonText: 'Create' }); await page.waitForSelector(`text=${escalationChainName}`); + if (!escalationStep) { + return; + } + // add an escalation step await selectDropdownValue({ page,