Skip to content

Commit

Permalink
feat(versioning): add logic to create version in single endpoint (#3991)
Browse files Browse the repository at this point in the history
  • Loading branch information
matthewelwell authored Jun 24, 2024
1 parent 7554d15 commit 57f8d68
Show file tree
Hide file tree
Showing 5 changed files with 566 additions and 9 deletions.
7 changes: 7 additions & 0 deletions api/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,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
173 changes: 173 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,177 @@ 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:
# 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)

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

0 comments on commit 57f8d68

Please sign in to comment.