diff --git a/api/conftest.py b/api/conftest.py index 781171aa148c..00d83ab7ad28 100644 --- a/api/conftest.py +++ b/api/conftest.py @@ -704,6 +704,7 @@ def flagsmith_identities_table(dynamodb: DynamoDBServiceResource) -> Table: {"AttributeName": "composite_key", "AttributeType": "S"}, {"AttributeName": "environment_api_key", "AttributeType": "S"}, {"AttributeName": "identifier", "AttributeType": "S"}, + {"AttributeName": "identity_uuid", "AttributeType": "S"}, ], GlobalSecondaryIndexes=[ { @@ -713,7 +714,12 @@ def flagsmith_identities_table(dynamodb: DynamoDBServiceResource) -> Table: {"AttributeName": "identifier", "KeyType": "RANGE"}, ], "Projection": {"ProjectionType": "ALL"}, - } + }, + { + "IndexName": "identity_uuid-index", + "KeySchema": [{"AttributeName": "identity_uuid", "KeyType": "HASH"}], + "Projection": {"ProjectionType": "ALL"}, + }, ], BillingMode="PAY_PER_REQUEST", ) diff --git a/api/edge_api/identities/models.py b/api/edge_api/identities/models.py index 85f7e087aa91..10c0c7d32f38 100644 --- a/api/edge_api/identities/models.py +++ b/api/edge_api/identities/models.py @@ -241,3 +241,20 @@ def _get_changes(self) -> IdentityChangeset: def _reset_initial_state(self): self._initial_state = copy.deepcopy(self) + + def clone_flag_states_from(self, source_identity: "EdgeIdentity") -> None: + """ + Clone the feature states from the source identity to the target identity. + """ + # Delete identity_target's feature states + for feature_state in list(self.feature_overrides): + self.remove_feature_override(feature_state=feature_state) + + # Clone identity_source's feature states to identity_target + for feature_in_source in source_identity.feature_overrides: + feature_state_target = FeatureStateModel( + feature=feature_in_source.feature, + feature_state_value=feature_in_source.feature_state_value, + enabled=feature_in_source.enabled, + ) + self.add_feature_override(feature_state_target) diff --git a/api/edge_api/identities/serializers.py b/api/edge_api/identities/serializers.py index 6b1232609a06..5d0c0907d6e5 100644 --- a/api/edge_api/identities/serializers.py +++ b/api/edge_api/identities/serializers.py @@ -264,3 +264,10 @@ def to_representation(self, instance: IdentityOverrideV2): class GetEdgeIdentityOverridesSerializer(serializers.Serializer): results = GetEdgeIdentityOverridesResultSerializer(many=True) + + +class EdgeIdentitySourceIdentityRequestSerializer(serializers.Serializer): + source_identity_uuid = serializers.CharField( + required=True, + help_text="UUID of the source identity to clone feature states from.", + ) diff --git a/api/edge_api/identities/views.py b/api/edge_api/identities/views.py index 09d900cce45a..c8ac3c0e3d50 100644 --- a/api/edge_api/identities/views.py +++ b/api/edge_api/identities/views.py @@ -7,7 +7,7 @@ from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator from drf_yasg.utils import swagger_auto_schema -from flag_engine.identities.models import IdentityModel +from flag_engine.identities.models import IdentityFeaturesList, IdentityModel from flag_engine.identities.traits.models import TraitModel from pyngo import drf_error_details from rest_framework import status, viewsets @@ -38,6 +38,7 @@ EdgeIdentityFsQueryparamSerializer, EdgeIdentityIdentifierSerializer, EdgeIdentitySerializer, + EdgeIdentitySourceIdentityRequestSerializer, EdgeIdentityTraitsSerializer, EdgeIdentityWithIdentifierFeatureStateDeleteRequestBody, EdgeIdentityWithIdentifierFeatureStateRequestBody, @@ -202,7 +203,6 @@ class EdgeIdentityFeatureStateViewSet(viewsets.ModelViewSet): lookup_field = "featurestate_uuid" serializer_class = EdgeIdentityFeatureStateSerializer - # Patch is not supported http_method_names = [ "get", @@ -215,10 +215,9 @@ class EdgeIdentityFeatureStateViewSet(viewsets.ModelViewSet): ] pagination_class = None - def initial(self, request, *args, **kwargs): - super().initial(request, *args, **kwargs) + def get_identity(self, edge_identity_identity_uuid: str) -> EdgeIdentity: identity_document = EdgeIdentity.dynamo_wrapper.get_item_from_uuid_or_404( - self.kwargs["edge_identity_identity_uuid"] + edge_identity_identity_uuid ) if ( @@ -233,8 +232,15 @@ def initial(self, request, *args, **kwargs): environment__api_key=identity.environment_api_key ).values_list("feature__name", flat=True) ) - identity.synchronise_features(valid_feature_names) - self.identity = identity + identity.synchronise_features(valid_feature_names=valid_feature_names) + + return identity + + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + self.identity: EdgeIdentity = self.get_identity( + edge_identity_identity_uuid=self.kwargs["edge_identity_identity_uuid"] + ) def get_object(self): feature_state = self.identity.get_feature_state_by_featurestate_uuid( @@ -261,7 +267,7 @@ def list(self, request, *args, **kwargs): ) q_params_serializer.is_valid(raise_exception=True) - identity_features = self.identity.feature_overrides + identity_features: IdentityFeaturesList = self.identity.feature_overrides feature = q_params_serializer.data.get("feature") if feature: @@ -300,6 +306,35 @@ def all(self, request, *args, **kwargs): return Response(serializer.data) + @swagger_auto_schema( + request_body=EdgeIdentitySourceIdentityRequestSerializer(), + responses={200: IdentityAllFeatureStatesSerializer(many=True)}, + ) + @action(detail=False, methods=["POST"], url_path="clone-from-given-identity") + def clone_from_given_identity(self, request, *args, **kwargs) -> Response: + """ + Clone feature states from a given source identity. + """ + # Get and validate source identity + serializer = EdgeIdentitySourceIdentityRequestSerializer( + data=request.data, context={"request": request} + ) + serializer.is_valid(raise_exception=True) + + source_identity: EdgeIdentity = self.get_identity( + edge_identity_identity_uuid=serializer.validated_data[ + "source_identity_uuid" + ] + ) + + self.identity.clone_flag_states_from(source_identity) + self.identity.save( + user=request.user.id, + master_api_key=getattr(request, "master_api_key", None), + ) + + return self.all(request, *args, **kwargs) + class EdgeIdentityWithIdentifierFeatureStateView(APIView): permission_classes = [IsAuthenticated, EdgeIdentityWithIdentifierViewPermissions] diff --git a/api/environments/dynamodb/wrappers/environment_wrapper.py b/api/environments/dynamodb/wrappers/environment_wrapper.py index 90e1bcd19fb9..dadf070b7ff7 100644 --- a/api/environments/dynamodb/wrappers/environment_wrapper.py +++ b/api/environments/dynamodb/wrappers/environment_wrapper.py @@ -38,7 +38,8 @@ def write_environments(self, environments: Iterable["Environment"]) -> None: class DynamoEnvironmentWrapper(BaseDynamoEnvironmentWrapper): - table_name = settings.ENVIRONMENTS_TABLE_NAME_DYNAMO + def get_table_name(self) -> str | None: + return settings.ENVIRONMENTS_TABLE_NAME_DYNAMO def write_environments(self, environments: Iterable["Environment"]): with self.table.batch_writer() as writer: diff --git a/api/environments/identities/models.py b/api/environments/identities/models.py index b33c956a193e..a95f4a178f61 100644 --- a/api/environments/identities/models.py +++ b/api/environments/identities/models.py @@ -141,6 +141,15 @@ def get_all_feature_states( return list(identity_flags.values()) + def get_overridden_feature_states(self) -> dict[int, FeatureState]: + """ + Get all overridden feature states for an identity. + + :return: dict[int, FeatureState] - Key: feature ID. Value: Overridden feature_state. + """ + + return {fs.feature_id: fs for fs in self.identity_features.all()} + def get_segments( self, traits: typing.List[Trait] = None, overrides_only: bool = False ) -> typing.List[Segment]: diff --git a/api/environments/identities/serializers.py b/api/environments/identities/serializers.py index da334db3334d..b180cbdc7b84 100644 --- a/api/environments/identities/serializers.py +++ b/api/environments/identities/serializers.py @@ -140,3 +140,10 @@ def get_segment(self, instance) -> typing.Optional[typing.Dict[str, typing.Any]] instance=instance.feature_segment.segment ).data return None + + +class IdentitySourceIdentityRequestSerializer(serializers.Serializer): + source_identity_id = serializers.IntegerField( + required=True, + help_text="ID of the source identity to clone feature states from.", + ) diff --git a/api/features/models.py b/api/features/models.py index a7cd2c4f28a4..71d31b9a7d60 100644 --- a/api/features/models.py +++ b/api/features/models.py @@ -947,6 +947,48 @@ def _is_more_recent_version(self, other: "FeatureState") -> bool: and self.version > other.version ) or (self.version is not None and other.version is None) + @staticmethod + def copy_identity_feature_states( + target_identity: "Identity", source_identity: "Identity" + ) -> None: + target_feature_states: dict[int, FeatureState] = ( + target_identity.get_overridden_feature_states() + ) + source_feature_states: dict[int, FeatureState] = ( + source_identity.get_overridden_feature_states() + ) + + # Delete own feature states not in source_identity + feature_states_to_delete = list( + target_feature_states.keys() - source_feature_states.keys() + ) + for feature_state_id in feature_states_to_delete: + target_feature_states[feature_state_id].delete() + + # Clone source_identity's feature states to target_identity + for source_feature_id, source_feature_state in source_feature_states.items(): + # Get target feature_state if exists in target identity or create new one + target_feature_state: FeatureState = target_feature_states.get( + source_feature_id + ) or FeatureState.objects.create( + environment=target_identity.environment, + identity=target_identity, + feature=source_feature_state.feature, + ) + + # Copy enabled value from source feature_state + target_feature_state.enabled = source_feature_states[ + source_feature_id + ].enabled + + # Copy feature state value from source feature_state + target_feature_state.feature_state_value.copy_from( + source_feature_state.feature_state_value + ) + + # Save changes to target feature_state + target_feature_state.save() + class FeatureStateValue( AbstractBaseFeatureValueModel, @@ -972,6 +1014,14 @@ def clone(self, feature_state: FeatureState) -> "FeatureStateValue": clone.save() return clone + def copy_from(self, source_feature_state_value: "FeatureStateValue"): + # Copy feature state type and values from given feature state value. + self.type = source_feature_state_value.type + self.boolean_value = source_feature_state_value.boolean_value + self.integer_value = source_feature_state_value.integer_value + self.string_value = source_feature_state_value.string_value + self.save() + def get_update_log_message(self, history_instance) -> typing.Optional[str]: fs = self.feature_state diff --git a/api/features/views.py b/api/features/views.py index c8f59ea13cd2..4d99588258d8 100644 --- a/api/features/views.py +++ b/api/features/views.py @@ -27,6 +27,7 @@ from environments.identities.models import Identity from environments.identities.serializers import ( IdentityAllFeatureStatesSerializer, + IdentitySourceIdentityRequestSerializer, ) from environments.models import Environment from environments.permissions.permissions import ( @@ -661,6 +662,34 @@ def all(self, request, *args, **kwargs): return Response(serializer.data) + @swagger_auto_schema( + request_body=IdentitySourceIdentityRequestSerializer(), + responses={200: IdentityAllFeatureStatesSerializer(many=True)}, + ) + @action(methods=["POST"], detail=False, url_path="clone-from-given-identity") + def clone_from_given_identity(self, request, *args, **kwargs) -> Response: + """ + Clone feature states from a given source identity. + """ + serializer = IdentitySourceIdentityRequestSerializer( + data=request.data, context={"request": request} + ) + serializer.is_valid(raise_exception=True) + # Get and validate source and target identities + target_identity = get_object_or_404( + queryset=Identity, pk=self.kwargs["identity_pk"] + ) + source_identity = get_object_or_404( + queryset=Identity, pk=request.data.get("source_identity_id") + ) + + # Clone feature states + FeatureState.copy_identity_feature_states( + target_identity=target_identity, source_identity=source_identity + ) + + return self.all(request, *args, **kwargs) + @method_decorator( name="list", diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 00d7a393108d..68b11a1c75cf 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -90,7 +90,22 @@ def dynamo_enabled_project_environment_one_document( @pytest.fixture() def dynamo_environment_wrapper( flagsmith_environment_table: Table, + settings: SettingsWrapper, ) -> DynamoEnvironmentWrapper: + settings.ENVIRONMENTS_TABLE_NAME_DYNAMO = flagsmith_environment_table.name wrapper = DynamoEnvironmentWrapper() wrapper.table_name = flagsmith_environment_table.name return wrapper + + +@pytest.fixture() +def app_settings_for_dynamodb( + settings: SettingsWrapper, + flagsmith_environment_table: Table, + flagsmith_environments_v2_table: Table, + flagsmith_identities_table: Table, +) -> None: + settings.ENVIRONMENTS_TABLE_NAME_DYNAMO = flagsmith_environment_table.name + settings.ENVIRONMENTS_V2_TABLE_NAME_DYNAMO = flagsmith_environments_v2_table.name + settings.IDENTITIES_TABLE_NAME_DYNAMO = flagsmith_identities_table.name + return diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py index fd1916469077..668173347417 100644 --- a/api/tests/integration/conftest.py +++ b/api/tests/integration/conftest.py @@ -100,7 +100,10 @@ def environment( @pytest.fixture() def dynamo_enabled_environment( - admin_client, dynamo_enabled_project, environment_api_key + admin_client: APIClient, + dynamo_enabled_project: int, + environment_api_key: str, + app_settings_for_dynamodb: None, ) -> int: environment_data = { "name": "Test Environment", diff --git a/api/tests/integration/edge_api/identities/test_edge_identity_featurestates_viewset.py b/api/tests/integration/edge_api/identities/test_edge_identity_featurestates_viewset.py index 2bb9a9c005e8..89d6cf4ba739 100644 --- a/api/tests/integration/edge_api/identities/test_edge_identity_featurestates_viewset.py +++ b/api/tests/integration/edge_api/identities/test_edge_identity_featurestates_viewset.py @@ -1,15 +1,26 @@ import json import typing +import uuid import pytest from core.constants import BOOLEAN, INTEGER, STRING from django.urls import reverse +from flag_engine.features.models import FeatureModel, FeatureStateModel +from mypy_boto3_dynamodb.service_resource import Table from pytest_lazyfixture import lazy_fixture from rest_framework import status from rest_framework.exceptions import NotFound from rest_framework.test import APIClient +from edge_api.identities.models import ( + EdgeIdentity, + IdentityFeaturesList, + IdentityModel, +) +from features.models import Feature +from projects.models import Project from tests.integration.helpers import create_mv_option_with_api +from util.mappers.engine import map_feature_to_engine def test_edge_identities_feature_states_list_does_not_call_sync_identity_document_features_if_not_needed( @@ -1033,3 +1044,131 @@ def _create_segment_override( content_type="application/json", ) assert create_segment_override_response.status_code == status.HTTP_201_CREATED + + +def test_edge_identity_clone_flag_states_from( + admin_client: APIClient, + app_settings_for_dynamodb: None, + dynamo_enabled_environment: int, + dynamo_enabled_project: int, + environment_api_key: str, + flagsmith_identities_table: Table, +) -> None: + def create_identity(identifier: str) -> EdgeIdentity: + identity_model = IdentityModel( + identifier=identifier, + environment_api_key=environment_api_key, + identity_features=IdentityFeaturesList(), + identity_uuid=uuid.uuid4(), + ) + return EdgeIdentity(engine_identity_model=identity_model) + + def features_for_identity_clone_flag_states_from( + project: Project, + ) -> tuple[Feature, ...]: + features: list[Feature] = [] + for i in range(1, 4): + features.append( + Feature.objects.create( + name=f"feature_{i}", project=project, default_enabled=True + ) + ) + return tuple(features) + + # Given + project: Project = Project.objects.get(id=dynamo_enabled_project) + + feature_1, feature_2, feature_3 = features_for_identity_clone_flag_states_from( + project + ) + + feature_model_1: FeatureModel = map_feature_to_engine(feature=feature_1) + feature_model_2: FeatureModel = map_feature_to_engine(feature=feature_2) + feature_model_3: FeatureModel = map_feature_to_engine(feature=feature_3) + + source_identity: EdgeIdentity = create_identity(identifier="source_identity") + target_identity: EdgeIdentity = create_identity(identifier="target_identity") + + source_feature_state_1_value = "Source Identity for feature value 1" + source_feature_state_1 = FeatureStateModel( + feature=feature_model_1, + environment_id=dynamo_enabled_environment, + enabled=True, + feature_state_value=source_feature_state_1_value, + ) + + source_feature_state_2_value = "Source Identity for feature value 2" + source_feature_state_2 = FeatureStateModel( + feature=feature_model_2, + environment_id=dynamo_enabled_environment, + enabled=True, + feature_state_value=source_feature_state_2_value, + ) + + target_feature_state_2_value = "Target Identity value for feature 2" + target_feature_state_2 = FeatureStateModel( + feature=feature_model_2, + environment_id=dynamo_enabled_environment, + enabled=False, + feature_state_value=target_feature_state_2_value, + ) + + target_feature_state_3 = FeatureStateModel( + feature=feature_model_3, + environment_id=dynamo_enabled_environment, + enabled=False, + ) + + # Add feature states for features 1 and 2 to source identity + source_identity.add_feature_override(feature_state=source_feature_state_1) + source_identity.add_feature_override(feature_state=source_feature_state_2) + + # Add feature states for features 2 and 3 to target identity. + target_identity.add_feature_override(feature_state=target_feature_state_2) + target_identity.add_feature_override(feature_state=target_feature_state_3) + + # Save identities to table + target_identity_document = target_identity.to_document() + source_identity_document = source_identity.to_document() + + flagsmith_identities_table.put_item(Item=target_identity_document) + flagsmith_identities_table.put_item(Item=source_identity_document) + + clone_from_given_identity_url: str = reverse( + viewname="api-v1:environments:edge-identity-featurestates-clone-from-given-identity", + args=(environment_api_key, target_identity.identity_uuid), + ) + + # When + + clone_identity_feature_states_response = admin_client.post( + path=clone_from_given_identity_url, + data=json.dumps( + obj={"source_identity_uuid": str(object=source_identity.identity_uuid)} + ), + content_type="application/json", + ) + + # Then + + assert clone_identity_feature_states_response.status_code == status.HTTP_200_OK + + response = clone_identity_feature_states_response.json() + + # Target identity contains only the 2 cloned overridden features states and 1 environment feature state + assert len(response) == 3 + + assert response[0]["feature"]["id"] == feature_1.id + assert response[0]["enabled"] == source_feature_state_1.enabled + assert response[0]["feature_state_value"] == source_feature_state_1_value + assert response[0]["overridden_by"] == "IDENTITY" + + assert response[1]["feature"]["id"] == feature_2.id + assert response[1]["enabled"] == source_feature_state_2.enabled + assert response[1]["feature_state_value"] == source_feature_state_2_value + assert response[1]["overridden_by"] == "IDENTITY" + + assert response[2]["feature"]["id"] == feature_3.id + assert response[2]["enabled"] == feature_3.default_enabled + assert response[2]["feature_state_value"] == feature_3.initial_value + assert response[2]["overridden_by"] is None diff --git a/api/tests/unit/environments/identities/test_unit_identities_feature_states_views.py b/api/tests/unit/environments/identities/test_unit_identities_feature_states_views.py index 3fae296896b7..4f7d2327a0a8 100644 --- a/api/tests/unit/environments/identities/test_unit_identities_feature_states_views.py +++ b/api/tests/unit/environments/identities/test_unit_identities_feature_states_views.py @@ -1,13 +1,18 @@ import json import pytest +from django.test import Client from django.urls import reverse from rest_framework import status +from environments.identities.models import Identity +from environments.models import Environment from environments.permissions.constants import ( UPDATE_FEATURE_STATE, VIEW_ENVIRONMENT, ) +from features.models import Feature, FeatureState, FeatureStateValue +from projects.models import Project from tests.unit.environments.helpers import get_environment_user_client @@ -106,3 +111,117 @@ def test_user_with_view_environment_permission_can_retrieve_all_feature_states_f # Then assert response.status_code == status.HTTP_200_OK + + +def test_identity_clone_flag_states_from( + project: Project, + environment: Environment, + admin_client: Client, +) -> None: + + def features_for_identity_clone_flag_states_from( + project: Project, + ) -> tuple[Feature, ...]: + features: list[Feature] = [] + for i in range(1, 4): + features.append( + Feature.objects.create( + name=f"feature_{i}", project=project, default_enabled=True + ) + ) + return tuple(features) + + # Given + feature_1, feature_2, feature_3 = features_for_identity_clone_flag_states_from( + project + ) + + source_identity: Identity = Identity.objects.create( + identifier="source_identity", environment=environment + ) + target_identity: Identity = Identity.objects.create( + identifier="target_identity", environment=environment + ) + + source_feature_state_1: FeatureState = FeatureState.objects.create( + feature=feature_1, + environment=environment, + identity=source_identity, + enabled=True, + ) + source_feature_state_1_value = "Source Identity for feature value 1" + FeatureStateValue.objects.filter(feature_state=source_feature_state_1).update( + string_value=source_feature_state_1_value + ) + + source_feature_state_2: FeatureState = FeatureState.objects.create( + feature=feature_2, + environment=environment, + identity=source_identity, + enabled=True, + ) + source_feature_state_2_value = "Source Identity for feature value 2" + FeatureStateValue.objects.filter(feature_state=source_feature_state_2).update( + string_value=source_feature_state_2_value + ) + + target_feature_state_2: FeatureState = FeatureState.objects.create( + feature=feature_2, + environment=environment, + identity=target_identity, + enabled=False, + ) + target_feature_state_2_value = "Target Identity value for feature 2" + FeatureStateValue.objects.filter(feature_state=target_feature_state_2).update( + string_value=target_feature_state_2_value + ) + + FeatureState.objects.create( + feature=feature_3, + environment=environment, + identity=target_identity, + enabled=False, + ) + + clone_identity_feature_states_url = reverse( + "api-v1:environments:identity-featurestates-clone-from-given-identity", + args=[environment.api_key, target_identity.id], + ) + + # When + clone_identity_feature_states_response = admin_client.post( + clone_identity_feature_states_url, + data=json.dumps({"source_identity_id": str(source_identity.id)}), + content_type="application/json", + ) + + # Then + assert clone_identity_feature_states_response.status_code == status.HTTP_200_OK + + response = clone_identity_feature_states_response.json() + + # Target identity contains only the 2 cloned overridden features states and 1 environment feature state + assert len(response) == 3 + + # Assert cloned data is correct + assert response[0]["feature"]["id"] == feature_1.id + assert response[0]["enabled"] == source_feature_state_1.enabled + assert response[0]["feature_state_value"] == source_feature_state_1_value + assert response[0]["overridden_by"] == "IDENTITY" + + assert response[1]["feature"]["id"] == feature_2.id + assert response[1]["enabled"] == source_feature_state_2.enabled + assert response[1]["feature_state_value"] == source_feature_state_2_value + assert response[1]["overridden_by"] == "IDENTITY" + + assert response[2]["feature"]["id"] == feature_3.id + assert response[2]["enabled"] == feature_3.default_enabled + assert response[2]["feature_state_value"] == feature_3.initial_value + assert response[2]["overridden_by"] is None + + # Target identity feature 3 override has been removed + assert not FeatureState.objects.filter( + feature=feature_3, + environment=environment, + identity=target_identity, + ).exists()