Skip to content

Commit

Permalink
Add alert group filter by escalation chain (#1535)
Browse files Browse the repository at this point in the history
# What this PR does
Adds a new filter on alert groups page that allows to filter alert
groups by escalation chain.

<img width="1204" alt="Screenshot 2023-03-13 at 22 42 00"
src="https://user-images.githubusercontent.com/20116910/224848730-ef753856-a050-4acb-ba36-498d2bca2b4f.png">


## 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
#1300, if PD rulesets are
migrated to a single integration with multiple escalation chains.

## Checklist

- [x] Tests updated
- [x] `CHANGELOG.md` updated
  • Loading branch information
vstpme authored Mar 14, 2023
1 parent e089e29 commit 61b7c2e
Show file tree
Hide file tree
Showing 7 changed files with 161 additions and 23 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions engine/apps/api/serializers/escalation_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
47 changes: 47 additions & 0 deletions engine/apps/api/tests/test_alert_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
37 changes: 37 additions & 0 deletions engine/apps/api/tests/test_escalation_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
]
16 changes: 15 additions & 1 deletion engine/apps/api/views/alert_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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"})
)
Expand Down Expand Up @@ -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",
Expand Down
61 changes: 41 additions & 20 deletions engine/apps/api/views/escalation_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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),
)
)

Expand Down
8 changes: 6 additions & 2 deletions grafana-plugin/integration-tests/utils/escalationChain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ const escalationStepValuePlaceholder: Record<EscalationStep, string> = {
export const createEscalationChain = async (
page: Page,
escalationChainName: string,
escalationStep: EscalationStep,
escalationStepValue: string
escalationStep: EscalationStep | null,
escalationStepValue: string | null
): Promise<void> => {
// go to the escalation chains page
await goToOnCallPage(page, 'escalations');
Expand All @@ -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,
Expand Down

0 comments on commit 61b7c2e

Please sign in to comment.