Skip to content

Commit

Permalink
feat: Feature Versioning V2 (#2382)
Browse files Browse the repository at this point in the history
  • Loading branch information
matthewelwell committed Nov 21, 2023
1 parent 0135570 commit bcfb10e
Show file tree
Hide file tree
Showing 64 changed files with 3,355 additions and 384 deletions.
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'),
),
]

1 comment on commit bcfb10e

@vercel
Copy link

@vercel vercel bot commented on bcfb10e Nov 21, 2023

Choose a reason for hiding this comment

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

Please sign in to comment.