Skip to content

Commit

Permalink
Update alert groups public API filters support (#4832)
Browse files Browse the repository at this point in the history
Related to #4747

- include labels in response
- allow filtering by labels
- allow filtering by started_at
- update docs
  • Loading branch information
matiasb authored Aug 15, 2024
1 parent 8dee250 commit a416863
Show file tree
Hide file tree
Showing 12 changed files with 168 additions and 30 deletions.
11 changes: 7 additions & 4 deletions docs/sources/oncall-api-reference/alertgroups.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down
2 changes: 1 addition & 1 deletion engine/apps/public_api/serializers/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,33 @@
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()
title = serializers.SerializerMethodField()
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",
Expand All @@ -31,6 +36,7 @@ class Meta:
"resolved_by",
"acknowledged_at",
"acknowledged_by",
"labels",
"title",
"permalinks",
"silenced_at",
Expand Down
107 changes: 105 additions & 2 deletions engine/apps/public_api/tests/test_alert_groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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",
[
Expand Down
2 changes: 2 additions & 0 deletions engine/apps/public_api/tests/test_escalation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion engine/apps/public_api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion engine/apps/public_api/views/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,)
Expand All @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
4 changes: 2 additions & 2 deletions engine/apps/public_api/views/escalation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Loading

0 comments on commit a416863

Please sign in to comment.