Skip to content

Commit

Permalink
feat: add endpoint to revert v2 versioning (#3897)
Browse files Browse the repository at this point in the history
  • Loading branch information
matthewelwell committed May 9, 2024
1 parent d92c082 commit da9e051
Show file tree
Hide file tree
Showing 9 changed files with 214 additions and 14 deletions.
6 changes: 4 additions & 2 deletions api/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,11 +336,13 @@ def with_environment_permissions(
"""

def _with_environment_permissions(
permission_keys: list[str], environment_id: int | None = None
permission_keys: list[str],
environment_id: int | None = None,
admin: bool = False,
) -> UserEnvironmentPermission:
environment_id = environment_id or environment.id
uep, __ = UserEnvironmentPermission.objects.get_or_create(
environment_id=environment_id, user=staff_user
environment_id=environment_id, user=staff_user, defaults={"admin": admin}
)
uep.permissions.add(*permission_keys)

Expand Down
6 changes: 0 additions & 6 deletions api/environments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
AFTER_DELETE,
AFTER_SAVE,
AFTER_UPDATE,
BEFORE_UPDATE,
LifecycleModel,
hook,
)
Expand All @@ -44,7 +43,6 @@
from environments.exceptions import EnvironmentHeaderNotPresentError
from environments.managers import EnvironmentManager
from features.models import Feature, FeatureSegment, FeatureState
from features.versioning.exceptions import FeatureVersioningError
from metadata.models import Metadata
from projects.models import IdentityOverridesV2MigrationStatus, Project
from segments.models import Segment
Expand Down Expand Up @@ -150,10 +148,6 @@ def clear_environment_cache(self):
# TODO: this could rebuild the cache itself (using an async task)
environment_cache.delete(self.initial_value("api_key"))

@hook(BEFORE_UPDATE, when="use_v2_feature_versioning", was=True, is_now=False)
def validate_use_v2_feature_versioning(self):
raise FeatureVersioningError("Cannot revert from v2 feature versioning.")

@hook(AFTER_DELETE)
def delete_from_dynamo(self):
if self.project.enable_dynamo_db and environment_wrapper.is_enabled:
Expand Down
17 changes: 16 additions & 1 deletion api/environments/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@
NestedEnvironmentPermissions,
)
from environments.sdk.schemas import SDKEnvironmentDocumentModel
from features.versioning.tasks import enable_v2_versioning
from features.versioning.tasks import (
disable_v2_versioning,
enable_v2_versioning,
)
from permissions.permissions_calculator import get_environment_permission_data
from permissions.serializers import (
PermissionModelSerializer,
Expand Down Expand Up @@ -216,6 +219,18 @@ def enable_v2_versioning(self, request: Request, api_key: str) -> Response:
enable_v2_versioning.delay(kwargs={"environment_id": environment.id})
return Response(status=status.HTTP_202_ACCEPTED)

@swagger_auto_schema(request_body=no_body, responses={202: ""})
@action(detail=True, methods=["POST"], url_path="disable-v2-versioning")
def disable_v2_versioning(self, request: Request, api_key: str) -> Response:
environment = self.get_object()
if environment.use_v2_feature_versioning is False:
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={"detail": "Environment is not using v2 versioning."},
)
disable_v2_versioning.delay(kwargs={"environment_id": environment.id})
return Response(status=status.HTTP_202_ACCEPTED)


class NestedEnvironmentViewSet(viewsets.GenericViewSet):
model_class = None
Expand Down
9 changes: 8 additions & 1 deletion api/features/managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,14 @@ def get_live_feature_states(
environment
)
latest_version_uuids = [efv.uuid for efv in latest_versions]
qs_filter &= Q(environment_feature_version__uuid__in=latest_version_uuids)

# Note that since identity overrides aren't part of the versioning system,
# we need to make sure we also return them here. We can still then subsequently
# filter them out with the `additional_filters` if needed.
qs_filter &= Q(
Q(environment_feature_version__uuid__in=latest_version_uuids)
| Q(identity__isnull=False)
)
else:
qs_filter &= Q(
live_from__isnull=False,
Expand Down
36 changes: 35 additions & 1 deletion api/features/versioning/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@


@register_task_handler()
def enable_v2_versioning(environment_id: int):
def enable_v2_versioning(environment_id: int) -> None:
from environments.models import Environment

environment = Environment.objects.get(id=environment_id)
Expand All @@ -35,6 +35,40 @@ def enable_v2_versioning(environment_id: int):
environment.save()


@register_task_handler()
def disable_v2_versioning(environment_id: int) -> None:
from environments.models import Environment
from features.models import FeatureSegment, FeatureState
from features.versioning.models import EnvironmentFeatureVersion

environment = Environment.objects.get(id=environment_id)

latest_feature_states = get_environment_flags_queryset(environment)
latest_feature_state_ids = [fs.id for fs in latest_feature_states]

# delete any feature states and feature segments associated with older versions
FeatureState.objects.filter(identity_id__isnull=True).exclude(
id__in=latest_feature_state_ids
).delete()
FeatureSegment.objects.exclude(
feature_states__id__in=latest_feature_state_ids
).delete()

# update the latest feature states (and respective feature segments) to be the
# latest version according to the old versioning system
latest_feature_states.update(
version=1, live_from=timezone.now(), environment_feature_version=None
)
FeatureSegment.objects.filter(
feature_states__id__in=latest_feature_state_ids
).update(environment_feature_version=None)

EnvironmentFeatureVersion.objects.filter(environment=environment).delete()

environment.use_v2_feature_versioning = False
environment.save()


def _create_initial_feature_versions(environment: "Environment"):
from features.models import Feature, FeatureSegment

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ def verify_consistent_responses(num_expected_flags: int) -> None:
# still behave the same
verify_consistent_responses(num_expected_flags=2)

# finally, let's publish the new version and verify that we get a different response
# now, let's publish the new version and verify that we get a different response
publish_ef_version_url = reverse(
"api-v1:versioning:environment-feature-versions-publish",
args=[environment, feature, ef_version_uuid],
Expand Down Expand Up @@ -237,6 +237,45 @@ def verify_consistent_responses(num_expected_flags: int) -> None:
== "v2-segment-override-value"
)

# finally, let's test that we can revert the v2 versioning, and we still get the
# same response
disable_versioning_url = reverse(
"api-v1:environments:environment-disable-v2-versioning",
args=[environment_api_key],
)
environment_update_response = admin_client.post(disable_versioning_url)
assert environment_update_response.status_code == status.HTTP_202_ACCEPTED

time.sleep(0.5)

environment_flags_response_after_revert = get_environment_flags_response_json(
num_expected_flags=2
)
identity_flags_response_after_revert = get_identity_flags_response_json(
num_expected_flags=2
)

# Verify that the environment flags have the same state / value
environment_flag_tuples_pre_revert = {
(f["enabled"], f["feature_state_value"], f["feature"]["id"])
for f in environment_flags_response_json_after_publish
}
environment_flag_tuples_post_revert = {
(f["enabled"], f["feature_state_value"], f["feature"]["id"])
for f in environment_flags_response_after_revert
}
assert environment_flag_tuples_pre_revert == environment_flag_tuples_post_revert

identity_flag_tuples_pre_revert = {
(f["enabled"], f["feature_state_value"], f["feature"]["id"])
for f in identity_flags_response_json_after_publish["flags"]
}
identity_flag_tuples_post_revert = {
(f["enabled"], f["feature_state_value"], f["feature"]["id"])
for f in identity_flags_response_after_revert["flags"]
}
assert identity_flag_tuples_pre_revert == identity_flag_tuples_post_revert


def test_v2_versioning_mv_feature(
admin_client: "APIClient",
Expand Down
2 changes: 1 addition & 1 deletion api/tests/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@
[list[str], int | None], UserOrganisationPermission
]
WithEnvironmentPermissionsCallable = Callable[
[list[str], int | None], UserEnvironmentPermission
[list[str], int | None, bool], UserEnvironmentPermission
]
89 changes: 88 additions & 1 deletion api/tests/unit/features/versioning/test_unit_versioning_tasks.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
from pytest_mock import MockerFixture

from environments.identities.models import Identity
from environments.models import Environment
from features.models import Feature
from features.models import Feature, FeatureSegment, FeatureState
from features.versioning.models import EnvironmentFeatureVersion
from features.versioning.tasks import (
disable_v2_versioning,
enable_v2_versioning,
trigger_update_version_webhooks,
)
from features.versioning.versioning_service import (
get_environment_flags_queryset,
)
from segments.models import Segment
from users.models import FFAdminUser
from webhooks.webhooks import WebhookEventType


Expand All @@ -28,6 +35,86 @@ def test_enable_v2_versioning(
assert environment.use_v2_feature_versioning is True


def test_disable_v2_versioning(
environment_v2_versioning: Environment,
feature: Feature,
segment: Segment,
staff_user: FFAdminUser,
identity: Identity,
) -> None:
# Given
# First, let's create a new version for the given feature which we'll also add a segment override to
v2 = EnvironmentFeatureVersion.objects.create(
environment=environment_v2_versioning, feature=feature
)

v2_environment_flag = v2.feature_states.filter(feature=feature).first()
v2_environment_flag.enabled = True
v2_environment_flag.save()

FeatureState.objects.create(
feature_segment=FeatureSegment.objects.create(
environment=environment_v2_versioning,
feature=feature,
segment=segment,
environment_feature_version=v2,
),
feature=feature,
environment=environment_v2_versioning,
enabled=True,
environment_feature_version=v2,
)

v2.publish(staff_user)

# Now, let's create a new version which we won't publish (and hence should be ignored after we disabled
# v2 versioning)
v3 = EnvironmentFeatureVersion.objects.create(
environment=environment_v2_versioning, feature=feature
)

v3_environment_flag = v3.feature_states.filter(feature=feature).first()
v3_environment_flag.enabled = False
v3_environment_flag.save()

# Let's also create an identity override to confirm it is not affected.
FeatureState.objects.create(
identity=identity,
feature=feature,
enabled=True,
environment=environment_v2_versioning,
)

# When
disable_v2_versioning(environment_v2_versioning.id)
environment_v2_versioning.refresh_from_db()

# Then
latest_feature_states = get_environment_flags_queryset(
environment=environment_v2_versioning
)

assert latest_feature_states.count() == 3
assert (
latest_feature_states.filter(
feature=feature, feature_segment__isnull=True, identity__isnull=True
)
.first()
.enabled
is True
)
assert (
latest_feature_states.filter(feature=feature, feature_segment__segment=segment)
.first()
.enabled
is True
)
assert (
latest_feature_states.filter(feature=feature, identity=identity).first().enabled
is True
)


def test_trigger_update_version_webhooks(
environment_v2_versioning: Environment, feature: Feature, mocker: MockerFixture
) -> None:
Expand Down
22 changes: 22 additions & 0 deletions api/tests/unit/features/versioning/test_unit_versioning_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -582,3 +582,25 @@ def test_filter_versions_by_is_live(
assert not_live_versions_response_json["results"][0]["uuid"] == str(
unpublished_environment_feature_version.uuid
)


def test_disable_v2_versioning_returns_bad_request_if_not_using_v2_versioning(
environment: Environment,
staff_client: "APIClient",
with_environment_permissions: WithEnvironmentPermissionsCallable,
) -> None:
# Given
url = reverse(
"api-v1:environments:environment-disable-v2-versioning",
args=[environment.api_key],
)

with_environment_permissions(
permission_keys=[], environment_id=environment.id, admin=True
)

# When
response = staff_client.post(url)

# Then
assert response.status_code == status.HTTP_400_BAD_REQUEST

0 comments on commit da9e051

Please sign in to comment.