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: Feature Versioning V2 #2382

Merged
merged 65 commits into from
Nov 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
f88c94f
WIP: V2 Feature Versioning implementation
matthewelwell Mar 23, 2022
716cc36
WIP: add views
matthewelwell Jun 9, 2023
e8f2d6c
WIP: integration test
matthewelwell Jun 21, 2023
ed30111
Merge branch 'main' into feature/1448/feature-versioning
matthewelwell Jun 28, 2023
b39e252
WIP: continue working on integration test and getting admin endpoints…
matthewelwell Jun 28, 2023
7483cda
Small refactor
matthewelwell Jun 30, 2023
744d8da
Get identities working with new versioning
matthewelwell Jun 30, 2023
ef58539
Merge branch 'main' into feature/1448/feature-versioning
matthewelwell Jul 5, 2023
98ee92f
Fix migrations
matthewelwell Jul 5, 2023
70c83d8
Refactoring tests / adding typing
matthewelwell Jul 5, 2023
d5be5d6
Fix tests
matthewelwell Jul 5, 2023
ea39474
Merge branch 'main' into feature/1448/feature-versioning
matthewelwell Jul 7, 2023
8565799
Additional refactoring + fixing post merge
matthewelwell Jul 12, 2023
c40ac48
Merge branch 'main' into feature/1448/feature-versioning
matthewelwell Jul 26, 2023
366ccff
Merge branch 'main' into feature/1448/feature-versioning
matthewelwell Jul 26, 2023
a60a752
Ensure that environment feature version is created when new environme…
matthewelwell Jul 26, 2023
ca084f5
Add export functionality
matthewelwell Jul 26, 2023
8247cb7
Add feature as read only
matthewelwell Jul 26, 2023
4bf3113
Merge branch 'main' into feature/1448/feature-versioning
matthewelwell Aug 2, 2023
68ab146
Add more unit tests
matthewelwell Aug 2, 2023
e348318
Add view tests and update logic
matthewelwell Aug 2, 2023
87e3076
Remove TODO
matthewelwell Aug 4, 2023
9ed8709
Remove unnecessary perform_update method
matthewelwell Aug 4, 2023
2157e8a
Add permissioning
matthewelwell Aug 4, 2023
6c5daa6
Merge branch 'main' into feature/1448/feature-versioning
matthewelwell Aug 8, 2023
f7f2bc0
skip creating audit log if feature state has environment feature version
matthewelwell Aug 8, 2023
7a02c54
Fix tests
matthewelwell Aug 9, 2023
591d038
Ensure that environment document is rebuild when a version is published
matthewelwell Aug 9, 2023
9a9107e
Ensure that existing feature states are cloned correctly
matthewelwell Aug 9, 2023
6225051
Tidy up local imports
matthewelwell Aug 9, 2023
aaebd16
Tidy up type hint
matthewelwell Aug 9, 2023
8934618
Prevent reverting v2 feature versioning
matthewelwell Aug 9, 2023
6f603e4
Remove unnecessary comment
matthewelwell Aug 9, 2023
63cd697
Remove TODO
matthewelwell Aug 9, 2023
a0ce8de
Add tests for creating initial feature versions
matthewelwell Aug 9, 2023
7e4471e
Remove hack for metadata validation
matthewelwell Aug 9, 2023
b517b3e
Merge branch 'main' into feature/1448/feature-versioning
matthewelwell Aug 10, 2023
780476c
Fix docs
matthewelwell Aug 10, 2023
04ac71e
Allow null feature segment
matthewelwell Aug 10, 2023
e93622a
Minor PR review tweaks
matthewelwell Aug 14, 2023
efeca55
Move from sha to uuid
matthewelwell Aug 15, 2023
e8c6af5
Use view to store orm objects, instead of request
matthewelwell Aug 15, 2023
2830c11
Rename to versioning_service
matthewelwell Aug 18, 2023
df144a1
Add description field and ordering
matthewelwell Aug 21, 2023
7b5ddda
test: add more tests for feature versioning logic (#2656)
matthewelwell Aug 23, 2023
3cbd6a2
Merge branch 'main' into feature/1448/feature-versioning
matthewelwell Aug 23, 2023
df88ffd
Merge branch 'main' into feature/1448/feature-versioning
matthewelwell Aug 25, 2023
a537162
Fix test
matthewelwell Aug 25, 2023
9dac028
Merge branch 'main' into feature/1448/feature-versioning
matthewelwell Aug 30, 2023
2909ed4
Add additional logic for cloning feature segments
matthewelwell Aug 30, 2023
8d112b4
Fix feature segment clone
matthewelwell Aug 30, 2023
709cf5e
feat: add change request logic to feature versioning (#2681)
matthewelwell Sep 6, 2023
247b61b
fix: create initial versions logic and correct filters for get enviro…
matthewelwell Sep 6, 2023
892279c
Ensure that created_by is added to the EnvironmentFeatureVersion
matthewelwell Sep 8, 2023
c1c97e7
Merge branch 'main' into feature/1448/feature-versioning
matthewelwell Sep 12, 2023
9be410b
Merge branch 'main' into feature/1448/feature-versioning
matthewelwell Oct 18, 2023
7166e5d
feat: add webhooks to v2 feature versioning (#2763)
matthewelwell Oct 24, 2023
ec4c6c8
feat(versioning-v2): add published_at (#2883)
matthewelwell Oct 25, 2023
6b6da3d
Merge branch 'main' into feature/1448/feature-versioning
matthewelwell Nov 3, 2023
2f13c12
Merge branch 'main' into feature/1448/feature-versioning
matthewelwell Nov 3, 2023
2556492
Merge branch 'main' into feature/1448/feature-versioning
matthewelwell Nov 15, 2023
eec46cf
Merge branch 'main' into feature/1448/feature-versioning
matthewelwell Nov 15, 2023
a2eca18
Merge branch 'main' into feature/1448/feature-versioning
matthewelwell Nov 21, 2023
5226951
Remove coverage files
matthewelwell Nov 21, 2023
2ec04b8
Move index migration
matthewelwell Nov 21, 2023
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
1 change: 1 addition & 0 deletions api/api/urls/v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
SDKEnvironmentAPIView.as_view(),
name="environment-document",
),
url("", include("features.versioning.urls", namespace="versioning")),
# API documentation
url(
r"^swagger(?P<format>\.json|\.yaml)$",
Expand Down
1 change: 1 addition & 0 deletions api/app/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@
"environments.identities.traits",
"features",
"features.multivariate",
"features.versioning",
"features.workflows.core",
"segments",
"app",
Expand Down
3 changes: 3 additions & 0 deletions api/audit/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ def create_audit_log_from_historical_record(
user_model = get_user_model()

instance = history_instance.instance
if instance.get_skip_create_audit_log():
return

history_user = user_model.objects.filter(id=history_user_id).first()

override_author = instance.get_audit_log_author(history_instance)
Expand Down
7 changes: 7 additions & 0 deletions api/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from features.models import Feature, FeatureSegment, FeatureState
from features.multivariate.models import MultivariateFeatureOption
from features.value_types import STRING
from features.versioning.tasks import enable_v2_versioning
from features.workflows.core.models import ChangeRequest
from metadata.models import (
Metadata,
Expand Down Expand Up @@ -224,6 +225,12 @@ def _with_project_permissions(
return _with_project_permissions


@pytest.fixture()
def environment_v2_versioning(environment):
enable_v2_versioning(environment.id)
return environment


@pytest.fixture()
def identity(environment):
return Identity.objects.create(identifier="test_identity", environment=environment)
Expand Down
3 changes: 3 additions & 0 deletions api/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ class _AbstractBaseAuditableModel(models.Model):
class Meta:
abstract = True

def get_skip_create_audit_log(self) -> bool:
return False

def get_create_log_message(self, history_instance) -> typing.Optional[str]:
"""Override if audit log records should be written when model is created"""
return None
Expand Down
26 changes: 15 additions & 11 deletions api/environments/identities/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,21 +74,25 @@ def get_all_feature_states(
feature_segment__environment=self.environment,
)
environment_default_query = Q(identity=None, feature_segment=None)
only_live_versions_query = Q(
live_from__lte=timezone.now(), version__isnull=False
)

# define the full query
full_query = (
only_live_versions_query
& belongs_to_environment_query
& (
overridden_for_identity_query
| overridden_for_segment_query
| environment_default_query
)
full_query = belongs_to_environment_query & (
overridden_for_identity_query
| overridden_for_segment_query
| environment_default_query
)

if self.environment.use_v2_feature_versioning:
full_query &= Q(
Q(identity=self) # identity overrides are not versioned
| Q(
environment_feature_version__live_from__isnull=False,
environment_feature_version__live_from__lte=timezone.now(),
),
)
else:
full_query &= Q(live_from__lte=timezone.now(), version__isnull=False)

if additional_filters:
full_query &= additional_filters

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 3.2.23 on 2023-11-21 10:23

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('environments', '0032_rename_use_mv_v2_evaluation_to_use_in_percentage_split_evaluation'),
]

operations = [
migrations.AddField(
model_name='environment',
name='use_v2_feature_versioning',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='historicalenvironment',
name='use_v2_feature_versioning',
field=models.BooleanField(default=False),
),
]
19 changes: 9 additions & 10 deletions api/environments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
AFTER_CREATE,
AFTER_SAVE,
AFTER_UPDATE,
BEFORE_UPDATE,
LifecycleModel,
hook,
)
Expand All @@ -41,6 +42,7 @@
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 segments.models import Segment
from util.mappers import map_environment_to_environment_document
Expand Down Expand Up @@ -124,27 +126,24 @@ class Environment(

objects = EnvironmentManager()

use_v2_feature_versioning = models.BooleanField(default=False)

class Meta:
ordering = ["id"]

@hook(AFTER_CREATE)
def create_feature_states(self):
features = self.project.features.all()
for feature in features:
FeatureState.objects.create(
feature=feature,
environment=self,
identity=None,
enabled=False
if self.project.prevent_flag_defaults
else feature.default_enabled,
)
FeatureState.create_initial_feature_states_for_environment(environment=self)

@hook(AFTER_UPDATE)
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.")

def __str__(self):
return "Project %s - Environment %s" % (self.project.name, self.name)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Generated by Django 3.2.23 on 2023-11-21 10:23

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('environment_permissions', '0008_add_manage_segment_overrides_permission'),
]

operations = [
migrations.AlterField(
model_name='userenvironmentpermission',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='environment_permissions', to=settings.AUTH_USER_MODEL),
),
]
6 changes: 5 additions & 1 deletion api/environments/permissions/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ class Meta:


class UserEnvironmentPermission(AbstractBasePermissionModel):
user = models.ForeignKey("users.FFAdminUser", on_delete=models.CASCADE)
user = models.ForeignKey(
"users.FFAdminUser",
on_delete=models.CASCADE,
related_name="environment_permissions",
)
environment = models.ForeignKey(
Environment, on_delete=models.CASCADE, related_query_name="userpermission"
)
Expand Down
2 changes: 2 additions & 0 deletions api/environments/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ class Meta:
"use_mv_v2_evaluation",
"use_identity_composite_key_for_hashing",
"hide_sensitive_data",
"use_v2_feature_versioning",
)
read_only_fields = ("use_v2_feature_versioning",)

def get_use_mv_v2_evaluation(self, instance: Environment) -> bool:
"""
Expand Down
16 changes: 15 additions & 1 deletion api/environments/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,20 @@
from django.db.models import Count
from django.utils.decorators import method_decorator
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from drf_yasg.utils import no_body, swagger_auto_schema
from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response

from environments.permissions.permissions import (
EnvironmentAdminPermission,
EnvironmentPermissions,
NestedEnvironmentPermissions,
)
from features.versioning.tasks import enable_v2_versioning
from permissions.permissions_calculator import get_environment_permission_data
from permissions.serializers import (
PermissionModelSerializer,
Expand Down Expand Up @@ -203,6 +205,18 @@ def user_permissions(self, request, *args, **kwargs):
def get_document(self, request, api_key: str):
return Response(Environment.get_environment_document(api_key))

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


class NestedEnvironmentViewSet(viewsets.GenericViewSet):
model_class = None
Expand Down
33 changes: 27 additions & 6 deletions api/features/feature_segments/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,38 +93,59 @@ def validate(self, attrs):
FeatureSegment.objects.filter(
id__in=[item["id"] for item in validated_attrs]
)
.select_related("environment")
.prefetch_related("feature_states")
)

if not len(feature_segments) == len(attrs):
raise serializers.ValidationError(
"Some of the provided ids were not found."
)

environments = set()
features = set()
environment_ids = set()
feature_ids = set()

for feature_segment in feature_segments:
environments.add(feature_segment.environment)
features.add(feature_segment.feature)
environment_ids.add(feature_segment.environment_id)
feature_ids.add(feature_segment.feature_id)

if not len(environments) == len(features) == 1:
if not len(environment_ids) == len(feature_ids) == 1:
raise serializers.ValidationError(
"All feature segments must belong to the same feature & environment."
)

environment = environments.pop()
environment = feature_segments[0].environment

if not self.context["request"].user.has_environment_permission(
MANAGE_SEGMENT_OVERRIDES, environment
):
raise PermissionDenied("You do not have permission to perform this action.")

if environment.use_v2_feature_versioning:
self._validate_unique_environment_feature_version(feature_segments)

return validated_attrs

def create(self, validated_data):
id_priority_pairs = FeatureSegment.to_id_priority_tuple_pairs(validated_data)
return FeatureSegment.update_priorities(id_priority_pairs)

@staticmethod
def _validate_unique_environment_feature_version(
feature_segments: list[FeatureSegment],
) -> None:
feature_states = []
for feature_segment in feature_segments:
feature_states.extend(feature_segment.feature_states.all())
unique_versions = {
feature_state.environment_feature_version_id
for feature_state in feature_states
}
if not len(unique_versions) == 1:
raise serializers.ValidationError(
"All feature segments must be associated with the same environment feature version."
)


class FeatureSegmentChangePrioritiesSerializer(serializers.ModelSerializer):
priority = serializers.IntegerField(
Expand Down
33 changes: 33 additions & 0 deletions api/features/features_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import typing

from features.dataclasses import EnvironmentFeatureOverridesData
from features.versioning.versioning_service import get_environment_flags_list

if typing.TYPE_CHECKING:
from environments.models import Environment


def get_overrides_data(
environment: "Environment",
) -> typing.Dict[int, EnvironmentFeatureOverridesData]:
"""
Get the number of identity / segment overrides in a given environment for each feature in the
project.

:param environment: the environment to get the overrides data for
:return: dictionary of {feature_id: EnvironmentFeatureOverridesData}
"""
environment_feature_states_list = get_environment_flags_list(environment)
all_overrides_data = {}

for feature_state in environment_feature_states_list:
env_feature_overrides_data = all_overrides_data.setdefault(
feature_state.feature_id, EnvironmentFeatureOverridesData()
)
if feature_state.feature_segment_id:
env_feature_overrides_data.num_segment_overrides += 1
elif feature_state.identity_id:
env_feature_overrides_data.add_identity_override()
all_overrides_data[feature_state.feature_id] = env_feature_overrides_data

return all_overrides_data
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Generated by Django 3.2.23 on 2023-11-21 10:23

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('feature_versioning', '0001_add_environment_feature_state_version_logic'),
('environments', '0033_add_environment_feature_state_version_logic'),
('segments', '0019_add_audit_to_condition'),
('features', '0060_feature_group_owners'),
]

operations = [
migrations.AddField(
model_name='featuresegment',
name='environment_feature_version',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='feature_segments', to='feature_versioning.environmentfeatureversion'),
),
migrations.AddField(
model_name='featurestate',
name='environment_feature_version',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feature_states', to='feature_versioning.environmentfeatureversion'),
),
migrations.AddField(
model_name='historicalfeaturesegment',
name='environment_feature_version',
field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='feature_versioning.environmentfeatureversion'),
),
migrations.AddField(
model_name='historicalfeaturestate',
name='environment_feature_version',
field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='feature_versioning.environmentfeatureversion'),
),
]
Loading