diff --git a/api/features/serializers.py b/api/features/serializers.py index 134181afe3a0..d4bcb387b027 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.IntegerField( + required=False, + help_text="Integer 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 de01c33a700d..3e753537aabb 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,29 @@ 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) + override_ordering: list[str] = [] + 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, + ) + ), + ) + 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 05abbecf7562..cc50ddb6f5e9 100644 --- a/api/tests/unit/features/test_unit_features_views.py +++ b/api/tests/unit/features/test_unit_features_views.py @@ -4150,6 +4150,81 @@ 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_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 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( + 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"] + + +@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 (