diff --git a/api/features/serializers.py b/api/features/serializers.py index 3c6e17280c39..2cbfdb98ea1d 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..23802e4d75e8 100644 --- a/api/features/views.py +++ b/api/features/views.py @@ -319,6 +319,25 @@ def get_queryset(self): # type: ignore[no-untyped-def] 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] @@ -354,6 +373,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..40f7a32fb440 --- /dev/null +++ b/api/tests/integration/features/test_identity_feature_states.py @@ -0,0 +1,164 @@ +import json + +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from environments.identities.models import Identity +from environments.models import Environment +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..9c65481edea2 --- /dev/null +++ b/api/tests/unit/features/test_unit_identity_feature_states.py @@ -0,0 +1,63 @@ +from typing import Any +from unittest import mock + +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 +): + # 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] } /> )