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 2 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
121 changes: 121 additions & 0 deletions api/features/versioning/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
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"]
)
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)

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
Original file line number Diff line number Diff line change
Expand Up @@ -638,3 +638,75 @@ 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(
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],
)

data = {
"publish": True,
"feature_states_to_update": [
{
"feature_segment": None,
"enabled": True,
"feature_state_value": {"type": "unicode", "string_value": "updated!"},
}
],
"feature_states_to_create": [
{
"feature_segment": {
# TODO: priorities?
"segment": another_segment.id,
},
"enabled": True,
"feature_state_value": {"type": "unicode", "string_value": "foo"},
}
],
"segment_ids_to_delete_overrides": [segment.id],
}

# 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=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
Loading