Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(versioning): add logic to create version in single endpoint #3991

Merged
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f15f3ae
Add test
matthewelwell May 21, 2024
0f66915
Add logic to create and publish a version in a single request
matthewelwell May 21, 2024
9a3240c
Add help text to serializers
matthewelwell May 21, 2024
35a329b
Update serializer attribute name to avoid odd DRF behaviour
matthewelwell May 23, 2024
41a0751
Handle segment overrides priority
matthewelwell May 23, 2024
0957540
Improve help text
matthewelwell May 23, 2024
a439242
Re-add admin_client_new fixture
matthewelwell May 23, 2024
0fd8ef3
Add test for updating and creating segment overrides simultaneously
matthewelwell May 23, 2024
055a85e
Add another comment
matthewelwell May 23, 2024
26b4c8f
Update comment
matthewelwell May 23, 2024
e0eddb7
Merge branch 'refs/heads/main' into feat(versioning)/add-logic-to-cre…
matthewelwell May 23, 2024
640a936
Increase coverage by testing failure paths
matthewelwell May 23, 2024
fd058f9
Make new fields write only
matthewelwell May 23, 2024
1ca8ade
PR feedback
matthewelwell May 23, 2024
e624c6a
Set min priority to 0
matthewelwell May 29, 2024
29a80ab
Fix issue updating multivariate feature values
matthewelwell May 29, 2024
94e8397
Merge branch 'refs/heads/main' into feat(versioning)/add-logic-to-cre…
matthewelwell May 31, 2024
b10d7a7
Merge branch 'refs/heads/main' into feat(versioning)/add-logic-to-cre…
matthewelwell Jun 3, 2024
3dfa2a6
Delete the feature segments (which cascade to the feature states) ins…
matthewelwell Jun 3, 2024
ce87fe0
Move / improve comment
matthewelwell Jun 24, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions api/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,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)
Expand Down
18 changes: 17 additions & 1 deletion api/features/feature_segments/serializers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import typing

from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied

Expand Down Expand Up @@ -37,10 +39,24 @@ 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=0, required=False)

class Meta:
model = FeatureSegment
fields = ("id", "segment", "priority", "uuid")
read_only_fields = ("priority",)

def save(self, **kwargs: typing.Any) -> FeatureSegment:
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):
Expand Down
172 changes: 172 additions & 0 deletions api/features/versioning/serializers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import typing

from rest_framework import serializers

from api_keys.user import APIKeyUser
Expand Down Expand Up @@ -88,6 +90,176 @@ def get_previous_version_uuid(
return str(previous_version.uuid)


class EnvironmentFeatureVersionCreateSerializer(EnvironmentFeatureVersionSerializer):
feature_states_to_create = EnvironmentFeatureVersionFeatureStateSerializer(
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."
),
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):
fields = EnvironmentFeatureVersionSerializer.Meta.fields + (
"feature_states_to_create",
"feature_states_to_update",
"segment_ids_to_delete_overrides",
"publish_immediately",
)
non_model_fields = (
"feature_states_to_create",
"feature_states_to_update",
"segment_ids_to_delete_overrides",
"publish_immediately",
)

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)

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", []
):
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.validated_data.get("publish_immediately", 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(
{
"feature_states_to_create": "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(
{
"feature_states_to_create": "Segment override already exists for Segment %d"
% segment_id
}
)

save_kwargs = {
"feature": version.feature,
"environment": version.environment,
"environment_feature_version": version,
}
fs_serializer = EnvironmentFeatureVersionFeatureStateSerializer(
data=feature_state,
context=save_kwargs,
)
fs_serializer.is_valid(raise_exception=True)
fs_serializer.save(**save_kwargs)

def _update_feature_state(
self, feature_state: dict[str, typing.Any], version: EnvironmentFeatureVersion
) -> None:
if self._is_segment_override(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)

# 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,
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
)

def _delete_feature_states(
self, segment_ids: list[int], version: EnvironmentFeatureVersion
) -> None:
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


class EnvironmentFeatureVersionPublishSerializer(serializers.Serializer):
live_from = serializers.DateTimeField(required=False)

Expand Down
3 changes: 3 additions & 0 deletions api/features/versioning/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
EnvironmentFeatureVersionRetrievePermissions,
)
from features.versioning.serializers import (
EnvironmentFeatureVersionCreateSerializer,
EnvironmentFeatureVersionFeatureStateSerializer,
EnvironmentFeatureVersionPublishSerializer,
EnvironmentFeatureVersionQuerySerializer,
Expand Down Expand Up @@ -66,6 +67,8 @@ def get_serializer_class(self):
return EnvironmentFeatureVersionPublishSerializer
case "retrieve":
return EnvironmentFeatureVersionRetrieveSerializer
case "create":
return EnvironmentFeatureVersionCreateSerializer
case _:
return EnvironmentFeatureVersionSerializer

Expand Down
Loading