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 13 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
16 changes: 15 additions & 1 deletion api/features/feature_segments/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,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=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):
Expand Down
157 changes: 157 additions & 0 deletions api/features/versioning/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,163 @@ class Meta:
)


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):
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
}
)

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,
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a DRYer way of doing this? The context was already set in the constructor, it seems weird to have to set them again.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not really too sure why we need both the context and the save kwargs. I tried to follow it down the rabbit hole (of my own making), but couldn't see anything obvious. I've tidied it up a little bit to use a single dict definition in both cases, and I'll try to tackle a better refactor in a separate piece of work.


def _update_feature_state(
self, feature_state: dict, version: EnvironmentFeatureVersion
) -> None:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be better if the typing on the dict had more information.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added as much information as I think I can without it being beneficial to just create e.g. a pydantic model.

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)

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_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)

Expand Down
4 changes: 3 additions & 1 deletion api/features/versioning/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
EnvironmentFeatureVersionPermissions,
)
from features.versioning.serializers import (
EnvironmentFeatureVersionCreateSerializer,
EnvironmentFeatureVersionFeatureStateSerializer,
EnvironmentFeatureVersionPublishSerializer,
EnvironmentFeatureVersionQuerySerializer,
Expand All @@ -48,7 +49,6 @@ class EnvironmentFeatureVersionViewSet(
CreateModelMixin,
DestroyModelMixin,
):
serializer_class = EnvironmentFeatureVersionSerializer
permission_classes = [IsAuthenticated, EnvironmentFeatureVersionPermissions]

def __init__(self, *args, **kwargs):
Expand All @@ -62,6 +62,8 @@ def get_serializer_class(self):
match self.action:
case "publish":
return EnvironmentFeatureVersionPublishSerializer
case "create":
return EnvironmentFeatureVersionCreateSerializer
case _:
return EnvironmentFeatureVersionSerializer

Expand Down
Loading