From f15f3aeaad1899aecfb6f8ce50528124c4046a4a Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Tue, 21 May 2024 10:43:38 +0100 Subject: [PATCH 01/17] Add test --- .../versioning/test_unit_versioning_views.py | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/api/tests/unit/features/versioning/test_unit_versioning_views.py b/api/tests/unit/features/versioning/test_unit_versioning_views.py index 76d7b471840c..3d95d66197f6 100644 --- a/api/tests/unit/features/versioning/test_unit_versioning_views.py +++ b/api/tests/unit/features/versioning/test_unit_versioning_views.py @@ -638,3 +638,66 @@ def test_disable_v2_versioning_returns_bad_request_if_not_using_v2_versioning( # Then assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_create_new_version_with_changes_in_single_request( + environment_v2_versioning: Environment, + feature: Feature, + segment: Segment, + admin_client_new: APIClient, +) -> None: + # Given + url = reverse( + "api-v1:versioning:environment-feature-versions-list", + args=[environment_v2_versioning.id, feature.id], + ) + + data = { + "publish": True, + "feature_states_to_update": [ + { + "feature_segment": None, + "enabled": True, + "feature_state_value": {"type": "unicode", "string_value": "updated!"}, + } + ], + "feature_states_to_delete": [], # TODO: test deleting a feature state + "feature_states_to_create": [ + { + "feature_segment": { + # TODO: priorities? + "segment": segment.id, + }, + "enabled": True, + "feature_state_value": {"type": "unicode", "string_value": "foo"}, + } + ], + } + + # When + response = admin_client_new.post( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then + assert response.status_code == status.HTTP_201_CREATED + + new_version_uuid = response.json()["uuid"] + new_version = EnvironmentFeatureVersion.objects.get(uuid=new_version_uuid) + + assert new_version.feature_states.count() == 2 + + new_version_environment_fs = new_version.feature_states.filter( + feature_segment__isnull=True + ).get() + assert new_version_environment_fs.get_feature_state_value() == "updated!" + assert new_version_environment_fs.enabled is True + + new_version_segment_fs = new_version.feature_states.filter( + feature_segment__segment=segment + ).get() + assert new_version_segment_fs.get_feature_state_value() == "foo" + assert new_version_segment_fs.enabled is True + + assert new_version.published is True + assert new_version.is_live is True From 0f669158792477797c481cc8aaa73873fc36e695 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Tue, 21 May 2024 12:21:02 +0100 Subject: [PATCH 02/17] Add logic to create and publish a version in a single request --- api/features/versioning/serializers.py | 121 ++++++++++++++++++ api/features/versioning/views.py | 4 +- .../versioning/test_unit_versioning_views.py | 17 ++- 3 files changed, 137 insertions(+), 5 deletions(-) diff --git a/api/features/versioning/serializers.py b/api/features/versioning/serializers.py index 1b91b6047a69..ac7b27180b2a 100644 --- a/api/features/versioning/serializers.py +++ b/api/features/versioning/serializers.py @@ -40,6 +40,127 @@ class Meta: ) +class EnvironmentFeatureVersionCreateSerializer(EnvironmentFeatureVersionSerializer): + feature_states_to_create = EnvironmentFeatureVersionFeatureStateSerializer( + many=True, allow_null=True, required=False + ) + feature_states_to_update = EnvironmentFeatureVersionFeatureStateSerializer( + many=True, allow_null=True, required=False + ) + segment_ids_to_delete_overrides = serializers.ListSerializer( + child=serializers.IntegerField(), + required=False, + allow_null=True, + ) + publish = serializers.BooleanField(required=False, default=False) + + class Meta(EnvironmentFeatureVersionSerializer.Meta): + fields = EnvironmentFeatureVersionSerializer.Meta.fields + ( + "feature_states_to_create", + "feature_states_to_update", + "segment_ids_to_delete_overrides", + "publish", + ) + non_model_fields = ( + "feature_states_to_create", + "feature_states_to_update", + "segment_ids_to_delete_overrides", + "publish", + ) + + def create(self, validated_data): + for field_name in self.Meta.non_model_fields: + validated_data.pop(field_name, None) + + version = super().create(validated_data) + + for feature_state_to_create in self.initial_data.get( + "feature_states_to_create", [] + ): + self._create_feature_state( + {**feature_state_to_create, "environment": version.environment_id}, + version, + ) + + for feature_state_to_update in self.initial_data.get( + "feature_states_to_update", [] + ): + self._update_feature_state(feature_state_to_update, version) + + self._delete_feature_states( + self.initial_data.get("segment_ids_to_delete_overrides", []), version + ) + + if self.initial_data.get("publish", False): + request = self.context["request"] + version.publish( + published_by=( + request.user if isinstance(request.user, FFAdminUser) else None + ) + ) + + return version + + def _create_feature_state( + self, feature_state: dict, version: EnvironmentFeatureVersion + ) -> None: + if not self._is_segment_override(feature_state): + raise serializers.ValidationError( + "Cannot create FeatureState objects that are not segment overrides." + ) + + segment_id = feature_state["feature_segment"]["segment"] + if version.feature_states.filter( + feature_segment__segment_id=segment_id + ).exists(): + raise serializers.ValidationError( + "Segment override already exists for Segment %d", segment_id + ) + + fs_serializer = EnvironmentFeatureVersionFeatureStateSerializer( + data=feature_state, + context={ + "feature": version.feature, + "environment": version.environment, + "environment_feature_version": version, + }, + ) + fs_serializer.is_valid(raise_exception=True) + fs_serializer.save( + environment_feature_version=version, + environment=version.environment, + feature=version.feature, + ) + + def _update_feature_state( + self, feature_state: dict, version: EnvironmentFeatureVersion + ) -> None: + if self._is_segment_override(feature_state): + instance = version.feature_states.get( + feature_segment__segment_id=feature_state["feature_segment"]["segment"] + ) + else: + instance = version.feature_states.get(feature_segment__isnull=True) + + fs_serializer = EnvironmentFeatureVersionFeatureStateSerializer( + instance=instance, data=feature_state + ) + fs_serializer.is_valid(raise_exception=True) + fs_serializer.save( + environment_feature_version=version, environment=version.environment + ) + + def _delete_feature_states( + self, segment_ids: list[int], version: EnvironmentFeatureVersion + ) -> None: + version.feature_states.filter( + feature_segment__segment_id__in=segment_ids + ).delete() + + def _is_segment_override(self, feature_state: dict) -> bool: + return feature_state.get("feature_segment") is not None + + class EnvironmentFeatureVersionPublishSerializer(serializers.Serializer): live_from = serializers.DateTimeField(required=False) diff --git a/api/features/versioning/views.py b/api/features/versioning/views.py index 3275db292843..5f5de98d85a1 100644 --- a/api/features/versioning/views.py +++ b/api/features/versioning/views.py @@ -27,6 +27,7 @@ EnvironmentFeatureVersionPermissions, ) from features.versioning.serializers import ( + EnvironmentFeatureVersionCreateSerializer, EnvironmentFeatureVersionFeatureStateSerializer, EnvironmentFeatureVersionPublishSerializer, EnvironmentFeatureVersionQuerySerializer, @@ -48,7 +49,6 @@ class EnvironmentFeatureVersionViewSet( CreateModelMixin, DestroyModelMixin, ): - serializer_class = EnvironmentFeatureVersionSerializer permission_classes = [IsAuthenticated, EnvironmentFeatureVersionPermissions] def __init__(self, *args, **kwargs): @@ -62,6 +62,8 @@ def get_serializer_class(self): match self.action: case "publish": return EnvironmentFeatureVersionPublishSerializer + case "create": + return EnvironmentFeatureVersionCreateSerializer case _: return EnvironmentFeatureVersionSerializer diff --git a/api/tests/unit/features/versioning/test_unit_versioning_views.py b/api/tests/unit/features/versioning/test_unit_versioning_views.py index 3d95d66197f6..74a13fcc490d 100644 --- a/api/tests/unit/features/versioning/test_unit_versioning_views.py +++ b/api/tests/unit/features/versioning/test_unit_versioning_views.py @@ -641,12 +641,17 @@ def test_disable_v2_versioning_returns_bad_request_if_not_using_v2_versioning( def test_create_new_version_with_changes_in_single_request( - environment_v2_versioning: Environment, feature: Feature, segment: Segment, + segment_featurestate: FeatureState, admin_client_new: APIClient, + environment_v2_versioning: Environment, ) -> None: # Given + another_segment = Segment.objects.create( + name="another-segment", project=feature.project + ) + url = reverse( "api-v1:versioning:environment-feature-versions-list", args=[environment_v2_versioning.id, feature.id], @@ -661,17 +666,17 @@ def test_create_new_version_with_changes_in_single_request( "feature_state_value": {"type": "unicode", "string_value": "updated!"}, } ], - "feature_states_to_delete": [], # TODO: test deleting a feature state "feature_states_to_create": [ { "feature_segment": { # TODO: priorities? - "segment": segment.id, + "segment": another_segment.id, }, "enabled": True, "feature_state_value": {"type": "unicode", "string_value": "foo"}, } ], + "segment_ids_to_delete_overrides": [segment.id], } # When @@ -694,10 +699,14 @@ def test_create_new_version_with_changes_in_single_request( assert new_version_environment_fs.enabled is True new_version_segment_fs = new_version.feature_states.filter( - feature_segment__segment=segment + feature_segment__segment=another_segment ).get() assert new_version_segment_fs.get_feature_state_value() == "foo" assert new_version_segment_fs.enabled is True + assert not new_version.feature_states.filter( + feature_segment__segment=segment + ).exists() + assert new_version.published is True assert new_version.is_live is True From 9a3240cea67c4f6a5298270633c63a4270eb8f2e Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Tue, 21 May 2024 12:30:02 +0100 Subject: [PATCH 03/17] Add help text to serializers --- api/features/versioning/serializers.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/api/features/versioning/serializers.py b/api/features/versioning/serializers.py index ac7b27180b2a..918deb569899 100644 --- a/api/features/versioning/serializers.py +++ b/api/features/versioning/serializers.py @@ -42,17 +42,31 @@ class Meta: class EnvironmentFeatureVersionCreateSerializer(EnvironmentFeatureVersionSerializer): feature_states_to_create = EnvironmentFeatureVersionFeatureStateSerializer( - many=True, allow_null=True, required=False + many=True, + allow_null=True, + required=False, + help_text=( + "Array of feature states that will be created in the new version. " + "Note: these can only include segment overrides." + ), ) feature_states_to_update = EnvironmentFeatureVersionFeatureStateSerializer( - many=True, allow_null=True, required=False + many=True, + allow_null=True, + required=False, + help_text="Array of feature states to update in the new version.", ) segment_ids_to_delete_overrides = serializers.ListSerializer( child=serializers.IntegerField(), required=False, allow_null=True, + help_text="Array of segment ids for which the segment overrides will be removed in the new version.", + ) + publish = serializers.BooleanField( + required=False, + default=False, + help_text="Boolean to confirm whether the new version should be publish immediately or not.", ) - publish = serializers.BooleanField(required=False, default=False) class Meta(EnvironmentFeatureVersionSerializer.Meta): fields = EnvironmentFeatureVersionSerializer.Meta.fields + ( From 35a329b34f5cd00a2c2ab6885f10e39f0b1ea5ec Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Thu, 23 May 2024 09:55:04 +0100 Subject: [PATCH 04/17] Update serializer attribute name to avoid odd DRF behaviour --- api/features/versioning/serializers.py | 11 +++++++---- .../features/versioning/test_unit_versioning_views.py | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/api/features/versioning/serializers.py b/api/features/versioning/serializers.py index 918deb569899..b4adba78b599 100644 --- a/api/features/versioning/serializers.py +++ b/api/features/versioning/serializers.py @@ -62,7 +62,7 @@ class EnvironmentFeatureVersionCreateSerializer(EnvironmentFeatureVersionSeriali allow_null=True, help_text="Array of segment ids for which the segment overrides will be removed in the new version.", ) - publish = serializers.BooleanField( + publish_immediately = serializers.BooleanField( required=False, default=False, help_text="Boolean to confirm whether the new version should be publish immediately or not.", @@ -73,13 +73,13 @@ class Meta(EnvironmentFeatureVersionSerializer.Meta): "feature_states_to_create", "feature_states_to_update", "segment_ids_to_delete_overrides", - "publish", + "publish_immediately", ) non_model_fields = ( "feature_states_to_create", "feature_states_to_update", "segment_ids_to_delete_overrides", - "publish", + "publish_immediately", ) def create(self, validated_data): @@ -88,6 +88,9 @@ def create(self, validated_data): version = super().create(validated_data) + # Note that we use self.initial_data for handling the feature states here + # since we want the raw data to pass into the serializers in the separate + # private methods used for modifying the FeatureState objects. for feature_state_to_create in self.initial_data.get( "feature_states_to_create", [] ): @@ -105,7 +108,7 @@ def create(self, validated_data): self.initial_data.get("segment_ids_to_delete_overrides", []), version ) - if self.initial_data.get("publish", False): + if self.validated_data.get("publish_immediately", False): request = self.context["request"] version.publish( published_by=( diff --git a/api/tests/unit/features/versioning/test_unit_versioning_views.py b/api/tests/unit/features/versioning/test_unit_versioning_views.py index 74a13fcc490d..76ee9938a2a6 100644 --- a/api/tests/unit/features/versioning/test_unit_versioning_views.py +++ b/api/tests/unit/features/versioning/test_unit_versioning_views.py @@ -658,7 +658,7 @@ def test_create_new_version_with_changes_in_single_request( ) data = { - "publish": True, + "publish_immediately": True, "feature_states_to_update": [ { "feature_segment": None, From 41a0751f8dd14c8369d00cb91e66c054af804de1 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Thu, 23 May 2024 10:31:53 +0100 Subject: [PATCH 05/17] Handle segment overrides priority --- api/features/feature_segments/serializers.py | 13 ++++- .../versioning/test_unit_versioning_views.py | 52 ++++++++++++++----- 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/api/features/feature_segments/serializers.py b/api/features/feature_segments/serializers.py index 519fe62a8c68..5a9c72181d42 100644 --- a/api/features/feature_segments/serializers.py +++ b/api/features/feature_segments/serializers.py @@ -37,10 +37,21 @@ def validate(self, data): class CreateSegmentOverrideFeatureSegmentSerializer(serializers.ModelSerializer): + priority = serializers.IntegerField(min_value=1, required=False) + class Meta: model = FeatureSegment fields = ("id", "segment", "priority", "uuid") - read_only_fields = ("priority",) + + def save(self, **kwargs): + priority: int | None = self.initial_data.pop("priority", None) + + feature_segment: FeatureSegment = super().save(**kwargs) + + if priority: + feature_segment.to(priority) + + return feature_segment class FeatureSegmentQuerySerializer(serializers.Serializer): diff --git a/api/tests/unit/features/versioning/test_unit_versioning_views.py b/api/tests/unit/features/versioning/test_unit_versioning_views.py index 76ee9938a2a6..0787d3b41864 100644 --- a/api/tests/unit/features/versioning/test_unit_versioning_views.py +++ b/api/tests/unit/features/versioning/test_unit_versioning_views.py @@ -644,12 +644,18 @@ def test_create_new_version_with_changes_in_single_request( feature: Feature, segment: Segment, segment_featurestate: FeatureState, - admin_client_new: APIClient, + admin_client: APIClient, environment_v2_versioning: Environment, ) -> None: + # TODO: replace fixture with admin_client_new + admin_client_new = admin_client + # Given - another_segment = Segment.objects.create( - name="another-segment", project=feature.project + additional_segment_1 = Segment.objects.create( + name="additional-segment-1", project=feature.project + ) + additional_segment_2 = Segment.objects.create( + name="additional-segment-2", project=feature.project ) url = reverse( @@ -669,12 +675,26 @@ def test_create_new_version_with_changes_in_single_request( "feature_states_to_create": [ { "feature_segment": { - # TODO: priorities? - "segment": another_segment.id, + "segment": additional_segment_1.id, + "priority": 2, }, "enabled": True, - "feature_state_value": {"type": "unicode", "string_value": "foo"}, - } + "feature_state_value": { + "type": "unicode", + "string_value": "segment-override-1", + }, + }, + { + "feature_segment": { + "segment": additional_segment_2.id, + "priority": 1, + }, + "enabled": True, + "feature_state_value": { + "type": "unicode", + "string_value": "segment-override-2", + }, + }, ], "segment_ids_to_delete_overrides": [segment.id], } @@ -690,7 +710,7 @@ def test_create_new_version_with_changes_in_single_request( new_version_uuid = response.json()["uuid"] new_version = EnvironmentFeatureVersion.objects.get(uuid=new_version_uuid) - assert new_version.feature_states.count() == 2 + assert new_version.feature_states.count() == 3 new_version_environment_fs = new_version.feature_states.filter( feature_segment__isnull=True @@ -698,11 +718,19 @@ def test_create_new_version_with_changes_in_single_request( assert new_version_environment_fs.get_feature_state_value() == "updated!" assert new_version_environment_fs.enabled is True - new_version_segment_fs = new_version.feature_states.filter( - feature_segment__segment=another_segment + new_version_segment_fs_1 = new_version.feature_states.filter( + feature_segment__segment=additional_segment_1 + ).get() + assert new_version_segment_fs_1.get_feature_state_value() == "segment-override-1" + assert new_version_segment_fs_1.enabled is True + assert new_version_segment_fs_1.feature_segment.priority == 2 + + new_version_segment_fs_2 = new_version.feature_states.filter( + feature_segment__segment=additional_segment_2 ).get() - assert new_version_segment_fs.get_feature_state_value() == "foo" - assert new_version_segment_fs.enabled is True + assert new_version_segment_fs_2.get_feature_state_value() == "segment-override-2" + assert new_version_segment_fs_2.enabled is True + assert new_version_segment_fs_2.feature_segment.priority == 1 assert not new_version.feature_states.filter( feature_segment__segment=segment From 095754036a91e740de23b5b933784874f518dc4a Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Thu, 23 May 2024 10:33:09 +0100 Subject: [PATCH 06/17] Improve help text --- api/features/versioning/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/features/versioning/serializers.py b/api/features/versioning/serializers.py index b4adba78b599..a266dfb6fbf3 100644 --- a/api/features/versioning/serializers.py +++ b/api/features/versioning/serializers.py @@ -60,7 +60,7 @@ class EnvironmentFeatureVersionCreateSerializer(EnvironmentFeatureVersionSeriali child=serializers.IntegerField(), required=False, allow_null=True, - help_text="Array of segment ids for which the segment overrides will be removed in the new version.", + help_text="List of segment ids for which the segment overrides will be removed in the new version.", ) publish_immediately = serializers.BooleanField( required=False, From a439242238bcc5bf5b3c59693384d89763e480ec Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Thu, 23 May 2024 10:33:53 +0100 Subject: [PATCH 07/17] Re-add admin_client_new fixture --- .../unit/features/versioning/test_unit_versioning_views.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/api/tests/unit/features/versioning/test_unit_versioning_views.py b/api/tests/unit/features/versioning/test_unit_versioning_views.py index 0787d3b41864..0d67d4a638d9 100644 --- a/api/tests/unit/features/versioning/test_unit_versioning_views.py +++ b/api/tests/unit/features/versioning/test_unit_versioning_views.py @@ -644,12 +644,9 @@ def test_create_new_version_with_changes_in_single_request( feature: Feature, segment: Segment, segment_featurestate: FeatureState, - admin_client: APIClient, + admin_client_new: APIClient, environment_v2_versioning: Environment, ) -> None: - # TODO: replace fixture with admin_client_new - admin_client_new = admin_client - # Given additional_segment_1 = Segment.objects.create( name="additional-segment-1", project=feature.project From 0fd8ef3d3fab4c29ce37899694dd3fde4fed9ea3 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Thu, 23 May 2024 11:01:28 +0100 Subject: [PATCH 08/17] Add test for updating and creating segment overrides simultaneously --- api/features/versioning/serializers.py | 11 ++- .../versioning/test_unit_versioning_views.py | 79 +++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/api/features/versioning/serializers.py b/api/features/versioning/serializers.py index a266dfb6fbf3..bbc28e93c958 100644 --- a/api/features/versioning/serializers.py +++ b/api/features/versioning/serializers.py @@ -156,11 +156,20 @@ def _update_feature_state( instance = version.feature_states.get( feature_segment__segment_id=feature_state["feature_segment"]["segment"] ) + # Patch the id of the feature segment onto the feature state data so that + # the serializer knows to update rather than try and create a new one. + feature_state["feature_segment"]["id"] = instance.feature_segment_id else: instance = version.feature_states.get(feature_segment__isnull=True) fs_serializer = EnvironmentFeatureVersionFeatureStateSerializer( - instance=instance, data=feature_state + instance=instance, + data=feature_state, + context={ + "feature": version.feature, + "environment": version.environment, + "environment_feature_version": version, + }, ) fs_serializer.is_valid(raise_exception=True) fs_serializer.save( diff --git a/api/tests/unit/features/versioning/test_unit_versioning_views.py b/api/tests/unit/features/versioning/test_unit_versioning_views.py index 0d67d4a638d9..b759322c64ea 100644 --- a/api/tests/unit/features/versioning/test_unit_versioning_views.py +++ b/api/tests/unit/features/versioning/test_unit_versioning_views.py @@ -735,3 +735,82 @@ def test_create_new_version_with_changes_in_single_request( assert new_version.published is True assert new_version.is_live is True + + +def test_update_and_create_segment_override_in_single_request( + feature: Feature, + segment: Segment, + segment_featurestate: FeatureState, + admin_client: APIClient, + environment_v2_versioning: Environment, +) -> None: + # Given + additional_segment = Segment.objects.create( + name="additional-segment", project=feature.project + ) + + url = reverse( + "api-v1:versioning:environment-feature-versions-list", + args=[environment_v2_versioning.id, feature.id], + ) + + data = { + "publish_immediately": True, + "feature_states_to_update": [ + { + "feature_segment": {"segment": segment.id, "priority": 2}, + "enabled": True, + "feature_state_value": { + "type": "unicode", + "string_value": "updated-segment-override", + }, + } + ], + "feature_states_to_create": [ + { + "feature_segment": { + "segment": additional_segment.id, + "priority": 1, + }, + "enabled": True, + "feature_state_value": { + "type": "unicode", + "string_value": "additional-segment-override", + }, + }, + ], + } + + # When + response = admin_client.post( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then + assert response.status_code == status.HTTP_201_CREATED + + new_version_uuid = response.json()["uuid"] + new_version = EnvironmentFeatureVersion.objects.get(uuid=new_version_uuid) + + assert new_version.feature_states.count() == 3 + + updated_segment_override = new_version.feature_states.filter( + feature_segment__segment=segment + ).get() + assert ( + updated_segment_override.get_feature_state_value() == "updated-segment-override" + ) + assert updated_segment_override.enabled is True + assert updated_segment_override.feature_segment.priority == 2 + + new_segment_override = new_version.feature_states.filter( + feature_segment__segment=additional_segment + ).get() + assert ( + new_segment_override.get_feature_state_value() == "additional-segment-override" + ) + assert new_segment_override.enabled is True + assert new_segment_override.feature_segment.priority == 1 + + assert new_version.published is True + assert new_version.is_live is True From 055a85e6f26442aa957064a98f865a3221e0456d Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Thu, 23 May 2024 11:04:21 +0100 Subject: [PATCH 09/17] Add another comment --- api/features/feature_segments/serializers.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/api/features/feature_segments/serializers.py b/api/features/feature_segments/serializers.py index 5a9c72181d42..541cfaf353fe 100644 --- a/api/features/feature_segments/serializers.py +++ b/api/features/feature_segments/serializers.py @@ -37,6 +37,9 @@ def validate(self, data): class CreateSegmentOverrideFeatureSegmentSerializer(serializers.ModelSerializer): + # Since the `priority` field on the FeatureSegment model is set to editable=False + # (to adhere to the django-ordered-model functionality), we redefine the priority + # field here, and use it manually in the save method. priority = serializers.IntegerField(min_value=1, required=False) class Meta: From 26b4c8fa3c8a37955dad41f792492c356e34817b Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Thu, 23 May 2024 11:05:18 +0100 Subject: [PATCH 10/17] Update comment --- api/features/versioning/serializers.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/api/features/versioning/serializers.py b/api/features/versioning/serializers.py index bbc28e93c958..d3661b592ffe 100644 --- a/api/features/versioning/serializers.py +++ b/api/features/versioning/serializers.py @@ -89,8 +89,9 @@ def create(self, validated_data): version = super().create(validated_data) # Note that we use self.initial_data for handling the feature states here - # since we want the raw data to pass into the serializers in the separate - # private methods used for modifying the FeatureState objects. + # since we want the raw data (rather than the serialized ORM objects) to + # pass into the serializers in the separate private methods used for modifying + # the FeatureState objects. for feature_state_to_create in self.initial_data.get( "feature_states_to_create", [] ): From 640a9364983a9d1df5d6ba949a3f87a769402e77 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Thu, 23 May 2024 11:44:18 +0100 Subject: [PATCH 11/17] Increase coverage by testing failure paths --- api/features/versioning/serializers.py | 9 ++- .../versioning/test_unit_versioning_views.py | 76 +++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/api/features/versioning/serializers.py b/api/features/versioning/serializers.py index 186db3c7467d..af72834859c5 100644 --- a/api/features/versioning/serializers.py +++ b/api/features/versioning/serializers.py @@ -148,7 +148,9 @@ def _create_feature_state( ) -> None: if not self._is_segment_override(feature_state): raise serializers.ValidationError( - "Cannot create FeatureState objects that are not segment overrides." + { + "feature_states_to_create": "Cannot create FeatureState objects that are not segment overrides." + } ) segment_id = feature_state["feature_segment"]["segment"] @@ -156,7 +158,10 @@ def _create_feature_state( feature_segment__segment_id=segment_id ).exists(): raise serializers.ValidationError( - "Segment override already exists for Segment %d", segment_id + { + "feature_states_to_create": "Segment override already exists for Segment %d" + % segment_id + } ) fs_serializer = EnvironmentFeatureVersionFeatureStateSerializer( diff --git a/api/tests/unit/features/versioning/test_unit_versioning_views.py b/api/tests/unit/features/versioning/test_unit_versioning_views.py index b759322c64ea..a3188db5930d 100644 --- a/api/tests/unit/features/versioning/test_unit_versioning_views.py +++ b/api/tests/unit/features/versioning/test_unit_versioning_views.py @@ -814,3 +814,79 @@ def test_update_and_create_segment_override_in_single_request( assert new_version.published is True assert new_version.is_live is True + + +def test_create_environment_default_when_creating_new_version_fails( + environment_v2_versioning: Environment, + feature: Feature, + admin_client_new: APIClient, +) -> None: + # Given + data = { + "feature_states_to_create": [ + { + "feature_segment": None, + "enabled": True, + "feature_state_value": { + "type": "unicode", + "string_value": "some new value", + }, + } + ] + } + + url = reverse( + "api-v1:versioning:environment-feature-versions-list", + args=[environment_v2_versioning.id, feature.id], + ) + + # When + response = admin_client_new.post( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + + assert response.json() == { + "feature_states_to_create": "Cannot create FeatureState objects that are not segment overrides." + } + + +def test_create_segment_override_for_existing_override_when_creating_new_version_fails( + feature: Feature, + admin_client_new: APIClient, + segment: Segment, + segment_featurestate: FeatureState, + environment_v2_versioning: Environment, +) -> None: + # Given + data = { + "feature_states_to_create": [ + { + "feature_segment": {"segment": segment.id}, + "enabled": True, + "feature_state_value": { + "type": "unicode", + "string_value": "some new value", + }, + } + ] + } + + url = reverse( + "api-v1:versioning:environment-feature-versions-list", + args=[environment_v2_versioning.id, feature.id], + ) + + # When + response = admin_client_new.post( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == { + "feature_states_to_create": "Segment override already exists for Segment %d" + % segment.id + } From fd058f95ff0af165f29bd74f37ff19cc534a0d49 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Thu, 23 May 2024 12:05:21 +0100 Subject: [PATCH 12/17] Make new fields write only --- api/features/versioning/serializers.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/api/features/versioning/serializers.py b/api/features/versioning/serializers.py index af72834859c5..92025b2c1f14 100644 --- a/api/features/versioning/serializers.py +++ b/api/features/versioning/serializers.py @@ -73,23 +73,27 @@ class EnvironmentFeatureVersionCreateSerializer(EnvironmentFeatureVersionSeriali "Array of feature states that will be created in the new version. " "Note: these can only include segment overrides." ), + write_only=True, ) feature_states_to_update = EnvironmentFeatureVersionFeatureStateSerializer( many=True, allow_null=True, required=False, help_text="Array of feature states to update in the new version.", + write_only=True, ) segment_ids_to_delete_overrides = serializers.ListSerializer( child=serializers.IntegerField(), required=False, allow_null=True, help_text="List of segment ids for which the segment overrides will be removed in the new version.", + write_only=True, ) publish_immediately = serializers.BooleanField( required=False, default=False, help_text="Boolean to confirm whether the new version should be publish immediately or not.", + write_only=True, ) class Meta(EnvironmentFeatureVersionSerializer.Meta): From 1ca8adec66a32f0f904d9e2b3a5b3f2912defb01 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Thu, 23 May 2024 17:54:01 +0100 Subject: [PATCH 13/17] PR feedback --- api/features/feature_segments/serializers.py | 4 ++- api/features/versioning/serializers.py | 25 ++++++++++--------- .../versioning/test_unit_versioning_views.py | 25 +++++++++++-------- 3 files changed, 31 insertions(+), 23 deletions(-) diff --git a/api/features/feature_segments/serializers.py b/api/features/feature_segments/serializers.py index 541cfaf353fe..017fcdac487d 100644 --- a/api/features/feature_segments/serializers.py +++ b/api/features/feature_segments/serializers.py @@ -1,3 +1,5 @@ +import typing + from rest_framework import serializers from rest_framework.exceptions import PermissionDenied @@ -46,7 +48,7 @@ class Meta: model = FeatureSegment fields = ("id", "segment", "priority", "uuid") - def save(self, **kwargs): + def save(self, **kwargs: typing.Any) -> FeatureSegment: priority: int | None = self.initial_data.pop("priority", None) feature_segment: FeatureSegment = super().save(**kwargs) diff --git a/api/features/versioning/serializers.py b/api/features/versioning/serializers.py index 92025b2c1f14..7f55a817f760 100644 --- a/api/features/versioning/serializers.py +++ b/api/features/versioning/serializers.py @@ -1,3 +1,5 @@ +import typing + from rest_framework import serializers from features.serializers import CreateSegmentOverrideFeatureStateSerializer @@ -110,7 +112,9 @@ class Meta(EnvironmentFeatureVersionSerializer.Meta): "publish_immediately", ) - def create(self, validated_data): + def create( + self, validated_data: dict[str, typing.Any] + ) -> EnvironmentFeatureVersion: for field_name in self.Meta.non_model_fields: validated_data.pop(field_name, None) @@ -168,23 +172,20 @@ def _create_feature_state( } ) + save_kwargs = { + "feature": version.feature, + "environment": version.environment, + "environment_feature_version": version, + } fs_serializer = EnvironmentFeatureVersionFeatureStateSerializer( data=feature_state, - context={ - "feature": version.feature, - "environment": version.environment, - "environment_feature_version": version, - }, + context=save_kwargs, ) fs_serializer.is_valid(raise_exception=True) - fs_serializer.save( - environment_feature_version=version, - environment=version.environment, - feature=version.feature, - ) + fs_serializer.save(**save_kwargs) def _update_feature_state( - self, feature_state: dict, version: EnvironmentFeatureVersion + self, feature_state: dict[str, typing.Any], version: EnvironmentFeatureVersion ) -> None: if self._is_segment_override(feature_state): instance = version.feature_states.get( diff --git a/api/tests/unit/features/versioning/test_unit_versioning_views.py b/api/tests/unit/features/versioning/test_unit_versioning_views.py index a3188db5930d..ccf74ebf475f 100644 --- a/api/tests/unit/features/versioning/test_unit_versioning_views.py +++ b/api/tests/unit/features/versioning/test_unit_versioning_views.py @@ -10,7 +10,10 @@ from rest_framework.test import APIClient from environments.models import Environment -from environments.permissions.constants import VIEW_ENVIRONMENT +from environments.permissions.constants import ( + UPDATE_FEATURE_STATE, + VIEW_ENVIRONMENT, +) from features.models import Feature, FeatureSegment, FeatureState from features.versioning.models import EnvironmentFeatureVersion from projects.permissions import VIEW_PROJECT @@ -70,25 +73,30 @@ def test_get_versions_for_a_feature_and_environment( def test_create_new_feature_version( - admin_user: FFAdminUser, - admin_client: APIClient, + staff_user: FFAdminUser, + staff_client: APIClient, environment_v2_versioning: Environment, feature: Feature, + with_environment_permissions: WithEnvironmentPermissionsCallable, + with_project_permissions: WithProjectPermissionsCallable, ) -> None: # Given + with_environment_permissions([VIEW_ENVIRONMENT, UPDATE_FEATURE_STATE]) + with_project_permissions([VIEW_PROJECT]) + url = reverse( "api-v1:versioning:environment-feature-versions-list", args=[environment_v2_versioning.id, feature.id], ) # When - response = admin_client.post(url) + response = staff_client.post(url) # Then assert response.status_code == status.HTTP_201_CREATED response_json = response.json() - assert response_json["created_by"] == admin_user.id + assert response_json["created_by"] == staff_user.id assert response_json["uuid"] @@ -383,7 +391,6 @@ def test_update_environment_feature_version_feature_state( def test_cannot_update_feature_state_in_published_environment_feature_version( admin_client: APIClient, - admin_user: FFAdminUser, environment_v2_versioning: Environment, feature: Feature, ) -> None: @@ -428,7 +435,6 @@ def test_cannot_update_feature_state_in_published_environment_feature_version( def test_delete_environment_feature_version_feature_state( admin_client: APIClient, - admin_user: FFAdminUser, environment_v2_versioning: Environment, segment: Segment, feature: Feature, @@ -519,7 +525,6 @@ def test_cannot_delete_feature_state_in_published_environment_feature_version( def test_cannot_delete_environment_default_feature_state_for_unpublished_environment_feature_version( admin_client: APIClient, - admin_user: FFAdminUser, environment_v2_versioning: Environment, feature: Feature, ) -> None: @@ -741,7 +746,7 @@ def test_update_and_create_segment_override_in_single_request( feature: Feature, segment: Segment, segment_featurestate: FeatureState, - admin_client: APIClient, + admin_client_new: APIClient, environment_v2_versioning: Environment, ) -> None: # Given @@ -782,7 +787,7 @@ def test_update_and_create_segment_override_in_single_request( } # When - response = admin_client.post( + response = admin_client_new.post( url, data=json.dumps(data), content_type="application/json" ) From e624c6a57a1e2e7de5078d72555b33bb1f498329 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 29 May 2024 18:08:48 +0100 Subject: [PATCH 14/17] Set min priority to 0 --- api/features/feature_segments/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/features/feature_segments/serializers.py b/api/features/feature_segments/serializers.py index 017fcdac487d..ff0b58ced4a8 100644 --- a/api/features/feature_segments/serializers.py +++ b/api/features/feature_segments/serializers.py @@ -42,7 +42,7 @@ class CreateSegmentOverrideFeatureSegmentSerializer(serializers.ModelSerializer) # Since the `priority` field on the FeatureSegment model is set to editable=False # (to adhere to the django-ordered-model functionality), we redefine the priority # field here, and use it manually in the save method. - priority = serializers.IntegerField(min_value=1, required=False) + priority = serializers.IntegerField(min_value=0, required=False) class Meta: model = FeatureSegment From 29a80abc1edd8b399e9dc3d6ca9f6b669b4da796 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 29 May 2024 18:25:24 +0100 Subject: [PATCH 15/17] Fix issue updating multivariate feature values --- api/conftest.py | 7 +++ api/features/versioning/serializers.py | 16 +++++ .../versioning/test_unit_versioning_views.py | 62 +++++++++++++++++++ 3 files changed, 85 insertions(+) diff --git a/api/conftest.py b/api/conftest.py index 31bda86c3a84..e852094cdd22 100644 --- a/api/conftest.py +++ b/api/conftest.py @@ -446,6 +446,13 @@ def multivariate_feature(project): return feature +@pytest.fixture() +def multivariate_options( + multivariate_feature: Feature, +) -> list[MultivariateFeatureOption]: + return list(multivariate_feature.multivariate_options.all()) + + @pytest.fixture() def identity_matching_segment(project, trait): segment = Segment.objects.create(name="Matching segment", project=project) diff --git a/api/features/versioning/serializers.py b/api/features/versioning/serializers.py index 7f55a817f760..ef80898cd358 100644 --- a/api/features/versioning/serializers.py +++ b/api/features/versioning/serializers.py @@ -197,6 +197,22 @@ def _update_feature_state( else: instance = version.feature_states.get(feature_segment__isnull=True) + # TODO: can this be simplified at all? + for existing_mvfsv in instance.multivariate_feature_state_values.all(): + updated_mvfsv_dicts = feature_state.get( + "multivariate_feature_state_values", [] + ) + updated_mvfsv_dict = next( + filter( + lambda d: d["multivariate_feature_option"] + == existing_mvfsv.multivariate_feature_option_id, + updated_mvfsv_dicts, + ), + None, + ) + if updated_mvfsv_dict: + updated_mvfsv_dict["id"] = existing_mvfsv.id + fs_serializer = EnvironmentFeatureVersionFeatureStateSerializer( instance=instance, data=feature_state, diff --git a/api/tests/unit/features/versioning/test_unit_versioning_views.py b/api/tests/unit/features/versioning/test_unit_versioning_views.py index ccf74ebf475f..9197f5dd2322 100644 --- a/api/tests/unit/features/versioning/test_unit_versioning_views.py +++ b/api/tests/unit/features/versioning/test_unit_versioning_views.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta import pytest +from core.constants import STRING from django.urls import reverse from django.utils import timezone from freezegun import freeze_time @@ -15,6 +16,7 @@ VIEW_ENVIRONMENT, ) from features.models import Feature, FeatureSegment, FeatureState +from features.multivariate.models import MultivariateFeatureOption from features.versioning.models import EnvironmentFeatureVersion from projects.permissions import VIEW_PROJECT from segments.models import Segment @@ -895,3 +897,63 @@ def test_create_segment_override_for_existing_override_when_creating_new_version "feature_states_to_create": "Segment override already exists for Segment %d" % segment.id } + + +def test_create_new_version_for_multivariate_feature( + multivariate_feature: Feature, + multivariate_options: list[MultivariateFeatureOption], + environment_v2_versioning: Environment, + staff_client: APIClient, + with_environment_permissions: WithEnvironmentPermissionsCallable, + with_project_permissions: WithProjectPermissionsCallable, +) -> None: + # Given + with_environment_permissions( + [VIEW_ENVIRONMENT, UPDATE_FEATURE_STATE], environment_v2_versioning.id + ) + with_project_permissions([VIEW_PROJECT]) + + create_version_url = reverse( + "api-v1:versioning:environment-feature-versions-list", + args=[environment_v2_versioning.id, multivariate_feature.id], + ) + + data = { + "feature_states_to_update": [ + { + "feature_segment": None, + "enabled": True, + "feature_state_value": { + "type": STRING, + "string_value": multivariate_feature.initial_value, + }, + "multivariate_feature_state_values": [ + { + "multivariate_feature_option": mvfo.id, + "percentage_allocation": 10, + } + for mvfo in multivariate_options + ], + } + ] + } + + # When + response = staff_client.post( + create_version_url, data=json.dumps(data), content_type="application/json" + ) + + # Then + assert response.status_code == status.HTTP_201_CREATED + + version_uuid = response.json()["uuid"] + new_version = EnvironmentFeatureVersion.objects.get(uuid=version_uuid) + + assert all( + [ + mvfsv.percentage_allocation == 10 + for mvfsv in new_version.feature_states.get( + feature=multivariate_feature + ).multivariate_feature_state_values.all() + ] + ) From 3dfa2a67069735c7e88231d2445e10a8651f23f4 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Mon, 3 Jun 2024 19:32:38 +0100 Subject: [PATCH 16/17] Delete the feature segments (which cascade to the feature states) instead of feature states --- api/features/versioning/serializers.py | 4 +- .../versioning/test_unit_versioning_views.py | 39 +++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/api/features/versioning/serializers.py b/api/features/versioning/serializers.py index 14d14466061e..d4442c916c11 100644 --- a/api/features/versioning/serializers.py +++ b/api/features/versioning/serializers.py @@ -254,9 +254,7 @@ def _update_feature_state( def _delete_feature_states( self, segment_ids: list[int], version: EnvironmentFeatureVersion ) -> None: - version.feature_states.filter( - feature_segment__segment_id__in=segment_ids - ).delete() + version.feature_segments.filter(segment_id__in=segment_ids).delete() def _is_segment_override(self, feature_state: dict) -> bool: return feature_state.get("feature_segment") is not None diff --git a/api/tests/unit/features/versioning/test_unit_versioning_views.py b/api/tests/unit/features/versioning/test_unit_versioning_views.py index f96fd6aacd83..09e42cfc2552 100644 --- a/api/tests/unit/features/versioning/test_unit_versioning_views.py +++ b/api/tests/unit/features/versioning/test_unit_versioning_views.py @@ -1085,3 +1085,42 @@ def test_create_new_version_for_multivariate_feature( ).multivariate_feature_state_values.all() ] ) + + +def test_create_new_version_delete_segment_override_updates_overrides_immediately( + feature: Feature, + segment: Segment, + feature_segment: FeatureSegment, + segment_featurestate: FeatureState, + environment_v2_versioning: Environment, + admin_client: APIClient, +) -> None: + # Given + create_version_url = reverse( + "api-v1:versioning:environment-feature-versions-list", + args=[environment_v2_versioning.id, feature.id], + ) + + data = { + "segment_ids_to_delete_overrides": [segment.id], + "publish_immediately": True, + } + + # When + response = admin_client.post( + create_version_url, + data=json.dumps(data), + content_type="application/json", + ) + + # Then + assert response.status_code == status.HTTP_201_CREATED + + get_feature_segments_url = "%s?environment=%d&feature=%d" % ( + reverse("api-v1:features:feature-segment-list"), + environment_v2_versioning.id, + feature.id, + ) + get_feature_segments_response = admin_client.get(get_feature_segments_url) + assert get_feature_segments_response.status_code == status.HTTP_200_OK + assert get_feature_segments_response.json()["count"] == 0 From ce87fe034d93cdaf58cb498d4fe30324ac47bc35 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Mon, 24 Jun 2024 11:45:12 +0100 Subject: [PATCH 17/17] Move / improve comment --- api/features/versioning/serializers.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/api/features/versioning/serializers.py b/api/features/versioning/serializers.py index d4442c916c11..9774961b4274 100644 --- a/api/features/versioning/serializers.py +++ b/api/features/versioning/serializers.py @@ -139,15 +139,16 @@ class Meta(EnvironmentFeatureVersionSerializer.Meta): def create( self, validated_data: dict[str, typing.Any] ) -> EnvironmentFeatureVersion: + # Note that we use self.initial_data below for handling the feature states + # since we want the raw data (rather than the serialized ORM objects) to pass + # into the serializers in the separate private methods used for modifying the + # FeatureState objects. As such, we just want to blindly remove the non-model + # attribute keys from the validated before calling super to create the version. for field_name in self.Meta.non_model_fields: validated_data.pop(field_name, None) version = super().create(validated_data) - # Note that we use self.initial_data for handling the feature states here - # since we want the raw data (rather than the serialized ORM objects) to - # pass into the serializers in the separate private methods used for modifying - # the FeatureState objects. for feature_state_to_create in self.initial_data.get( "feature_states_to_create", [] ):