From 255b33ed820f0e3c48fa7898863b8a1da7f043f1 Mon Sep 17 00:00:00 2001 From: Sahil Date: Thu, 7 May 2026 15:36:10 +0530 Subject: [PATCH 1/2] feat: add identity_feature_state to project features endpoint --- api/features/serializers.py | 11 ++ api/features/views.py | 24 ++- .../features/test_identity_feature_states.py | 157 ++++++++++++++++++ .../test_unit_identity_feature_states.py | 56 +++++++ frontend/common/services/useProjectFlag.ts | 9 +- frontend/common/types/responses.ts | 16 +- .../web/components/pages/IdentityPage.tsx | 77 +++------ 7 files changed, 288 insertions(+), 62 deletions(-) create mode 100644 api/tests/integration/features/test_identity_feature_states.py create mode 100644 api/tests/unit/features/test_unit_identity_feature_states.py diff --git a/api/features/serializers.py b/api/features/serializers.py index 3c6e17280c39..a2d455105ce9 100644 --- a/api/features/serializers.py +++ b/api/features/serializers.py @@ -193,6 +193,7 @@ class CreateFeatureSerializer(DeleteBeforeUpdateWritableNestedModelSerializer): environment_feature_state = serializers.SerializerMethodField() segment_feature_state = serializers.SerializerMethodField() + identity_feature_state = serializers.SerializerMethodField() num_segment_overrides = serializers.SerializerMethodField( help_text="Number of segment overrides that exist for the given feature " @@ -240,6 +241,7 @@ class Meta: "project", "environment_feature_state", "segment_feature_state", + "identity_feature_state", "num_segment_overrides", "num_identity_overrides", "is_num_identity_overrides_complete", @@ -417,6 +419,15 @@ def get_segment_feature_state( # type: ignore[return] ): return FeatureStateSerializerSmall(instance=segment_feature_state).data + @extend_schema_field(FeatureStateSerializerSmall(allow_null=True)) + def get_identity_feature_state( # type: ignore[return] + self, instance: Feature + ) -> dict[str, Any] | None: + if (identity_feature_states := self.context.get("identity_feature_states")) and ( + identity_feature_state := identity_feature_states.get(instance.id) + ): + return FeatureStateSerializerSmall(instance=identity_feature_state).data + def get_num_segment_overrides(self, instance: Feature) -> int: try: return self.context["overrides_data"][instance.id].num_segment_overrides # type: ignore[no-any-return] diff --git a/api/features/views.py b/api/features/views.py index 965f01bdf79b..ac5946291807 100644 --- a/api/features/views.py +++ b/api/features/views.py @@ -312,15 +312,36 @@ def get_queryset(self): # type: ignore[no-untyped-def] ) segment_feature_states = get_environment_flags_list( environment=self.environment, - additional_filters=segment_query, + additional_filters=segment_query, additional_select_related_args=["feature_state_value", "feature"], ) self._segment_feature_states = { fs.feature_id: fs for fs in segment_feature_states } + if identity_id := query_data.get("identity"): + if not project.enable_dynamo_db: + try: + identity_obj = Identity.objects.get( + id=identity_id, + environment=self.environment, + ) + all_identity_states = identity_obj.get_all_feature_states() + self._identity_feature_states = { + fs.feature_id: fs + for fs in all_identity_states + if fs.feature_id in self.feature_ids + } + except (Identity.DoesNotExist, ValueError): + self._identity_feature_states = {} + else: + + # TODO: Implement Edge identity state retrieval if needed + self._identity_feature_states = {} + return queryset + def paginate_queryset(self, queryset: QuerySet[Feature]) -> list[Feature]: # type: ignore[override] if getattr(self, "_page", None): return self._page # type: ignore[no-any-return,has-type] @@ -354,6 +375,7 @@ def get_serializer_context(self): # type: ignore[no-untyped-def] user=self.request.user, feature_states=feature_states, segment_feature_states=segment_feature_states, + identity_feature_states=getattr(self, "_identity_feature_states", {}), ) if self.action == "list" and "environment" in self.request.query_params: diff --git a/api/tests/integration/features/test_identity_feature_states.py b/api/tests/integration/features/test_identity_feature_states.py new file mode 100644 index 000000000000..a3c41892b4b0 --- /dev/null +++ b/api/tests/integration/features/test_identity_feature_states.py @@ -0,0 +1,157 @@ +import json +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient +from environments.models import Environment +from environments.identities.models import Identity +from features.models import Feature +from projects.models import Project + + +@pytest.mark.django_db +def test_list_features__with_identity__returns_identity_feature_state( + admin_client_new: APIClient, + project: Project, + environment: Environment, + feature: Feature, + identity: Identity, +) -> None: + # Given - create an identity override + url = reverse( + "api-v1:environments:identity-featurestates-list", + args=[environment.api_key, identity.id], + ) + data = { + "feature": feature.id, + "enabled": True, + "feature_state_value": {"type": "unicode", "string_value": "identity-override"}, + } + response = admin_client_new.post( + url, data=json.dumps(data), content_type="application/json" + ) + assert response.status_code == status.HTTP_201_CREATED + + # When + features_url = reverse("api-v1:projects:project-features-list", args=[project.id]) + list_url = f"{features_url}?environment={environment.id}&identity={identity.id}" + list_response = admin_client_new.get(list_url) + + # Then + assert list_response.status_code == status.HTTP_200_OK + feature_data = list_response.json()["results"][0] + assert feature_data["id"] == feature.id + assert feature_data["identity_feature_state"] is not None + assert feature_data["identity_feature_state"]["enabled"] is True + assert feature_data["identity_feature_state"]["feature_state_value"] == "identity-override" + + +@pytest.mark.django_db +def test_list_features__without_identity_param__identity_feature_state_is_none( + admin_client_new: APIClient, + project: Project, + environment: Environment, + feature: Feature, +) -> None: + # Given + features_url = reverse("api-v1:projects:project-features-list", args=[project.id]) + list_url = f"{features_url}?environment={environment.id}" + + # When + list_response = admin_client_new.get(list_url) + + # Then + assert list_response.status_code == status.HTTP_200_OK + feature_data = list_response.json()["results"][0] + assert "identity_feature_state" in feature_data + assert feature_data["identity_feature_state"] is None + + +@pytest.mark.django_db +def test_list_features__with_invalid_identity_id__identity_feature_state_is_none( + admin_client_new: APIClient, + project: Project, + environment: Environment, + feature: Feature, +) -> None: + # Given + features_url = reverse("api-v1:projects:project-features-list", args=[project.id]) + list_url = f"{features_url}?environment={environment.id}&identity=999999" + + # When + list_response = admin_client_new.get(list_url) + + # Then + assert list_response.status_code == status.HTTP_200_OK + feature_data = list_response.json()["results"][0] + assert feature_data["identity_feature_state"] is None + + +@pytest.mark.django_db +def test_list_features__with_edge_project__identity_feature_state_is_none( + admin_client_new: APIClient, + project: Project, + environment: Environment, + feature: Feature, +) -> None: + # Given + project.enable_dynamo_db = True + project.save() + + features_url = reverse("api-v1:projects:project-features-list", args=[project.id]) + list_url = f"{features_url}?environment={environment.id}&identity=59efa2a7-6a45-46d6-b953-a7073a90eacf" + + # When + list_response = admin_client_new.get(list_url) + + # Then + assert list_response.status_code == status.HTTP_200_OK + feature_data = list_response.json()["results"][0] + assert feature_data["identity_feature_state"] is None + + +@pytest.mark.django_db +def test_list_features__with_identity_from_different_environment__identity_feature_state_is_none( + admin_client_new: APIClient, + project: Project, + environment: Environment, + feature: Feature, + identity: Identity, +) -> None: + # Given + other_environment = Environment.objects.create( + name="other-environment", + project=project, + ) + + features_url = reverse("api-v1:projects:project-features-list", args=[project.id]) + list_url = f"{features_url}?environment={other_environment.id}&identity={identity.id}" + + # When + list_response = admin_client_new.get(list_url) + + # Then + assert list_response.status_code == status.HTTP_200_OK + feature_data = list_response.json()["results"][0] + assert feature_data["identity_feature_state"] is None + + +@pytest.mark.django_db +def test_list_features__with_identity_no_override__identity_feature_state_is_none( + admin_client_new: APIClient, + project: Project, + environment: Environment, + feature: Feature, + identity: Identity, +) -> None: + # Given - identity exists but has no override + features_url = reverse("api-v1:projects:project-features-list", args=[project.id]) + list_url = f"{features_url}?environment={environment.id}&identity={identity.id}" + + # When + list_response = admin_client_new.get(list_url) + + # Then + assert list_response.status_code == status.HTTP_200_OK + feature_data = list_response.json()["results"][0] + assert feature_data["identity_feature_state"] is None diff --git a/api/tests/unit/features/test_unit_identity_feature_states.py b/api/tests/unit/features/test_unit_identity_feature_states.py new file mode 100644 index 000000000000..7947bc3f5458 --- /dev/null +++ b/api/tests/unit/features/test_unit_identity_feature_states.py @@ -0,0 +1,56 @@ +from typing import Any +from unittest import mock + +from features.serializers import CreateFeatureSerializer +from features.models import Feature, FeatureState + + +def test_create_feature_serializer__get_identity_feature_state__returns_state_from_context() -> None: + # Given + feature = mock.MagicMock(spec=Feature, id=1) + feature_state = mock.MagicMock(spec=FeatureState, feature_id=1, enabled=True) + + with mock.patch("features.serializers.FeatureStateSerializerSmall") as MockSerializer: + MockSerializer.return_value.data = {"enabled": True, "feature_state_value": "foo"} + + context: dict[str, Any] = { + "identity_feature_states": {1: feature_state} + } + serializer = CreateFeatureSerializer(instance=feature, context=context) + + # When + result = serializer.get_identity_feature_state(feature) + + # Then + assert result == {"enabled": True, "feature_state_value": "foo"} + MockSerializer.assert_called_once_with(instance=feature_state) + + +def test_create_feature_serializer__get_identity_feature_state__returns_none_if_no_state_in_context() -> None: + # Given + feature = mock.MagicMock(spec=Feature, id=1) + + context: dict[str, Any] = { + "identity_feature_states": {} + } + serializer = CreateFeatureSerializer(instance=feature, context=context) + + # When + result = serializer.get_identity_feature_state(feature) + + # Then + assert result is None + + +def test_create_feature_serializer__get_identity_feature_state__returns_none_if_context_missing_key() -> None: + # Given + feature = mock.MagicMock(spec=Feature, id=1) + + context: dict[str, Any] = {} + serializer = CreateFeatureSerializer(instance=feature, context=context) + + # When + result = serializer.get_identity_feature_state(feature) + + # Then + assert result is None diff --git a/frontend/common/services/useProjectFlag.ts b/frontend/common/services/useProjectFlag.ts index 1456f02f9adb..7ce19e33f8de 100644 --- a/frontend/common/services/useProjectFlag.ts +++ b/frontend/common/services/useProjectFlag.ts @@ -84,11 +84,12 @@ export const projectFlagService = service { id: 'LIST', type: 'FeatureList' }, ], query: (query: Req['getFeatureList']) => { - const { environmentId, projectId, ...params } = query + const { environmentId, identity, projectId, ...params } = query return { params: { ...params, environment: parseInt(environmentId), + identity, page: params.page || 1, page_size: params.page_size || FEATURES_PAGE_SIZE, }, @@ -115,6 +116,12 @@ export const projectFlagService = service } return acc }, {} as Res['featureList']['environmentStates']), + identityStates: response.results.reduce((acc, feature) => { + if (feature.identity_feature_state) { + acc[feature.id] = feature.identity_feature_state + } + return acc + }, {} as Res['featureList']['identityStates']), pagination: { count: response.count, currentPage: arg.page || 1, diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index b765b948b4a1..678b88c7b07f 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -467,13 +467,13 @@ export type AuditLogItem = { related_object_uuid?: number related_feature_id?: number related_object_type: - | 'FEATURE' - | 'FEATURE_STATE' - | 'ENVIRONMENT' - | 'CHANGE_REQUEST' - | 'SEGMENT' - | 'EF_VERSION' - | 'EDGE_IDENTITY' + | 'FEATURE' + | 'FEATURE_STATE' + | 'ENVIRONMENT' + | 'CHANGE_REQUEST' + | 'SEGMENT' + | 'EF_VERSION' + | 'EDGE_IDENTITY' is_system_event: boolean } @@ -643,6 +643,7 @@ export type ProjectFlag = { description?: string environment_feature_state?: FeatureState segment_feature_state?: FeatureState + identity_feature_state?: IdentityFeatureState id: number initial_value: FlagsmithValue is_archived: boolean @@ -1263,6 +1264,7 @@ export type Res = { next: string | null previous: string | null environmentStates: Record + identityStates: Record pagination: { count: number next: string | null diff --git a/frontend/web/components/pages/IdentityPage.tsx b/frontend/web/components/pages/IdentityPage.tsx index 7878102d927d..976f0dadb539 100644 --- a/frontend/web/components/pages/IdentityPage.tsx +++ b/frontend/web/components/pages/IdentityPage.tsx @@ -1,11 +1,11 @@ import React, { FC, useEffect, useState } from 'react' import { Link, useHistory, useRouteMatch } from 'react-router-dom' import { useRouteContext } from 'components/providers/RouteContext' -import keyBy from 'lodash/keyBy' + import { getStore } from 'common/store' import { getTags } from 'common/services/useTag' -import { Identity, IdentityFeatureState } from 'common/types/responses' +import { Identity } from 'common/types/responses' import API from 'project/api' import AccountStore from 'common/stores/account-store' import AppActions from 'common/dispatcher/app-actions' @@ -24,14 +24,12 @@ import Panel from 'components/base/grid/Panel' import PanelSearch from 'components/PanelSearch' import TryIt from 'components/TryIt' import Utils from 'common/utils/utils' -import _data from 'common/data/base/_data' import { removeIdentity } from './IdentitiesPage' import IdentityTraits from 'components/IdentityTraits' import { useGetIdentitySegmentsQuery } from 'common/services/useIdentitySegment' import useDebouncedSearch from 'common/useDebouncedSearch' import FeatureOverrideRow from 'components/feature-override/FeatureOverrideRow' import FeatureFilters from 'components/feature-page/FeatureFilters' -import Project from 'common/project' import SettingTitle from 'components/SettingTitle' import { useGetFeatureListQuery } from 'common/services/useProjectFlag' import { useProjectEnvironments } from 'common/hooks/useProjectEnvironments' @@ -54,8 +52,7 @@ const IdentityPage: FC = () => { const { filters, goToPage, handleFilterChange, page } = useFeatureFilters(history) - const [actualFlags, setActualFlags] = - useState>() + const preselect = Utils.fromParam().flag const [segmentsPage, setSegmentsPage] = useState(1) const { search, searchInput, setSearchInput } = useDebouncedSearch('') @@ -75,12 +72,12 @@ const IdentityPage: FC = () => { const apiParams = environmentId ? buildApiFilterParams( - filters, - page, - environmentId, - projectId!, - getEnvironmentIdFromKey, - ) + filters, + page, + environmentId, + projectId!, + getEnvironmentIdFromKey, + ) : null const { data: featureListData, isFetching: isLoadingFeatures } = @@ -96,23 +93,13 @@ const IdentityPage: FC = () => { useEffect(() => { AppActions.getIdentity(environmentId, id) getTags(getStore(), { projectId: `${projectId}` }) - getActualFlags() + API.trackPage(Constants.pages.USER) // eslint-disable-next-line }, []) - const getActualFlags = () => { - const url = `${ - Project.api - }environments/${environmentId}/${Utils.getIdentitiesEndpoint()}/${id}/${Utils.getFeatureStatesEndpoint()}/all/` - _data.get(url).then((res: IdentityFeatureState[]) => { - setActualFlags(keyBy(res, (v: IdentityFeatureState) => v.feature.name)) - }) - } - const onSave = () => { - getActualFlags() - } + const onSave = () => { } const editSegment = (segment: any) => { API.trackEvent(Constants.events.VIEW_SEGMENT) @@ -138,17 +125,17 @@ const IdentityPage: FC = () => { {({ identity, - identityFlags, + isLoading: isIdentityLoading, }: { identity: { identity: Identity; identifier: string } - identityFlags: IdentityFeatureState[] + isLoading: boolean }) => { const identityName = (identity && identity.identity.identifier) || id const isDataLoaded = - !!actualFlags && !!identityFlags && !!projectFlags && !!projectId + !!featureListData && !!projectFlags && !!projectId return isIdentityLoading || !isDataLoaded ? (
@@ -235,7 +222,8 @@ const IdentityPage: FC = () => { className='mx-2' title={'Identity Feature States'} json={ - identityFlags && Object.values(identityFlags) + featureListData?.identityStates && + Object.values(featureListData.identityStates) } /> @@ -261,29 +249,13 @@ const IdentityPage: FC = () => { isLoading={isLoadingFeatures} items={projectFlags} renderRow={({ id: featureId, name }, i) => { - const identityFlag = identityFlags[featureId] - const actualEnabled = - actualFlags && actualFlags[name]?.enabled - const environmentFlag = - (environmentFlags && - environmentFlags[featureId]) || - {} + const identityFlag = + featureListData?.identityStates?.[featureId] const projectFlag = projectFlags?.find( - (p: any) => p.id === environmentFlag.feature, + (p: any) => p.id === featureId, ) - const actualFlagForFeature = - actualFlags?.[name]?.feature?.id === featureId - ? actualFlags[name] - : undefined - const overrideFeatureState = identityFlag - ? { - ...identityFlag, - //resolves multivariate value if one is set - feature_state_value: - actualFlagForFeature?.feature_state_value, - } - : actualFlagForFeature ?? - environmentFlags[featureId] + const overrideFeatureState = + identityFlag ?? environmentFlags?.[featureId] return ( !!projectFlag && ( { identifier={identity?.identity?.identifier} identityName={identityName} shouldPreselect={name === preselect} - toggleDataTest={`user-feature-switch-${i}${ - actualEnabled ? '-on' : '-off' - }`} + toggleDataTest={`user-feature-switch-${i}${identityFlag?.enabled ? '-on' : '-off' + }`} level='identity' valueDataTest={`user-feature-value-${i}`} projectFlag={projectFlag} dataTest={`user-feature-${i}`} overrideFeatureState={overrideFeatureState} environmentFeatureState={ - environmentFlags[featureId] + environmentFlags?.[featureId] } /> ) From 868a79f852911eab7454b6871c58ec40625f2b0e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 10:20:25 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- api/features/serializers.py | 6 ++-- api/features/views.py | 4 +-- .../features/test_identity_feature_states.py | 13 ++++++-- .../test_unit_identity_feature_states.py | 31 ++++++++++++------- 4 files changed, 33 insertions(+), 21 deletions(-) diff --git a/api/features/serializers.py b/api/features/serializers.py index a2d455105ce9..2cbfdb98ea1d 100644 --- a/api/features/serializers.py +++ b/api/features/serializers.py @@ -423,9 +423,9 @@ def get_segment_feature_state( # type: ignore[return] def get_identity_feature_state( # type: ignore[return] self, instance: Feature ) -> dict[str, Any] | None: - if (identity_feature_states := self.context.get("identity_feature_states")) and ( - identity_feature_state := identity_feature_states.get(instance.id) - ): + if ( + identity_feature_states := self.context.get("identity_feature_states") + ) and (identity_feature_state := identity_feature_states.get(instance.id)): return FeatureStateSerializerSmall(instance=identity_feature_state).data def get_num_segment_overrides(self, instance: Feature) -> int: diff --git a/api/features/views.py b/api/features/views.py index ac5946291807..23802e4d75e8 100644 --- a/api/features/views.py +++ b/api/features/views.py @@ -312,7 +312,7 @@ def get_queryset(self): # type: ignore[no-untyped-def] ) segment_feature_states = get_environment_flags_list( environment=self.environment, - additional_filters=segment_query, + additional_filters=segment_query, additional_select_related_args=["feature_state_value", "feature"], ) self._segment_feature_states = { @@ -335,13 +335,11 @@ def get_queryset(self): # type: ignore[no-untyped-def] except (Identity.DoesNotExist, ValueError): self._identity_feature_states = {} else: - # TODO: Implement Edge identity state retrieval if needed self._identity_feature_states = {} return queryset - def paginate_queryset(self, queryset: QuerySet[Feature]) -> list[Feature]: # type: ignore[override] if getattr(self, "_page", None): return self._page # type: ignore[no-any-return,has-type] diff --git a/api/tests/integration/features/test_identity_feature_states.py b/api/tests/integration/features/test_identity_feature_states.py index a3c41892b4b0..40f7a32fb440 100644 --- a/api/tests/integration/features/test_identity_feature_states.py +++ b/api/tests/integration/features/test_identity_feature_states.py @@ -1,10 +1,12 @@ import json + import pytest from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient -from environments.models import Environment + from environments.identities.models import Identity +from environments.models import Environment from features.models import Feature from projects.models import Project @@ -43,7 +45,10 @@ def test_list_features__with_identity__returns_identity_feature_state( assert feature_data["id"] == feature.id assert feature_data["identity_feature_state"] is not None assert feature_data["identity_feature_state"]["enabled"] is True - assert feature_data["identity_feature_state"]["feature_state_value"] == "identity-override" + assert ( + feature_data["identity_feature_state"]["feature_state_value"] + == "identity-override" + ) @pytest.mark.django_db @@ -125,7 +130,9 @@ def test_list_features__with_identity_from_different_environment__identity_featu ) features_url = reverse("api-v1:projects:project-features-list", args=[project.id]) - list_url = f"{features_url}?environment={other_environment.id}&identity={identity.id}" + list_url = ( + f"{features_url}?environment={other_environment.id}&identity={identity.id}" + ) # When list_response = admin_client_new.get(list_url) diff --git a/api/tests/unit/features/test_unit_identity_feature_states.py b/api/tests/unit/features/test_unit_identity_feature_states.py index 7947bc3f5458..9c65481edea2 100644 --- a/api/tests/unit/features/test_unit_identity_feature_states.py +++ b/api/tests/unit/features/test_unit_identity_feature_states.py @@ -1,21 +1,26 @@ from typing import Any from unittest import mock -from features.serializers import CreateFeatureSerializer from features.models import Feature, FeatureState +from features.serializers import CreateFeatureSerializer -def test_create_feature_serializer__get_identity_feature_state__returns_state_from_context() -> None: +def test_create_feature_serializer__get_identity_feature_state__returns_state_from_context() -> ( + None +): # Given feature = mock.MagicMock(spec=Feature, id=1) feature_state = mock.MagicMock(spec=FeatureState, feature_id=1, enabled=True) - with mock.patch("features.serializers.FeatureStateSerializerSmall") as MockSerializer: - MockSerializer.return_value.data = {"enabled": True, "feature_state_value": "foo"} - - context: dict[str, Any] = { - "identity_feature_states": {1: feature_state} + with mock.patch( + "features.serializers.FeatureStateSerializerSmall" + ) as MockSerializer: + MockSerializer.return_value.data = { + "enabled": True, + "feature_state_value": "foo", } + + context: dict[str, Any] = {"identity_feature_states": {1: feature_state}} serializer = CreateFeatureSerializer(instance=feature, context=context) # When @@ -26,13 +31,13 @@ def test_create_feature_serializer__get_identity_feature_state__returns_state_fr MockSerializer.assert_called_once_with(instance=feature_state) -def test_create_feature_serializer__get_identity_feature_state__returns_none_if_no_state_in_context() -> None: +def test_create_feature_serializer__get_identity_feature_state__returns_none_if_no_state_in_context() -> ( + None +): # Given feature = mock.MagicMock(spec=Feature, id=1) - context: dict[str, Any] = { - "identity_feature_states": {} - } + context: dict[str, Any] = {"identity_feature_states": {}} serializer = CreateFeatureSerializer(instance=feature, context=context) # When @@ -42,7 +47,9 @@ def test_create_feature_serializer__get_identity_feature_state__returns_none_if_ assert result is None -def test_create_feature_serializer__get_identity_feature_state__returns_none_if_context_missing_key() -> None: +def test_create_feature_serializer__get_identity_feature_state__returns_none_if_context_missing_key() -> ( + None +): # Given feature = mock.MagicMock(spec=Feature, id=1)