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:
parent
8dee2503e6
commit
a416863a28
12 changed files with 168 additions and 30 deletions
|
|
@ -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**
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -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",
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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": [],
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue