Skip to content

Commit 02c72e2

Browse files
committed
add evaluation data filters, improve coverage
1 parent a974906 commit 02c72e2

5 files changed

Lines changed: 160 additions & 32 deletions

File tree

api/app_analytics/serializers.py

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, get_args
1+
from typing import TYPE_CHECKING, Any, get_args
22

33
from django.conf import settings
44
from rest_framework import serializers
@@ -12,6 +12,30 @@
1212
from environments.models import Environment
1313
from features.models import FeatureState
1414

15+
if TYPE_CHECKING:
16+
_SerializerType = serializers.Serializer[Any]
17+
else:
18+
_SerializerType = object
19+
20+
21+
class LabelsQuerySerializerMixin(_SerializerType):
22+
def get_fields(self) -> dict[str, serializers.Field[Any, Any, Any, Any]]:
23+
return {**super().get_fields(), **_get_label_fields()}
24+
25+
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
26+
"""
27+
Yank label filters from the top level and place them under a
28+
separate `labels_filter` key
29+
"""
30+
attrs = super().validate(attrs)
31+
if labels_filter := {
32+
label: attrs.pop(label)
33+
for label in LABELS
34+
if label in attrs and attrs[label] is not None
35+
}:
36+
attrs["labels_filter"] = labels_filter
37+
return attrs
38+
1539

1640
class LabelsSerializer(serializers.Serializer[Labels]):
1741
def get_fields(self) -> dict[str, serializers.Field[Any, Any, Any, Any]]:
@@ -27,7 +51,7 @@ class UsageDataSerializer(serializers.Serializer): # type: ignore[type-arg]
2751
labels = LabelsSerializer(allow_null=True, required=False)
2852

2953

30-
class UsageDataQuerySerializer(serializers.Serializer): # type: ignore[type-arg]
54+
class UsageDataQuerySerializer(LabelsQuerySerializerMixin, serializers.Serializer): # type: ignore[type-arg]
3155
project_id = serializers.IntegerField(required=False)
3256
environment_id = serializers.IntegerField(required=False)
3357
period = serializers.ChoiceField(
@@ -37,23 +61,6 @@ class UsageDataQuerySerializer(serializers.Serializer): # type: ignore[type-arg
3761
required=False,
3862
)
3963

40-
def get_fields(self) -> dict[str, serializers.Field[Any, Any, Any, Any]]:
41-
return {**super().get_fields(), **_get_label_fields()}
42-
43-
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
44-
"""
45-
Yank label filters from the top level and place them under a
46-
separate `labels_filter` key
47-
"""
48-
attrs = super().validate(attrs)
49-
if labels_filter := {
50-
label: attrs.pop(label)
51-
for label in LABELS
52-
if label in attrs and attrs[label] is not None
53-
}:
54-
attrs["labels_filter"] = labels_filter
55-
return attrs
56-
5764

5865
class UsageTotalCountSerializer(serializers.Serializer): # type: ignore[type-arg]
5966
count = serializers.IntegerField()

api/features/serializers.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from rest_framework import serializers
2222
from rest_framework.exceptions import PermissionDenied
2323

24+
from app_analytics.serializers import LabelsQuerySerializerMixin, LabelsSerializer
2425
from environments.identities.models import Identity
2526
from environments.sdk.serializers_mixins import (
2627
HideSensitiveFieldsSerializerMixin,
@@ -597,6 +598,7 @@ class FeatureInfluxDataSerializer(serializers.Serializer): # type: ignore[type-
597598
class FeatureEvaluationDataSerializer(serializers.Serializer): # type: ignore[type-arg]
598599
day = serializers.CharField()
599600
count = serializers.IntegerField()
601+
labels = LabelsSerializer(allow_null=True)
600602

601603

602604
class GetInfluxDataQuerySerializer(serializers.Serializer): # type: ignore[type-arg]
@@ -605,7 +607,7 @@ class GetInfluxDataQuerySerializer(serializers.Serializer): # type: ignore[type
605607
aggregate_every = serializers.CharField(required=False, default="24h")
606608

607609

608-
class GetUsageDataQuerySerializer(serializers.Serializer): # type: ignore[type-arg]
610+
class GetUsageDataQuerySerializer(LabelsQuerySerializerMixin, serializers.Serializer): # type: ignore[type-arg]
609611
period = serializers.IntegerField(
610612
required=False, default=30, help_text="number of days"
611613
)

api/features/views.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -382,11 +382,11 @@ def get_influx_data(self, request, pk, project_pk): # type: ignore[no-untyped-d
382382
def get_evaluation_data(self, request, pk, project_pk): # type: ignore[no-untyped-def]
383383
feature = get_object_or_404(Feature, pk=pk)
384384

385-
query_serializer = GetUsageDataQuerySerializer(data=request.query_params)
386-
query_serializer.is_valid(raise_exception=True)
385+
filters = GetUsageDataQuerySerializer(data=request.query_params)
386+
filters.is_valid(raise_exception=True)
387387

388388
usage_data = get_feature_evaluation_data(
389-
feature=feature, **query_serializer.data
389+
feature=feature, **filters.validated_data
390390
)
391391
serializer = FeatureEvaluationDataSerializer(usage_data, many=True)
392392

api/tests/unit/app_analytics/test_unit_app_analytics_views.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,55 @@ def test_get_usage_data__ninety_day_period(
307307
)
308308

309309

310+
def test_get_usage_data__labels_filter__returns_expected(
311+
mocker: MockerFixture,
312+
admin_client_new: APIClient,
313+
organisation: Organisation,
314+
) -> None:
315+
# Given
316+
today = date.today()
317+
318+
url = reverse("api-v1:organisations:usage-data", args=[organisation.id])
319+
url += "?client_application_name=test-app"
320+
321+
mocked_get_usage_data = mocker.patch(
322+
"app_analytics.views.get_usage_data",
323+
autospec=True,
324+
return_value=[
325+
UsageData(
326+
flags=10,
327+
day=today,
328+
labels={"client_application_name": "test-app"},
329+
),
330+
],
331+
)
332+
333+
# When
334+
response = admin_client_new.get(url)
335+
336+
# Then
337+
assert response.status_code == status.HTTP_200_OK
338+
assert response.json() == [
339+
{
340+
"flags": 10,
341+
"day": str(today),
342+
"identities": 0,
343+
"traits": 0,
344+
"environment_document": 0,
345+
"labels": {
346+
"client_application_name": "test-app",
347+
"client_application_version": None,
348+
},
349+
},
350+
]
351+
352+
mocked_get_usage_data.assert_called_once_with(
353+
organisation=organisation,
354+
period=None,
355+
labels_filter={"client_application_name": "test-app"},
356+
)
357+
358+
310359
def test_get_usage_data_for_non_admin_user_returns_403( # type: ignore[no-untyped-def]
311360
mocker, test_user_client, organisation
312361
):

api/tests/unit/features/test_unit_features_views.py

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -872,28 +872,98 @@ def test_get_feature_evaluation_data(
872872
)
873873
url = f"{base_url}?environment_id={environment.id}"
874874
mocked_get_feature_evaluation_data = mocker.patch(
875-
"features.views.get_feature_evaluation_data", autospec=True
875+
"features.views.get_feature_evaluation_data",
876+
autospec=True,
876877
)
878+
today = date.today()
877879
mocked_get_feature_evaluation_data.return_value = [
878-
FeatureEvaluationData(count=10, day=date.today()),
879-
FeatureEvaluationData(count=10, day=date.today() - timedelta(days=1)),
880+
FeatureEvaluationData(count=10, day=today),
881+
FeatureEvaluationData(
882+
count=10,
883+
day=today,
884+
labels={"client_application_name": "web"},
885+
),
886+
FeatureEvaluationData(count=10, day=today - timedelta(days=1)),
880887
]
881888
# When
882889
response = admin_client_new.get(url)
883890

884891
# Then
885892
assert response.status_code == status.HTTP_200_OK
886-
assert len(response.json()) == 2
887-
assert response.json()[0] == {"day": str(date.today()), "count": 10}
888-
assert response.json()[1] == {
889-
"day": str(date.today() - timedelta(days=1)),
890-
"count": 10,
891-
}
893+
assert response.json() == [
894+
{
895+
"count": 10,
896+
"day": str(today),
897+
"labels": None,
898+
},
899+
{
900+
"count": 10,
901+
"day": str(today),
902+
"labels": {
903+
"client_application_name": "web",
904+
"client_application_version": None,
905+
},
906+
},
907+
{
908+
"count": 10,
909+
"day": str(today - timedelta(days=1)),
910+
"labels": None,
911+
},
912+
]
892913
mocked_get_feature_evaluation_data.assert_called_with(
893914
feature=feature, period=30, environment_id=environment.id
894915
)
895916

896917

918+
def test_get_feature_evaluation_data__labels_filter__returns_expected(
919+
project: Project,
920+
feature: Feature,
921+
environment: Environment,
922+
mocker: MockerFixture,
923+
admin_client_new: APIClient,
924+
) -> None:
925+
# Given
926+
base_url = reverse(
927+
"api-v1:projects:project-features-get-evaluation-data",
928+
args=[project.id, feature.id],
929+
)
930+
url = f"{base_url}?environment_id={environment.id}&client_application_name=web"
931+
mocked_get_feature_evaluation_data = mocker.patch(
932+
"features.views.get_feature_evaluation_data",
933+
autospec=True,
934+
)
935+
today = date.today()
936+
mocked_get_feature_evaluation_data.return_value = [
937+
FeatureEvaluationData(
938+
count=10,
939+
day=today,
940+
labels={"client_application_name": "web"},
941+
),
942+
]
943+
944+
# When
945+
response = admin_client_new.get(url)
946+
947+
# Then
948+
assert response.status_code == status.HTTP_200_OK
949+
assert response.json() == [
950+
{
951+
"count": 10,
952+
"day": str(today),
953+
"labels": {
954+
"client_application_name": "web",
955+
"client_application_version": None,
956+
},
957+
},
958+
]
959+
mocked_get_feature_evaluation_data.assert_called_with(
960+
feature=feature,
961+
period=30,
962+
environment_id=environment.id,
963+
labels_filter={"client_application_name": "web"},
964+
)
965+
966+
897967
def test_create_segment_override_forbidden(
898968
feature: Feature,
899969
segment: Segment,

0 commit comments

Comments
 (0)