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
This commit is contained in:
Matias Bordese 2024-08-15 16:58:25 -03:00 committed by GitHub
parent 8dee2503e6
commit a416863a28
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 168 additions and 30 deletions

View file

@ -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**

View file

@ -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

View file

@ -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 IncidentSerializer(EagerLoadingMixin, serializers.ModelSerializer):
"resolved_by",
"acknowledged_at",
"acknowledged_by",
"labels",
"title",
"permalinks",
"silenced_at",

View file

@ -2,6 +2,7 @@ from unittest.mock import patch
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 construct_expected_response_from_alert_groups(alert_groups):
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 construct_expected_response_from_alert_groups(alert_groups):
"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 construct_expected_response_from_alert_groups(alert_groups):
}
)
return {
"count": alert_groups.count(),
"count": len(alert_groups),
"next": None,
"previous": None,
"results": results,
@ -75,15 +87,17 @@ def construct_expected_response_from_alert_groups(alert_groups):
@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",
[

View file

@ -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,

View file

@ -17,7 +17,7 @@ router.register(r"schedules", views.OnCallScheduleChannelView, basename="schedul
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")

View file

@ -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

View file

@ -10,19 +10,30 @@ 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.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 @@ class IncidentView(
)
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):

View file

@ -5,7 +5,7 @@ from rest_framework.views import APIView
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 @@ class EscalationView(APIView):
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)

View file

@ -9,7 +9,7 @@ from django.utils import timezone
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": [],

View file

@ -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": {

View file

@ -76,13 +76,13 @@ class ModelFieldFilterMixin:
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 @@ class ByTeamModelFieldFilterMixin:
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})