Skip to content

Commit

Permalink
feat: add limits and totals to API responses (#2615)
Browse files Browse the repository at this point in the history
  • Loading branch information
matthewelwell authored Aug 10, 2023
1 parent 5e13707 commit 321d435
Show file tree
Hide file tree
Showing 8 changed files with 176 additions and 21 deletions.
4 changes: 2 additions & 2 deletions api/audit/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

from audit.models import AuditLog
from environments.serializers import EnvironmentSerializerLight
from projects.serializers import ProjectSerializer
from projects.serializers import ProjectListSerializer
from users.serializers import UserListSerializer


class AuditLogSerializer(serializers.ModelSerializer):
author = UserListSerializer()
environment = EnvironmentSerializerLight()
project = ProjectSerializer()
project = ProjectListSerializer()

class Meta:
model = AuditLog
Expand Down
14 changes: 12 additions & 2 deletions api/environments/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@
ReadOnlyIfNotValidPlanMixin,
)
from projects.models import Project
from projects.serializers import ProjectSerializer
from projects.serializers import ProjectListSerializer
from util.drf_writable_nested.serializers import (
DeleteBeforeUpdateWritableNestedModelSerializer,
)


class EnvironmentSerializerFull(serializers.ModelSerializer):
feature_states = FeatureStateSerializerFull(many=True)
project = ProjectSerializer()
project = ProjectListSerializer()

class Meta:
model = Environment
Expand Down Expand Up @@ -86,6 +86,16 @@ def get_project(self, validated_data: dict = None) -> Project:
)


class EnvironmentRetrieveSerializerWithMetadata(EnvironmentSerializerWithMetadata):
total_segment_overrides = serializers.IntegerField()

class Meta(EnvironmentSerializerWithMetadata.Meta):
fields = EnvironmentSerializerWithMetadata.Meta.fields + (
"total_segment_overrides",
)
read_only_fields = ("total_segment_overrides",)


class CreateUpdateEnvironmentSerializer(
ReadOnlyIfNotValidPlanMixin, EnvironmentSerializerWithMetadata
):
Expand Down
14 changes: 13 additions & 1 deletion api/environments/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import logging

from django.db.models import Count
from django.utils.decorators import method_decorator
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
Expand Down Expand Up @@ -41,6 +42,7 @@
CloneEnvironmentSerializer,
CreateUpdateEnvironmentSerializer,
EnvironmentAPIKeySerializer,
EnvironmentRetrieveSerializerWithMetadata,
EnvironmentSerializerWithMetadata,
WebhookSerializer,
)
Expand Down Expand Up @@ -73,6 +75,8 @@ def get_serializer_class(self):
return DeleteAllTraitKeysSerializer
if self.action == "clone":
return CloneEnvironmentSerializer
if self.action == "retrieve":
return EnvironmentRetrieveSerializerWithMetadata
elif self.action in ("create", "update", "partial_update"):
return CreateUpdateEnvironmentSerializer
return EnvironmentSerializerWithMetadata
Expand All @@ -98,12 +102,20 @@ def get_queryset(self):
return (
self.request.master_api_key.organisation.projects.environments.all()
)

return self.request.user.get_permitted_environments(
"VIEW_ENVIRONMENT", project=project
)

# Permission class handles validation of permissions for other actions
return Environment.objects.all()
queryset = Environment.objects.all()

if self.action == "retrieve":
queryset = queryset.annotate(
total_segment_overrides=Count("feature_segments")
)

return queryset

def perform_create(self, serializer):
environment = serializer.save()
Expand Down
4 changes: 2 additions & 2 deletions api/organisations/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
PermissionModelSerializer,
UserObjectPermissionsSerializer,
)
from projects.serializers import ProjectSerializer
from projects.serializers import ProjectListSerializer
from users.serializers import UserIdSerializer
from webhooks.mixins import TriggerSampleWebhookMixin
from webhooks.webhooks import WebhookType
Expand Down Expand Up @@ -118,7 +118,7 @@ def create(self, request, **kwargs):
def projects(self, request, pk):
organisation = self.get_object()
projects = organisation.projects.all()
return Response(ProjectSerializer(projects, many=True).data)
return Response(ProjectListSerializer(projects, many=True).data)

@action(detail=True, methods=["POST"])
def invite(self, request, pk):
Expand Down
26 changes: 23 additions & 3 deletions api/projects/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from users.serializers import UserListSerializer, UserPermissionGroupSerializer


class ProjectSerializer(serializers.ModelSerializer):
class ProjectListSerializer(serializers.ModelSerializer):
migration_status = serializers.SerializerMethodField(
help_text="Edge migration status of the project; can be one of: "
+ ", ".join([k.value for k in ProjectIdentityMigrationStatus])
Expand All @@ -39,10 +39,8 @@ class Meta:
def get_migration_status(self, obj: Project) -> str:
if not settings.PROJECT_METADATA_TABLE_NAME_DYNAMO:
migration_status = ProjectIdentityMigrationStatus.NOT_APPLICABLE.value

elif obj.is_edge_project_by_default:
migration_status = ProjectIdentityMigrationStatus.MIGRATION_COMPLETED.value

else:
migration_status = IdentityMigrator(obj.id).migration_status.value

Expand All @@ -58,6 +56,28 @@ def get_use_edge_identities(self, obj: Project) -> bool:
)


class ProjectRetrieveSerializer(ProjectListSerializer):
total_features = serializers.IntegerField()
total_segments = serializers.IntegerField()

class Meta(ProjectListSerializer.Meta):
fields = ProjectListSerializer.Meta.fields + (
"max_segments_allowed",
"max_features_allowed",
"max_segment_overrides_allowed",
"total_features",
"total_segments",
)

read_only_fields = (
"max_segments_allowed",
"max_features_allowed",
"max_segment_overrides_allowed",
"total_features",
"total_segments",
)


class CreateUpdateUserProjectPermissionSerializer(
CreateUpdateUserPermissionSerializerABC
):
Expand Down
44 changes: 35 additions & 9 deletions api/projects/tests/test_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@
from django.utils import timezone

from environments.dynamodb.types import ProjectIdentityMigrationStatus
from projects.serializers import ProjectSerializer
from projects.serializers import (
ProjectListSerializer,
ProjectRetrieveSerializer,
)


def test_ProjectSerializer_get_migration_status_returns_migration_not_applicable_if_not_configured(
def test_ProjectListSerializer_get_migration_status_returns_migration_not_applicable_if_not_configured(
mocker, project, settings
):
# Given
Expand All @@ -16,7 +19,7 @@ def test_ProjectSerializer_get_migration_status_returns_migration_not_applicable
"projects.serializers.IdentityMigrator", autospec=True
)

serializer = ProjectSerializer()
serializer = ProjectListSerializer()

# When
migration_status = serializer.get_migration_status(project)
Expand All @@ -26,7 +29,7 @@ def test_ProjectSerializer_get_migration_status_returns_migration_not_applicable
mocked_identity_migrator.assert_not_called()


def test_ProjectSerializer_get_migration_status_returns_migration_completed_for_new_projects(
def test_ProjectListSerializer_get_migration_status_returns_migration_completed_for_new_projects(
mocker, project, settings
):
# Given
Expand All @@ -36,7 +39,7 @@ def test_ProjectSerializer_get_migration_status_returns_migration_completed_for_
"projects.serializers.IdentityMigrator", autospec=True
)

serializer = ProjectSerializer()
serializer = ProjectListSerializer()

# When
migration_status = serializer.get_migration_status(project)
Expand All @@ -46,7 +49,7 @@ def test_ProjectSerializer_get_migration_status_returns_migration_completed_for_
mocked_identity_migrator.assert_not_called()


def test_ProjectSerializer_get_migration_status_calls_migrator_with_correct_arguments_for_old_projects(
def test_ProjectListSerializer_get_migration_status_calls_migrator_with_correct_arguments_for_old_projects(
mocker, project, settings
):
# Given
Expand All @@ -57,7 +60,7 @@ def test_ProjectSerializer_get_migration_status_calls_migrator_with_correct_argu

settings.EDGE_RELEASE_DATETIME = timezone.now()

serializer = ProjectSerializer()
serializer = ProjectListSerializer()

# When
migration_status = serializer.get_migration_status(project)
Expand All @@ -78,9 +81,32 @@ def test_ProjectSerializer_get_migration_status_calls_migrator_with_correct_argu
(ProjectIdentityMigrationStatus.NOT_APPLICABLE.value, False),
],
)
def test_ProjectSerializer_get_use_edge_identities(project, migration_status, expected):
def test_ProjectListSerializer_get_use_edge_identities(
project, migration_status, expected
):
# Given
serializer = ProjectSerializer(context={"migration_status": migration_status})
serializer = ProjectListSerializer(context={"migration_status": migration_status})

# When/Then
assert expected is serializer.get_use_edge_identities(project)


@pytest.mark.parametrize(
"migration_status, expected",
[
(ProjectIdentityMigrationStatus.MIGRATION_COMPLETED.value, True),
(ProjectIdentityMigrationStatus.MIGRATION_IN_PROGRESS.value, False),
(ProjectIdentityMigrationStatus.MIGRATION_NOT_STARTED.value, False),
(ProjectIdentityMigrationStatus.NOT_APPLICABLE.value, False),
],
)
def test_ProjectRetrieveSerializer_get_use_edge_identities(
project, migration_status, expected
):
# Given
serializer = ProjectRetrieveSerializer(
context={"migration_status": migration_status}
)

# When/Then
assert expected is serializer.get_use_edge_identities(project)
21 changes: 19 additions & 2 deletions api/projects/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from __future__ import unicode_literals

from django.conf import settings
from django.db.models import Count, Q
from django.utils.decorators import method_decorator
from drf_yasg import openapi
from drf_yasg.utils import no_body, swagger_auto_schema
Expand Down Expand Up @@ -44,7 +45,8 @@
CreateUpdateUserProjectPermissionSerializer,
ListUserPermissionGroupProjectPermissionSerializer,
ListUserProjectPermissionSerializer,
ProjectSerializer,
ProjectListSerializer,
ProjectRetrieveSerializer,
)


Expand All @@ -70,7 +72,11 @@
),
)
class ProjectViewSet(viewsets.ModelViewSet):
serializer_class = ProjectSerializer
def get_serializer_class(self):
if self.action == "retrieve":
return ProjectRetrieveSerializer
return ProjectListSerializer

permission_classes = [ProjectPermissions | MasterAPIKeyProjectPermissions]
pagination_class = None

Expand All @@ -83,6 +89,17 @@ def get_queryset(self):
else:
queryset = self.request.user.get_permitted_projects(
permission_key=VIEW_PROJECT
).annotate(
total_features=Count(
"features",
filter=Q(features__deleted_at__isnull=True),
distinct=True,
),
total_segments=Count(
"segments",
filter=Q(segments__deleted_at__isnull=True),
distinct=True,
),
)

organisation_id = self.request.query_params.get("organisation")
Expand Down
70 changes: 70 additions & 0 deletions api/tests/unit/projects/test_unit_projects_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import pytest
from django.urls import reverse
from rest_framework import status

from projects.models import Project

list_url = reverse("api-v1:projects:project-list")
PROJECT_NAME = "Test project"


@pytest.mark.django_db
def test_get_project_list_data(admin_client, organisation):
# Given
hide_disabled_flags = False
enable_dynamo_db = False
prevent_flag_defaults = True
enable_realtime_updates = False
only_allow_lower_case_feature_names = True

Project.objects.create(
name=PROJECT_NAME,
organisation=organisation,
hide_disabled_flags=hide_disabled_flags,
enable_dynamo_db=enable_dynamo_db,
prevent_flag_defaults=prevent_flag_defaults,
enable_realtime_updates=enable_realtime_updates,
only_allow_lower_case_feature_names=only_allow_lower_case_feature_names,
)

# When
response = admin_client.get(list_url)

# Then
assert response.status_code == status.HTTP_200_OK
assert response.json()[0]["name"] == PROJECT_NAME
assert response.json()[0]["hide_disabled_flags"] is hide_disabled_flags
assert response.json()[0]["enable_dynamo_db"] is enable_dynamo_db
assert response.json()[0]["prevent_flag_defaults"] is prevent_flag_defaults
assert response.json()[0]["enable_realtime_updates"] is enable_realtime_updates
assert (
response.json()[0]["only_allow_lower_case_feature_names"]
is only_allow_lower_case_feature_names
)
assert "max_segments_allowed" not in response.json()[0].keys()
assert "max_features_allowed" not in response.json()[0].keys()
assert "max_segment_overrides_allowed" not in response.json()[0].keys()
assert "total_features" not in response.json()[0].keys()
assert "total_segments" not in response.json()[0].keys()


@pytest.mark.django_db
def test_get_project_data_by_id(admin_client, organisation):
# Given
project = Project.objects.create(
name=PROJECT_NAME,
organisation=organisation,
)
url = reverse("api-v1:projects:project-detail", args=[project.id])

# When
response = admin_client.get(url)

# Then
assert response.status_code == status.HTTP_200_OK
assert response.json()["name"] == PROJECT_NAME
assert response.json()["max_segments_allowed"] == 100
assert response.json()["max_features_allowed"] == 400
assert response.json()["max_segment_overrides_allowed"] == 100
assert response.json()["total_features"] == 0
assert response.json()["total_segments"] == 0

2 comments on commit 321d435

@vercel
Copy link

@vercel vercel bot commented on 321d435 Aug 10, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vercel
Copy link

@vercel vercel bot commented on 321d435 Aug 10, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.