From 194e214d8795e2a74a6f883d2f930a5f9263c0b6 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Mon, 16 Feb 2026 17:11:55 -0300 Subject: [PATCH 01/11] Sort features by segment override --- api/features/views.py | 17 ++++++++-- .../unit/features/test_unit_features_views.py | 34 +++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/api/features/views.py b/api/features/views.py index de01c33a700d..f541ed09070f 100644 --- a/api/features/views.py +++ b/api/features/views.py @@ -7,7 +7,7 @@ from common.projects.permissions import VIEW_PROJECT from django.conf import settings from django.core.cache import caches -from django.db.models import Max, Q, QuerySet +from django.db.models import Exists, Max, OuterRef, Q, QuerySet from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.cache import cache_page @@ -56,7 +56,7 @@ from .constants import INTERSECTION, UNION from .features_service import get_overrides_data -from .models import Feature, FeatureState +from .models import Feature, FeatureSegment, FeatureState from .multivariate.serializers import ( FeatureMVOptionsValuesResponseSerializer, ) @@ -224,7 +224,18 @@ def get_queryset(self): # type: ignore[no-untyped-def] "-" if query_data["sort_direction"] == "DESC" else "", query_data["sort_field"], ) - queryset = queryset.order_by(sort) + if segment_id := query_data.get("segment"): + queryset = queryset.annotate( + has_segment_override=Exists( + FeatureSegment.objects.filter( + feature=OuterRef("pk"), + segment_id=segment_id, + ) + ), + ) + queryset = queryset.order_by("-has_segment_override", sort) + else: + queryset = queryset.order_by(sort) if environment_id: page = self.paginate_queryset(queryset) diff --git a/api/tests/unit/features/test_unit_features_views.py b/api/tests/unit/features/test_unit_features_views.py index 05abbecf7562..a7cd62e4b51f 100644 --- a/api/tests/unit/features/test_unit_features_views.py +++ b/api/tests/unit/features/test_unit_features_views.py @@ -4150,6 +4150,40 @@ def test_list_features_segment_query_param_with_invalid_segment( assert feature_data["segment_feature_state"] is None +@pytest.mark.parametrize("sort_field", ["name", "created_date"]) +def test_list_features__segment_query__sorts_by_field_with_overrides_first( + admin_client_new: APIClient, + project: Project, + environment: Environment, + segment: Segment, + sort_field: str, +) -> None: + # Given + Feature.objects.create(project=project, name="feature_a") + Feature.objects.create(project=project, name="feature_b") + feature_c = Feature.objects.create(project=project, name="feature_c") + + # feature_c has a segment override + FeatureSegment.objects.create( + feature=feature_c, segment=segment, environment=environment + ) + + # When + response = admin_client_new.get( + f"/api/v1/projects/{project.id}/features/" + f"?environment={environment.id}" + f"&segment={segment.id}" + f"&sort_field={sort_field}&sort_direction=ASC" + ) + + # Then + assert response.status_code == status.HTTP_200_OK + results = response.json()["results"] + result_names = [r["name"] for r in results] + assert result_names[0] == "feature_c" + assert result_names[1:] == ["feature_a", "feature_b"] + + def test_create_multiple_features_with_metadata_keeps_metadata_isolated( admin_client_new: APIClient, project: Project, From 2f3c4a6583ff7919e8d53700c6ecf1efbae2c69e Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Mon, 16 Feb 2026 18:15:51 -0300 Subject: [PATCH 02/11] Sort features by identity override --- api/features/serializers.py | 4 +++ api/features/views.py | 16 +++++++-- .../unit/features/test_unit_features_views.py | 34 +++++++++++++++++++ frontend/web/components/pages/UserPage.tsx | 8 ++--- 4 files changed, 55 insertions(+), 7 deletions(-) diff --git a/api/features/serializers.py b/api/features/serializers.py index 134181afe3a0..41a97ffee7e2 100644 --- a/api/features/serializers.py +++ b/api/features/serializers.py @@ -95,6 +95,10 @@ class FeatureQuerySerializer(serializers.Serializer): # type: ignore[type-arg] required=False, help_text="Integer ID of the segment to retrieve segment overrides for.", ) + identity = serializers.CharField( + required=False, + help_text="ID of the identity to sort features with identity overrides first.", + ) is_enabled = serializers.BooleanField( allow_null=True, required=False, diff --git a/api/features/views.py b/api/features/views.py index f541ed09070f..116586dc9d21 100644 --- a/api/features/views.py +++ b/api/features/views.py @@ -224,6 +224,7 @@ def get_queryset(self): # type: ignore[no-untyped-def] "-" if query_data["sort_direction"] == "DESC" else "", query_data["sort_field"], ) + override_ordering: list[str] = [] if segment_id := query_data.get("segment"): queryset = queryset.annotate( has_segment_override=Exists( @@ -233,9 +234,18 @@ def get_queryset(self): # type: ignore[no-untyped-def] ) ), ) - queryset = queryset.order_by("-has_segment_override", sort) - else: - queryset = queryset.order_by(sort) + override_ordering.append("-has_segment_override") + if identity_id := query_data.get("identity"): + queryset = queryset.annotate( + has_identity_override=Exists( + FeatureState.objects.filter( + feature=OuterRef("pk"), + identity_id=identity_id, + ) + ), + ) + override_ordering.append("-has_identity_override") + queryset = queryset.order_by(*override_ordering, sort) if environment_id: page = self.paginate_queryset(queryset) diff --git a/api/tests/unit/features/test_unit_features_views.py b/api/tests/unit/features/test_unit_features_views.py index a7cd62e4b51f..be8808e885e2 100644 --- a/api/tests/unit/features/test_unit_features_views.py +++ b/api/tests/unit/features/test_unit_features_views.py @@ -4184,6 +4184,40 @@ def test_list_features__segment_query__sorts_by_field_with_overrides_first( assert result_names[1:] == ["feature_a", "feature_b"] +@pytest.mark.parametrize("sort_field", ["name", "created_date"]) +def test_list_features__identity_query__sorts_by_field_with_overrides_first( + admin_client_new: APIClient, + project: Project, + environment: Environment, + identity: Identity, + sort_field: str, +) -> None: + # Given + Feature.objects.create(project=project, name="feature_a") + Feature.objects.create(project=project, name="feature_b") + feature_c = Feature.objects.create(project=project, name="feature_c") + + # feature_c has an identity override + FeatureState.objects.create( + feature=feature_c, environment=environment, identity=identity + ) + + # When + response = admin_client_new.get( + f"/api/v1/projects/{project.id}/features/" + f"?environment={environment.id}" + f"&identity={identity.id}" + f"&sort_field={sort_field}&sort_direction=ASC" + ) + + # Then + assert response.status_code == status.HTTP_200_OK + results = response.json()["results"] + result_names = [r["name"] for r in results] + assert result_names[0] == "feature_c" + assert result_names[1:] == ["feature_a", "feature_b"] + + def test_create_multiple_features_with_metadata_keeps_metadata_isolated( admin_client_new: APIClient, project: Project, diff --git a/frontend/web/components/pages/UserPage.tsx b/frontend/web/components/pages/UserPage.tsx index cbb1a27315cb..7775d68e747a 100644 --- a/frontend/web/components/pages/UserPage.tsx +++ b/frontend/web/components/pages/UserPage.tsx @@ -88,9 +88,9 @@ const UserPage: FC = () => { true, search, sort, - getServerFilter(filter), + { ...getServerFilter(filter), identity: id }, ) - }, [filter, environmentId, projectId]) + }, [filter, environmentId, projectId, id]) useEffect(() => { AppActions.getIdentity(environmentId, id) @@ -141,10 +141,10 @@ const UserPage: FC = () => { search, sort, pageNumber, - getServerFilter(filter), + { ...getServerFilter(filter), identity: id }, ) }, - [environmentId, projectId, filter], + [environmentId, projectId, filter, id], ) return ( From 36917da24b1bb17334f7c743239f626b023f4d2b Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 17 Feb 2026 14:18:37 -0300 Subject: [PATCH 03/11] Fix sorting based on segment overrides --- api/features/views.py | 3 ++- api/tests/unit/features/test_unit_features_views.py | 11 +++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/api/features/views.py b/api/features/views.py index 116586dc9d21..3e753537aabb 100644 --- a/api/features/views.py +++ b/api/features/views.py @@ -225,12 +225,13 @@ def get_queryset(self): # type: ignore[no-untyped-def] query_data["sort_field"], ) override_ordering: list[str] = [] - if segment_id := query_data.get("segment"): + if environment_id and (segment_id := query_data.get("segment")): queryset = queryset.annotate( has_segment_override=Exists( FeatureSegment.objects.filter( feature=OuterRef("pk"), segment_id=segment_id, + environment_id=environment_id, ) ), ) diff --git a/api/tests/unit/features/test_unit_features_views.py b/api/tests/unit/features/test_unit_features_views.py index be8808e885e2..cc50ddb6f5e9 100644 --- a/api/tests/unit/features/test_unit_features_views.py +++ b/api/tests/unit/features/test_unit_features_views.py @@ -4160,13 +4160,20 @@ def test_list_features__segment_query__sorts_by_field_with_overrides_first( ) -> None: # Given Feature.objects.create(project=project, name="feature_a") - Feature.objects.create(project=project, name="feature_b") + feature_b = Feature.objects.create(project=project, name="feature_b") feature_c = Feature.objects.create(project=project, name="feature_c") + other_environment = Environment.objects.create( + project=project, name="other_environment" + ) - # feature_c has a segment override + # feature_c has a segment override in the requested environment FeatureSegment.objects.create( feature=feature_c, segment=segment, environment=environment ) + # feature_b has a segment override in a different environment + FeatureSegment.objects.create( + feature=feature_b, segment=segment, environment=other_environment + ) # When response = admin_client_new.get( From 520db304b21a6818ae0c65afe00291c252a73aca Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 17 Feb 2026 14:44:37 -0300 Subject: [PATCH 04/11] Fix identity URI param --- api/features/serializers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/features/serializers.py b/api/features/serializers.py index 41a97ffee7e2..d4bcb387b027 100644 --- a/api/features/serializers.py +++ b/api/features/serializers.py @@ -95,9 +95,9 @@ class FeatureQuerySerializer(serializers.Serializer): # type: ignore[type-arg] required=False, help_text="Integer ID of the segment to retrieve segment overrides for.", ) - identity = serializers.CharField( + identity = serializers.IntegerField( required=False, - help_text="ID of the identity to sort features with identity overrides first.", + help_text="Integer ID of the identity to sort features with identity overrides first.", ) is_enabled = serializers.BooleanField( allow_null=True, From ec88a8231d0ebb7b5d891ba88498a20e3fb79b3a Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 20 Feb 2026 14:56:13 -0300 Subject: [PATCH 05/11] Sort features by **edge** identity overrides --- .../identities/edge_identity_service.py | 14 +++++- api/features/serializers.py | 4 +- api/features/views.py | 46 +++++++++++++++---- .../unit/features/test_unit_features_views.py | 36 +++++++++++++++ 4 files changed, 87 insertions(+), 13 deletions(-) diff --git a/api/edge_api/identities/edge_identity_service.py b/api/edge_api/identities/edge_identity_service.py index e269747a57b6..e66b57fca833 100644 --- a/api/edge_api/identities/edge_identity_service.py +++ b/api/edge_api/identities/edge_identity_service.py @@ -1,10 +1,16 @@ -from environments.dynamodb import DynamoEnvironmentV2Wrapper +from flag_engine.identities.models import IdentityModel + +from environments.dynamodb import ( + DynamoEnvironmentV2Wrapper, + DynamoIdentityWrapper, +) from environments.dynamodb.types import ( IdentityOverridesV2List, IdentityOverrideV2, ) ddb_environment_v2_wrapper = DynamoEnvironmentV2Wrapper() +ddb_identity_wrapper = DynamoIdentityWrapper() def get_edge_identity_overrides( @@ -53,3 +59,9 @@ def get_edge_identity_overrides_for_feature_ids( ) return results + + +def get_overridden_feature_ids_for_edge_identity(identity_uuid: str) -> set[int]: + identity_document = ddb_identity_wrapper.get_item_from_uuid_or_404(identity_uuid) + identity_model = IdentityModel.model_validate(identity_document) + return {fs.feature.id for fs in identity_model.identity_features} diff --git a/api/features/serializers.py b/api/features/serializers.py index d4bcb387b027..41a97ffee7e2 100644 --- a/api/features/serializers.py +++ b/api/features/serializers.py @@ -95,9 +95,9 @@ class FeatureQuerySerializer(serializers.Serializer): # type: ignore[type-arg] required=False, help_text="Integer ID of the segment to retrieve segment overrides for.", ) - identity = serializers.IntegerField( + identity = serializers.CharField( required=False, - help_text="Integer ID of the identity to sort features with identity overrides first.", + help_text="ID of the identity to sort features with identity overrides first.", ) is_enabled = serializers.BooleanField( allow_null=True, diff --git a/api/features/views.py b/api/features/views.py index 3e753537aabb..4d8e6027ba05 100644 --- a/api/features/views.py +++ b/api/features/views.py @@ -7,7 +7,17 @@ from common.projects.permissions import VIEW_PROJECT from django.conf import settings from django.core.cache import caches -from django.db.models import Exists, Max, OuterRef, Q, QuerySet +from django.db.models import ( + BooleanField, + Case, + Exists, + Max, + OuterRef, + Q, + QuerySet, + Value, + When, +) from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.cache import cache_page @@ -35,6 +45,9 @@ from app_analytics.throttles import InfluxQueryThrottle from core.constants import FLAGSMITH_UPDATED_AT_HEADER, SDK_ENVIRONMENT_KEY_HEADER from core.request_origin import RequestOrigin +from edge_api.identities.edge_identity_service import ( + get_overridden_feature_ids_for_edge_identity, +) from environments.authentication import EnvironmentKeyAuthentication from environments.identities.models import Identity from environments.identities.serializers import ( @@ -236,15 +249,28 @@ def get_queryset(self): # type: ignore[no-untyped-def] ), ) override_ordering.append("-has_segment_override") - if identity_id := query_data.get("identity"): - queryset = queryset.annotate( - has_identity_override=Exists( - FeatureState.objects.filter( - feature=OuterRef("pk"), - identity_id=identity_id, - ) - ), - ) + if identity := query_data.get("identity"): + if project.enable_dynamo_db: + # Bounded by Project.max_features_allowed + override_feature_ids = get_overridden_feature_ids_for_edge_identity( + identity + ) + queryset = queryset.annotate( + has_identity_override=Case( + When(pk__in=override_feature_ids, then=Value(True)), + default=Value(False), + output_field=BooleanField(), + ), + ) + else: + queryset = queryset.annotate( + has_identity_override=Exists( + FeatureState.objects.filter( + feature=OuterRef("pk"), + identity_id=identity, + ) + ), + ) override_ordering.append("-has_identity_override") queryset = queryset.order_by(*override_ordering, sort) diff --git a/api/tests/unit/features/test_unit_features_views.py b/api/tests/unit/features/test_unit_features_views.py index cc50ddb6f5e9..71aaba110ae5 100644 --- a/api/tests/unit/features/test_unit_features_views.py +++ b/api/tests/unit/features/test_unit_features_views.py @@ -4225,6 +4225,42 @@ def test_list_features__identity_query__sorts_by_field_with_overrides_first( assert result_names[1:] == ["feature_a", "feature_b"] +@pytest.mark.parametrize("sort_field", ["name", "created_date"]) +def test_list_features__edge_identity_query__sorts_with_overrides_first( + admin_client_new: APIClient, + project: Project, + environment: Environment, + sort_field: str, + mocker: MockerFixture, +) -> None: + # Given + project.enable_dynamo_db = True + project.save() + + Feature.objects.create(project=project, name="feature_a") + feature_b = Feature.objects.create(project=project, name="feature_b") + Feature.objects.create(project=project, name="feature_c") + mocker.patch.object( + views, + "get_overridden_feature_ids_for_edge_identity", + return_value={feature_b.id}, + ) + + # When + response = admin_client_new.get( + f"/api/v1/projects/{project.id}/features/" + f"?environment={environment.id}" + f"&identity=59efa2a7-6a45-46d6-b953-a7073a90eacf" + f"&sort_field={sort_field}&sort_direction=ASC" + ) + + # Then + assert response.status_code == status.HTTP_200_OK + results = response.json()["results"] + result_names = [r["name"] for r in results] + assert result_names == ["feature_b", "feature_a", "feature_c"] + + def test_create_multiple_features_with_metadata_keeps_metadata_isolated( admin_client_new: APIClient, project: Project, From 2c612c94116ad5482b676b6d2919e6a2f9ce9edd Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 20 Feb 2026 14:59:25 -0300 Subject: [PATCH 06/11] Trim tests nails --- .../identities/edge_identity_service.py | 17 ++- .../identities/test_edge_identity_service.py | 104 ++++++++++++++++++ .../unit/features/test_unit_features_views.py | 22 ++-- 3 files changed, 121 insertions(+), 22 deletions(-) create mode 100644 api/tests/unit/edge_api/identities/test_edge_identity_service.py diff --git a/api/edge_api/identities/edge_identity_service.py b/api/edge_api/identities/edge_identity_service.py index e66b57fca833..ce5cef2d067a 100644 --- a/api/edge_api/identities/edge_identity_service.py +++ b/api/edge_api/identities/edge_identity_service.py @@ -1,16 +1,11 @@ -from flag_engine.identities.models import IdentityModel - -from environments.dynamodb import ( - DynamoEnvironmentV2Wrapper, - DynamoIdentityWrapper, -) +from edge_api.identities.models import EdgeIdentity +from environments.dynamodb import DynamoEnvironmentV2Wrapper from environments.dynamodb.types import ( IdentityOverridesV2List, IdentityOverrideV2, ) ddb_environment_v2_wrapper = DynamoEnvironmentV2Wrapper() -ddb_identity_wrapper = DynamoIdentityWrapper() def get_edge_identity_overrides( @@ -62,6 +57,8 @@ def get_edge_identity_overrides_for_feature_ids( def get_overridden_feature_ids_for_edge_identity(identity_uuid: str) -> set[int]: - identity_document = ddb_identity_wrapper.get_item_from_uuid_or_404(identity_uuid) - identity_model = IdentityModel.model_validate(identity_document) - return {fs.feature.id for fs in identity_model.identity_features} + identity_document = EdgeIdentity.dynamo_wrapper.get_item_from_uuid_or_404( + identity_uuid + ) + identity = EdgeIdentity.from_identity_document(identity_document) + return {fs.feature.id for fs in identity.feature_overrides} diff --git a/api/tests/unit/edge_api/identities/test_edge_identity_service.py b/api/tests/unit/edge_api/identities/test_edge_identity_service.py new file mode 100644 index 000000000000..f2dc3f398f37 --- /dev/null +++ b/api/tests/unit/edge_api/identities/test_edge_identity_service.py @@ -0,0 +1,104 @@ +from unittest.mock import MagicMock + +import pytest +from pytest_mock import MockerFixture + +from edge_api.identities.edge_identity_service import ( + get_overridden_feature_ids_for_edge_identity, +) + + +@pytest.fixture() +def _mock_ddb_identity_wrapper(mocker: MockerFixture) -> MagicMock: + return mocker.patch( + "edge_api.identities.models.EdgeIdentity.dynamo_wrapper", + ) + + +def _make_identity_document( + *, + environment_api_key: str, + identity_features: list[dict], # type: ignore[type-arg] +) -> dict: # type: ignore[type-arg] + return { + "composite_key": f"{environment_api_key}_test_user", + "identity_traits": [], + "identity_features": identity_features, + "identifier": "test_user", + "created_date": "2021-09-21T10:12:42.230257+00:00", + "environment_api_key": environment_api_key, + "identity_uuid": "59efa2a7-6a45-46d6-b953-a7073a90eacf", + "django_id": None, + } + + +def test_get_overridden_feature_ids_for_edge_identity__identity_with_overrides__returns_feature_ids( + _mock_ddb_identity_wrapper: MagicMock, +) -> None: + # Given + feature_a_id = 101 + feature_b_id = 202 + identity_document = _make_identity_document( + environment_api_key="test_key", + identity_features=[ + { + "feature": { + "id": feature_a_id, + "name": "feature_a", + "type": "STANDARD", + }, + "enabled": True, + "featurestate_uuid": "a7495917-ee57-41d1-a64e-e0697dbc57fb", + "feature_state_value": None, + "feature_segment": None, + "multivariate_feature_state_values": [], + }, + { + "feature": { + "id": feature_b_id, + "name": "feature_b", + "type": "STANDARD", + }, + "enabled": False, + "featurestate_uuid": "b8506028-ff68-42e2-b75f-f1708ecd68fc", + "feature_state_value": None, + "feature_segment": None, + "multivariate_feature_state_values": [], + }, + ], + ) + _mock_ddb_identity_wrapper.get_item_from_uuid_or_404.return_value = ( + identity_document + ) + + # When + result = get_overridden_feature_ids_for_edge_identity( + "59efa2a7-6a45-46d6-b953-a7073a90eacf" + ) + + # Then + assert result == {feature_a_id, feature_b_id} + _mock_ddb_identity_wrapper.get_item_from_uuid_or_404.assert_called_once_with( + "59efa2a7-6a45-46d6-b953-a7073a90eacf" + ) + + +def test_get_overridden_feature_ids_for_edge_identity__identity_without_overrides__returns_empty_set( + _mock_ddb_identity_wrapper: MagicMock, +) -> None: + # Given + identity_document = _make_identity_document( + environment_api_key="test_key", + identity_features=[], + ) + _mock_ddb_identity_wrapper.get_item_from_uuid_or_404.return_value = ( + identity_document + ) + + # When + result = get_overridden_feature_ids_for_edge_identity( + "59efa2a7-6a45-46d6-b953-a7073a90eacf" + ) + + # Then + assert result == set() diff --git a/api/tests/unit/features/test_unit_features_views.py b/api/tests/unit/features/test_unit_features_views.py index 71aaba110ae5..983f4b94a74a 100644 --- a/api/tests/unit/features/test_unit_features_views.py +++ b/api/tests/unit/features/test_unit_features_views.py @@ -4185,10 +4185,9 @@ def test_list_features__segment_query__sorts_by_field_with_overrides_first( # Then assert response.status_code == status.HTTP_200_OK - results = response.json()["results"] - result_names = [r["name"] for r in results] - assert result_names[0] == "feature_c" - assert result_names[1:] == ["feature_a", "feature_b"] + assert ["feature_c", "feature_a", "feature_b"] == [ + f["name"] for f in response.json()["results"] + ] @pytest.mark.parametrize("sort_field", ["name", "created_date"]) @@ -4219,14 +4218,13 @@ def test_list_features__identity_query__sorts_by_field_with_overrides_first( # Then assert response.status_code == status.HTTP_200_OK - results = response.json()["results"] - result_names = [r["name"] for r in results] - assert result_names[0] == "feature_c" - assert result_names[1:] == ["feature_a", "feature_b"] + assert ["feature_c", "feature_a", "feature_b"] == [ + f["name"] for f in response.json()["results"] + ] @pytest.mark.parametrize("sort_field", ["name", "created_date"]) -def test_list_features__edge_identity_query__sorts_with_overrides_first( +def test_list_features__edge_identity_query__sorts_by_field_with_overrides_first( admin_client_new: APIClient, project: Project, environment: Environment, @@ -4256,9 +4254,9 @@ def test_list_features__edge_identity_query__sorts_with_overrides_first( # Then assert response.status_code == status.HTTP_200_OK - results = response.json()["results"] - result_names = [r["name"] for r in results] - assert result_names == ["feature_b", "feature_a", "feature_c"] + assert ["feature_b", "feature_a", "feature_c"] == [ + f["name"] for f in response.json()["results"] + ] def test_create_multiple_features_with_metadata_keeps_metadata_isolated( From 71738c028486dced596e9793ee54867642a6b7e7 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 20 Feb 2026 19:06:42 -0300 Subject: [PATCH 07/11] Reorder clicks in e2e tests --- frontend/e2e/tests/segment-test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/e2e/tests/segment-test.ts b/frontend/e2e/tests/segment-test.ts index 916cb257af27..15e2916afc79 100644 --- a/frontend/e2e/tests/segment-test.ts +++ b/frontend/e2e/tests/segment-test.ts @@ -258,7 +258,8 @@ export const testSegment3 = async () => { await click(byId('user-feature-switch-1-on')) await click('#confirm-toggle-feature-btn') await waitAndRefresh() // wait and refresh to avoid issues with data sync from UK -> US in github workflows - await waitForElementVisible(byId('user-feature-switch-1-off')) + // After toggling, 'flag' has an identity override and sorts first (index 0). + await waitForElementVisible(byId('user-feature-switch-0-off')) log('Edit flag for user') await click(byId('user-feature-0')) @@ -268,8 +269,8 @@ export const testSegment3 = async () => { await assertTextContent(byId('user-feature-value-0'), '"small"') log('Toggle flag for user again') - await click(byId('user-feature-switch-1-off')) + await click(byId('user-feature-switch-0-off')) await click('#confirm-toggle-feature-btn') await waitAndRefresh() // wait and refresh to avoid issues with data sync from UK -> US in github workflows - await waitForElementVisible(byId('user-feature-switch-1-on')) + await waitForElementVisible(byId('user-feature-switch-0-on')) } From 7f74e4a11ba580333c3d597e9f3eb3adca90cd89 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Mon, 23 Feb 2026 19:17:25 -0300 Subject: [PATCH 08/11] Improve validation --- .../identities/edge_identity_service.py | 11 +++- api/features/serializers.py | 13 ++++ api/features/views.py | 15 +++-- .../identities/test_edge_identity_service.py | 24 +++++-- .../unit/features/test_unit_features_views.py | 62 +++++++++++++++++++ 5 files changed, 111 insertions(+), 14 deletions(-) diff --git a/api/edge_api/identities/edge_identity_service.py b/api/edge_api/identities/edge_identity_service.py index ce5cef2d067a..4301367240de 100644 --- a/api/edge_api/identities/edge_identity_service.py +++ b/api/edge_api/identities/edge_identity_service.py @@ -1,3 +1,5 @@ +from django.core.exceptions import ObjectDoesNotExist + from edge_api.identities.models import EdgeIdentity from environments.dynamodb import DynamoEnvironmentV2Wrapper from environments.dynamodb.types import ( @@ -57,8 +59,11 @@ def get_edge_identity_overrides_for_feature_ids( def get_overridden_feature_ids_for_edge_identity(identity_uuid: str) -> set[int]: - identity_document = EdgeIdentity.dynamo_wrapper.get_item_from_uuid_or_404( - identity_uuid - ) + try: + identity_document = EdgeIdentity.dynamo_wrapper.get_item_from_uuid( + identity_uuid + ) + except ObjectDoesNotExist: + return set() identity = EdgeIdentity.from_identity_document(identity_document) return {fs.feature.id for fs in identity.feature_overrides} diff --git a/api/features/serializers.py b/api/features/serializers.py index 41a97ffee7e2..5ed68feb53db 100644 --- a/api/features/serializers.py +++ b/api/features/serializers.py @@ -1,5 +1,6 @@ from datetime import datetime from typing import Any +from uuid import UUID import django.core.exceptions from common.features.multivariate.serializers import ( @@ -99,6 +100,18 @@ class FeatureQuerySerializer(serializers.Serializer): # type: ignore[type-arg] required=False, help_text="ID of the identity to sort features with identity overrides first.", ) + + def validate_identity(self, value: str) -> str: + project = self.context.get("project") + if project and project.enable_dynamo_db: + try: + UUID(value) + except ValueError: + raise serializers.ValidationError("Must be a valid UUID.") + elif not value.isdigit(): + raise serializers.ValidationError("Must be a valid integer.") + return value + is_enabled = serializers.BooleanField( allow_null=True, required=False, diff --git a/api/features/views.py b/api/features/views.py index 4d8e6027ba05..b355e76eacff 100644 --- a/api/features/views.py +++ b/api/features/views.py @@ -210,7 +210,10 @@ def get_queryset(self): # type: ignore[no-untyped-def] ) ) - query_serializer = FeatureQuerySerializer(data=self.request.query_params) + query_serializer = FeatureQuerySerializer( + data=self.request.query_params, + context={"project": project}, + ) query_serializer.is_valid(raise_exception=True) query_data = query_serializer.validated_data @@ -218,7 +221,7 @@ def get_queryset(self): # type: ignore[no-untyped-def] queryset, project.id ) - queryset = self._filter_queryset(queryset) + queryset = self._filter_queryset(queryset, query_serializer) if environment_id := query_data.get("environment"): queryset = queryset.annotate( @@ -557,9 +560,11 @@ def filter_owners_and_group_owners( return queryset.filter(owners_q | group_owners_q) - def _filter_queryset(self, queryset: QuerySet[Feature]) -> QuerySet[Feature]: - query_serializer = FeatureQuerySerializer(data=self.request.query_params) - query_serializer.is_valid(raise_exception=True) + def _filter_queryset( + self, + queryset: QuerySet[Feature], + query_serializer: FeatureQuerySerializer, + ) -> QuerySet[Feature]: query_data = query_serializer.validated_data if query_data.get("search"): diff --git a/api/tests/unit/edge_api/identities/test_edge_identity_service.py b/api/tests/unit/edge_api/identities/test_edge_identity_service.py index f2dc3f398f37..16103519e6df 100644 --- a/api/tests/unit/edge_api/identities/test_edge_identity_service.py +++ b/api/tests/unit/edge_api/identities/test_edge_identity_service.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock import pytest +from django.core.exceptions import ObjectDoesNotExist from pytest_mock import MockerFixture from edge_api.identities.edge_identity_service import ( @@ -67,9 +68,7 @@ def test_get_overridden_feature_ids_for_edge_identity__identity_with_overrides__ }, ], ) - _mock_ddb_identity_wrapper.get_item_from_uuid_or_404.return_value = ( - identity_document - ) + _mock_ddb_identity_wrapper.get_item_from_uuid.return_value = identity_document # When result = get_overridden_feature_ids_for_edge_identity( @@ -78,7 +77,7 @@ def test_get_overridden_feature_ids_for_edge_identity__identity_with_overrides__ # Then assert result == {feature_a_id, feature_b_id} - _mock_ddb_identity_wrapper.get_item_from_uuid_or_404.assert_called_once_with( + _mock_ddb_identity_wrapper.get_item_from_uuid.assert_called_once_with( "59efa2a7-6a45-46d6-b953-a7073a90eacf" ) @@ -91,10 +90,23 @@ def test_get_overridden_feature_ids_for_edge_identity__identity_without_override environment_api_key="test_key", identity_features=[], ) - _mock_ddb_identity_wrapper.get_item_from_uuid_or_404.return_value = ( - identity_document + _mock_ddb_identity_wrapper.get_item_from_uuid.return_value = identity_document + + # When + result = get_overridden_feature_ids_for_edge_identity( + "59efa2a7-6a45-46d6-b953-a7073a90eacf" ) + # Then + assert result == set() + + +def test_get_overridden_feature_ids_for_edge_identity__nonexistent_identity__returns_empty_set( + _mock_ddb_identity_wrapper: MagicMock, +) -> None: + # Given + _mock_ddb_identity_wrapper.get_item_from_uuid.side_effect = ObjectDoesNotExist() + # When result = get_overridden_feature_ids_for_edge_identity( "59efa2a7-6a45-46d6-b953-a7073a90eacf" diff --git a/api/tests/unit/features/test_unit_features_views.py b/api/tests/unit/features/test_unit_features_views.py index 983f4b94a74a..b79effb796eb 100644 --- a/api/tests/unit/features/test_unit_features_views.py +++ b/api/tests/unit/features/test_unit_features_views.py @@ -4259,6 +4259,68 @@ def test_list_features__edge_identity_query__sorts_by_field_with_overrides_first ] +@pytest.mark.parametrize( + "enable_dynamo_db, identity_value", + [ + (False, "not-an-integer"), + (True, "0"), + ], +) +def test_list_features__identity_query__invalid_format__returns_400( + admin_client_new: APIClient, + project: Project, + environment: Environment, + enable_dynamo_db: bool, + identity_value: str, +) -> None: + # Given + project.enable_dynamo_db = enable_dynamo_db + project.save() + + # When + response = admin_client_new.get( + f"/api/v1/projects/{project.id}/features/" + f"?environment={environment.id}" + f"&identity={identity_value}" + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_list_features__edge_identity_query__nonexistent_identity__returns_features_unsorted( + admin_client_new: APIClient, + project: Project, + environment: Environment, + mocker: MockerFixture, +) -> None: + # Given + project.enable_dynamo_db = True + project.save() + Feature.objects.create(project=project, name="feature_a") + Feature.objects.create(project=project, name="feature_b") + Feature.objects.create(project=project, name="feature_c") + mocker.patch.object( + views, + "get_overridden_feature_ids_for_edge_identity", + return_value=set(), + ) + + # When + response = admin_client_new.get( + f"/api/v1/projects/{project.id}/features/" + f"?environment={environment.id}" + f"&identity=59efa2a7-6a45-46d6-b953-a7073a90eacf" + f"&sort_field=name&sort_direction=ASC" + ) + + # Then + assert response.status_code == status.HTTP_200_OK + assert ["feature_a", "feature_b", "feature_c"] == [ + f["name"] for f in response.json()["results"] + ] + + def test_create_multiple_features_with_metadata_keeps_metadata_isolated( admin_client_new: APIClient, project: Project, From 41d4509a93a77a8fc8e615038cedd01ede843aa2 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 24 Feb 2026 13:33:28 -0300 Subject: [PATCH 09/11] Fix code positioning --- api/features/serializers.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/api/features/serializers.py b/api/features/serializers.py index 5ed68feb53db..24b756a69edb 100644 --- a/api/features/serializers.py +++ b/api/features/serializers.py @@ -101,17 +101,6 @@ class FeatureQuerySerializer(serializers.Serializer): # type: ignore[type-arg] help_text="ID of the identity to sort features with identity overrides first.", ) - def validate_identity(self, value: str) -> str: - project = self.context.get("project") - if project and project.enable_dynamo_db: - try: - UUID(value) - except ValueError: - raise serializers.ValidationError("Must be a valid UUID.") - elif not value.isdigit(): - raise serializers.ValidationError("Must be a valid integer.") - return value - is_enabled = serializers.BooleanField( allow_null=True, required=False, @@ -133,6 +122,17 @@ def validate_identity(self, value: str) -> str: help_text="Comma separated list of group owner ids to filter on", ) + def validate_identity(self, value: str) -> str: + project = self.context.get("project") + if project and project.enable_dynamo_db: + try: + UUID(value) + except ValueError: + raise serializers.ValidationError("Must be a valid UUID.") + elif not value.isdigit(): + raise serializers.ValidationError("Must be a valid integer.") + return value + def validate_owners(self, owners: str) -> list[int]: try: return [int(owner_id.strip()) for owner_id in owners.split(",")] From 933a47726f34401af48f14f32b3d96f769159a72 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 24 Feb 2026 13:33:42 -0300 Subject: [PATCH 10/11] Improve type safety --- api/features/serializers.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/api/features/serializers.py b/api/features/serializers.py index 24b756a69edb..1981e431b2bf 100644 --- a/api/features/serializers.py +++ b/api/features/serializers.py @@ -122,9 +122,14 @@ class FeatureQuerySerializer(serializers.Serializer): # type: ignore[type-arg] help_text="Comma separated list of group owner ids to filter on", ) + @property + def project(self) -> Project: + if isinstance(project := self.context.get("project"), Project): + return project + raise RuntimeError(f"{type(self)} requires 'project' in context.") + def validate_identity(self, value: str) -> str: - project = self.context.get("project") - if project and project.enable_dynamo_db: + if self.project.enable_dynamo_db: try: UUID(value) except ValueError: From e9d4f8de632413b5e2f0a48db8f5fc492f9d19fb Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 24 Feb 2026 13:54:45 -0300 Subject: [PATCH 11/11] Fix coverage --- api/features/serializers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/features/serializers.py b/api/features/serializers.py index 1981e431b2bf..60bfaef08d94 100644 --- a/api/features/serializers.py +++ b/api/features/serializers.py @@ -126,7 +126,8 @@ class FeatureQuerySerializer(serializers.Serializer): # type: ignore[type-arg] def project(self) -> Project: if isinstance(project := self.context.get("project"), Project): return project - raise RuntimeError(f"{type(self)} requires 'project' in context.") + else: # pragma: no cover + raise RuntimeError(f"{type(self)} requires 'project' in context.") def validate_identity(self, value: str) -> str: if self.project.enable_dynamo_db: