Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions api/experimentation/feature_state_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@
from features.models import FeatureState


def has_live_experiment(feature_id: int) -> bool:
# Multivariate options belong to the feature, not an environment, so an
# experiment running in any environment locks them.
return bool(
Experiment.objects.filter(
feature_id=feature_id,
status=ExperimentStatus.RUNNING,
).exists()
)


def get_feature_state_metadata_builder(
environment: "Environment",
) -> Callable[["FeatureState"], dict[str, Any] | None]:
Expand Down
5 changes: 4 additions & 1 deletion api/experimentation/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@
from features.feature_states.models import API_VALUE_TYPES
from features.future.services import delete_segment_override, update_flag
from features.future.types import SegmentOverrideRequest
from features.models import FeatureState
from features.models import Feature, FeatureState
from features.value_types import BOOLEAN, STRING
from features.versioning.versioning_service import get_environment_flags_list
from integrations.flagsmith.client import get_openfeature_client
Expand Down Expand Up @@ -617,6 +617,9 @@ def transition_experiment_status(

experiment.status = target_status
if target_status == ExperimentStatus.RUNNING and not experiment.started_at:
Feature.objects.select_for_update().filter(
pk=experiment.feature_id
).exists()
Comment on lines +620 to +622

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor · Functional Correctness · Quick win

Code analysis · TLA+ model checking

Script executed:

rg -n 'VALID_STATUS_TRANSITIONS|started_at|ExperimentStatus.PAUSED|transition_experiment_status' api/experimentation; sed -n '605,632p' api/experimentation/services.py

Repository: Flagsmith/flagsmith

Length of output: 3106


tla executed:

---- MODULE ReviewModel ----
EXTENDS Naturals
(* Assumption to verify: resuming a paused experiment retains started_at,
   so the new feature lock is skipped. Writer phases represent its feature
   lock, live check, and save. A violation records a save while running.
TLC
INIT Init
NEXT Next
INVARIANT Safe
*)
VARIABLES live, owner, writer, resumed, violation
vars == <<live, owner, writer, resumed, violation>>
Init == /\ live = FALSE /\ owner = "none" /\ writer = "waiting"
        /\ resumed = FALSE /\ violation = FALSE
Check == /\ writer = "waiting" /\ owner = "none"
         /\ owner' = "writer"
         /\ writer' = (IF live THEN "rejected" ELSE "ready")
         /\ UNCHANGED <<live, resumed, violation>>
Resume == /\ ~resumed /\ live' = TRUE /\ resumed' = TRUE
          /\ UNCHANGED <<owner, writer, violation>>
Save == /\ writer = "ready" /\ writer' = "done"
        /\ owner' = "none" /\ violation' = live
        /\ UNCHANGED <<live, resumed>>
Reject == /\ writer = "rejected" /\ writer' = "done"
          /\ owner' = "none" /\ UNCHANGED <<live, resumed, violation>>
Idle == /\ writer = "done" /\ resumed /\ UNCHANGED vars
Next == Check \/ Resume \/ Save \/ Reject \/ Idle
Safe == ~violation
====

Length of output: 3972


Script executed:

git diff 13539afcb99c20b4351e26f215d01b49f2a9be03 d564c8d053d14408261767f8c48432435b3adc51 -- api/experimentation/services.py api/features/multivariate/views.py api/features/serializers.py
nl -ba api/features/multivariate/views.py | sed -n '1,180p'
nl -ba api/experimentation/models.py | sed -n '125,220p'
nl -ba api/experimentation/views.py | sed -n '445,515p;580,655p'
nl -ba api/experimentation/services.py | sed -n '570,645p'
rg -n -F -e 'has_live_experiment' -e 'live_experiment' -e 'transition_experiment_status(' -- api/experimentation api/features api/tests/unit/features/multivariate

Repository: Flagsmith/flagsmith

Length of output: 33851


Script executed:

nl -ba api/experimentation/feature_state_metadata.py | sed -n '1,130p'
rg -n -F -e 'def enable_experiment_rollout' -e 'def _enable_experiment_rollout' -e 'LIVE_EXPERIMENT' -- api/experimentation api/features
rg -n -F -e 'paused' -e 'PAUSED' -- api/tests/unit/features/multivariate/test_unit_multivariate_live_experiment.py api/tests/unit/experimentation

Repository: Flagsmith/flagsmith

Length of output: 4823


Script executed:

nl -ba api/experimentation/services.py | sed -n '1110,1235p'
nl -ba api/features/multivariate/models.py | sed -n '1,240p'
nl -ba api/features/multivariate/serializers.py | sed -n '1,220p'
rg -n -F -e 'multivariate_feature_option' -e 'multivariate_options' -- api/experimentation/services.py api/experimentation/serializers.py
nl -ba api/tests/unit/experimentation/test_experiment_views.py | sed -n '550,620p'
rg -n -F -e 'EXPERIMENTATION_ENABLED' -- api/app/settings/common.py api/experimentation/permissions.py

Repository: Flagsmith/flagsmith

Length of output: 24983


Script executed:

nl -ba api/experimentation/services.py | sed -n '400,465p;900,950p;990,1050p'
nl -ba api/experimentation/permissions.py | sed -n '1,80p'
nl -ba api/tests/unit/experimentation/test_experiment_views.py | sed -n '619,700p'
rg -n -F -e 'multivariate_feature_option.get' -e 'multivariate_feature_option.' -- api/features api/evaluation
rg -n -F -e 'EXPERIMENTATION_ENABLED' -- api/app/settings docker-compose.yml docker-compose.yaml helm charts 2>/dev/null || test "$?" -eq 1

Repository: Flagsmith/flagsmith

Length of output: 13459


Script executed:

rg -n -F -e 'def create_experiment_audit_log' -e 'class AuditLog(' -e 'def apply_experiment_rollout' -- api/experimentation api/audit
nl -ba api/experimentation/services.py | sed -n '515,575p;1030,1110p'
nl -ba api/features/versioning/schemas.py | sed -n '1,85p'
nl -ba docker-compose.yml | sed -n '20,45p'
rg -n -F -e 'get_experiment_rollout' -e '_expected_variant_shares' -- api/experimentation/services.py

Repository: Flagsmith/flagsmith

Length of output: 10425


Web query:

PostgreSQL foreign key update unchanged key skip referential integrity trigger RI_FKey_fk_upd_check_required select for key share

Result:

Sources consulted:
- postgres/src/backend/utils/adt/ri_triggers.c at master · postgres/postgres · GitHub — https://github.com/postgres/postgres/blob/master/src/backend/utils/adt/ri_triggers.c
- PostgreSQL Source Code: src/backend/utils/adt/ri_triggers.c Source File — https://doxygen.postgresql.org/ri__triggers_8c_source.html
- PostgreSQL Source Code: src/backend/utils/adt/ri_triggers.c File Reference — https://doxygen.postgresql.org/ri__triggers_8c.html
- …and 27 more

Script executed:

nl -ba docker-compose.yml | sed -n '1,20p;44,70p'
rg -n -F -e 'postgres:' -e 'postgresql:' -- docker-compose.yml api/pyproject.toml api/Dockerfile

Repository: Flagsmith/flagsmith

Length of output: 2589


Script executed:

python3 - <<'PY'
import urllib.request
url='https://raw.githubusercontent.com/postgres/postgres/REL_15_5/src/backend/utils/adt/ri_triggers.c'
try:
    text=urllib.request.urlopen(url, timeout=15).read().decode()
except Exception as e:
    print('FETCH FAILED:',type(e).__name__,str(e))
else:
    lines=text.splitlines()
    for i,line in enumerate(lines):
        if line.startswith('RI_FKey_fk_upd_check_required('):
            print(url)
            for j in range(i-12, min(i+85,len(lines))):
                print(f'{j+1}: {lines[j]}')
            break
    else:
        print('Function not found')
PY

Repository: Flagsmith/flagsmith

Length of output: 3544


Lock the feature when resuming an experiment.

If an option update passes its check while an experiment is paused, a concurrent resume can commit before the update. The option change can then commit while the experiment is running. Acquire the feature lock for every transition to RUNNING.

experiment.started_at = timezone.now()
_remove_superseded_experiment_rollouts(
experiment, AuthorData.from_user(user)
Expand Down
3 changes: 3 additions & 0 deletions api/features/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
# Multivariate variant reported when an identity falls through to the control value
CONTROL_VARIANT_KEY = "control"
RESERVED_VARIANT_KEY_MESSAGE = f'"{CONTROL_VARIANT_KEY}" is a reserved variant key.'
LIVE_EXPERIMENT_MV_OPTIONS_MESSAGE = (
"Multivariate options cannot be changed while the feature is in a live experiment."
)

# Tag filtering strategy
UNION = "UNION"
Expand Down
5 changes: 5 additions & 0 deletions api/features/import_export/services.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from django.core.exceptions import ValidationError
from django.db.models import Q

from environments.models import Environment
from experimentation.feature_state_metadata import has_live_experiment
from features.constants import LIVE_EXPERIMENT_MV_OPTIONS_MESSAGE
from features.import_export.types import FeatureExportData
from features.models import Feature, FeatureSegment, FeatureState
from features.multivariate.models import MultivariateFeatureOption
Expand All @@ -15,6 +18,8 @@ def overwrite_feature_for_environment(
Apply a destructive feature import to a single environment without
affecting other environments' feature states or the Feature definition.
"""
if has_live_experiment(existing_feature.id):
raise ValidationError(LIVE_EXPERIMENT_MV_OPTIONS_MESSAGE)
Comment on lines +21 to +22

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Major · Functional Correctness · Quick win

Supported by static analysis

Script executed:

sed -n '1,110p' api/features/import_export/services.py; rg -n 'OVERWRITE_DESTRUCTIVE|live experiment|running experiment' api/features/import_export api/tests/unit/features/import_export docs

Repository: Flagsmith/flagsmith

Length of output: 6848


Script executed:

git diff 13539afcb99c20b4351e26f215d01b49f2a9be03 d564c8d053d14408261767f8c48432435b3adc51 -- api/features/import_export/services.py api/features/import_export/tasks.py api/tests/unit/features/import_export/test_unit_features_import_export_tasks.py
nl -ba api/features/import_export/services.py
nl -ba api/features/import_export/tasks.py | sed -n '1,230p'
nl -ba api/tests/unit/features/import_export/test_unit_features_import_export_tasks.py | sed -n '1,180p'
rg -n -F -e 'overwrite_feature_for_environment' -e 'OVERWRITE_DESTRUCTIVE' -e 'Overwrite Destructive' -- api/features/import_export api/tests/unit/features/import_export docs frontend

Repository: Flagsmith/flagsmith

Length of output: 33801


Script executed:

nl -ba api/experimentation/feature_state_metadata.py
nl -ba docs/docs/administration-and-security/data-management/bulk-import-and-export.md | sed -n '20,65p'
rg -n -e '^class (FeatureState|FeatureStateValue|FeatureSegment|MultivariateFeatureStateValue|MultivariateFeatureOption)' -e 'def save\(' api/features/models.py api/features/multivariate/models.py
rg -n -F -e 'FeatureStateValue' -e 'MultivariateFeatureStateValue' -e 'FeatureSegment' api/features/signals.py api/features/multivariate api/features/apps.py
nl -ba api/tests/unit/features/import_export/test_unit_features_import_export_tasks.py | sed -n '460,760p'
nl -ba api/features/import_export/serializers.py | sed -n '1,130p'
rg -n -F -e 'LIVE_EXPERIMENT_MV_OPTIONS_MESSAGE' -e 'def get_environment_flags_list' api/features

Repository: Flagsmith/flagsmith

Length of output: 26237


Script executed:

nl -ba api/features/multivariate/models.py | sed -n '1,200p'
nl -ba api/features/models.py | sed -n '248,390p;483,710p;1159,1270p'
nl -ba api/features/signals.py | sed -n '1,260p'
nl -ba api/features/versioning/versioning_service.py | sed -n '1,125p'
rg -n -F -e 'get_environment_flags_list(' -e 'get_live_feature_states(' -e 'feature_state_value' api/evaluation api/util/mappers/sdk.py
nl -ba api/features/constants.py | sed -n '1,25p'
nl -ba api/features/import_export/views.py | sed -n '1,180p'

Repository: Flagsmith/flagsmith

Length of output: 43530


Script executed:

nl -ba api/evaluation/mappers.py | sed -n '185,240p;285,325p;365,425p'
rg -n -e '@hook' -e '^    def ' api/features/models.py | sed -n '35,120p'
nl -ba api/features/models.py | sed -n '745,1020p'
rg -n -F -e 'map_environment' -e 'map_feature_state' api/evaluation/services.py api/evaluation/mappers.py

Repository: Flagsmith/flagsmith

Length of output: 24140


Allow isolated imports into other environments.

If an experiment runs in environment A, a destructive import into environment B fails even when it reuses all existing options. The entire import batch rolls back. Allow this case while still blocking new options and destructive imports into an environment with a running experiment.


Major · Functional Correctness · Heavy lift

Code analysis · TLA+ model checking

Script executed:

rg -n 'overwrite_feature_for_environment|select_for_update|ATOMIC_REQUESTS|isolation_level' api/features/import_export api/experimentation api/app; sed -n '12,84p' api/features/import_export/services.py; sed -n '134,175p' api/features/import_export/tasks.py

Repository: Flagsmith/flagsmith

Length of output: 5261


tla executed:

---- MODULE ReviewModel ----
(* The import takes no feature lock. Start abstracts a complete first-start
   transaction, including its feature lock. It can finish between the import
   check and write. Violation means the import writes while running.
TLC
INIT Init
NEXT Next
INVARIANT Safe
*)
VARIABLES live, phase, started, violation
vars == <<live, phase, started, violation>>
Init == /\ live = FALSE /\ phase = "check"
        /\ started = FALSE /\ violation = FALSE
Check == /\ phase = "check"
         /\ phase' = (IF live THEN "done" ELSE "write")
         /\ UNCHANGED <<live, started, violation>>
Start == /\ ~started /\ started' = TRUE /\ live' = TRUE
         /\ UNCHANGED <<phase, violation>>
Write == /\ phase = "write" /\ phase' = "done"
         /\ violation' = live /\ UNCHANGED <<live, started>>
Idle == /\ phase = "done" /\ started /\ UNCHANGED vars
Next == Check \/ Start \/ Write \/ Idle
Safe == ~violation
====

Length of output: 3770


Script executed:

git diff 13539afcb99c20b4351e26f215d01b49f2a9be03 d564c8d053d14408261767f8c48432435b3adc51 -- api/features/import_export/services.py api/features/import_export/tasks.py api/experimentation/services.py api/features/multivariate/views.py
nl -ba api/features/import_export/services.py | sed -n '1,140p'
nl -ba api/features/import_export/tasks.py | sed -n '105,180p'
nl -ba api/experimentation/services.py | sed -n '40,115p;550,710p'
rg -n -F -e 'overwrite_feature_for_environment(' -e 'has_live_experiment(' -- api/
rg -n -F -e 'FeatureSegment' -e 'experiment' -- api/experimentation/models.py api/experimentation/services.py | sed -n '1,180p'

Repository: Flagsmith/flagsmith

Length of output: 42554


Script executed:

nl -ba api/experimentation/feature_state_metadata.py | sed -n '1,110p'
nl -ba api/experimentation/services.py | sed -n '410,475p;860,910p;910,1105p'
nl -ba api/experimentation/models.py | sed -n '125,210p'
rg -n -F -e 'class FeatureSegment' -e 'class FeatureState' -e 'feature_segment = ' -- api/features/models.py
rg -n -F -e 'apply_experiment_rollout(' -e 'transition_experiment_status(' -- api/experimentation api/tests/unit/experimentation
rg -n -F -e 'rollout_segment' -e 'feature_segment' -- api/features/versioning/versioning_service.py api/experimentation/views.py
nl -ba api/app/settings/common.py | sed -n '190,220p;265,295p'

Repository: Flagsmith/flagsmith

Length of output: 29115


Script executed:

nl -ba api/features/models.py | sed -n '240,360p;480,580p'
nl -ba api/experimentation/serializers.py | sed -n '335,425p'
nl -ba api/experimentation/views.py | sed -n '480,540p;590,645p'
nl -ba api/features/versioning/versioning_service.py | sed -n '100,155p;175,225p'
nl -ba api/tests/unit/features/import_export/test_unit_features_import_export_tasks.py | sed -n '1,180p'
rg -n -F -e 'get_feature_state_metadata_builder' -e 'in_experiment' -- api/ | sed -n '1,140p'
git show 13539afcb99c20b4351e26f215d01b49f2a9be03:api/features/import_export/services.py | sed -n '1,90p'

Repository: Flagsmith/flagsmith

Length of output: 36085


Script executed:

rg -n -F -e 'def enable_experiment_rollout' -e 'def get_environment_flags_list' -e 'def get_environment_document' -- api/experimentation api/features/versioning api/util/mappers
nl -ba api/experimentation/services.py | sed -n '1095,1195p'
nl -ba api/util/mappers/engine.py | sed -n '160,255p'
nl -ba api/features/versioning/versioning_service.py | sed -n '1,110p'
nl -ba api/tests/unit/environments/test_unit_environments_views_sdk_environment.py | sed -n '115,180p'
rg -n -F -e 'rebuild_environment_document' -e 'AFTER_DELETE' -- api/features/models.py api/features/signals.py api/features/import_export/ 2>/dev/null || test "$?" -eq 1

Repository: Flagsmith/flagsmith

Length of output: 16309


Script executed:

rg -n -F -e 'postgres:' -e 'postgresql:' -e 'POSTGRES_VERSION' -- docker-compose.yml docker-compose.yaml .github api 2>/dev/null || test "$?" -eq 1

Repository: Flagsmith/flagsmith

Length of output: 1197


Web query:

PostgreSQL documentation explicit locking SELECT FOR UPDATE ordinary SELECT does not block row locks READ COMMITTED

Result:

Sources consulted:
- PostgreSQL: Documentation: 18: 13.3. Explicit Locking — https://www.postgresql.org/docs/current/explicit-locking.html
- PostgreSQL: Documentation: 19: 13.3. Explicit Locking — https://www.postgresql.org/docs/19/explicit-locking.html
- PostgreSQL: Documentation: 7.3: Explicit Locking — https://www.postgresql.org/docs/7.3/explicit-locking.html
- PostgreSQL: Documentation: 9.0: Explicit Locking — https://www.postgresql.org/docs/9/explicit-locking.html
- PostgreSQL: Documentation: 8.0: Explicit Locking — https://www.postgresql.org/docs/8.0/explicit-locking.html
- …and 11 more

Web query:

site:postgresql.org/docs/15/explicit-locking.html "Row-level locks do not affect data querying"

Result:

Sources consulted:
- PostgreSQL: Documentation: 15: 13.3. Explicit Locking — https://www.postgresql.org/docs/15/explicit-locking.html
- PostgreSQL: Documentation: 14: 13.3. Explicit Locking — https://www.postgresql.org/docs/14/explicit-locking.html
- PostgreSQL 15.17 Documentation — https://www.postgresql.org/files/documentation/pdf/15/postgresql-15-US.pdf
- PostgreSQL: Documentation: 19: 13.3. Explicit Locking — https://www.postgresql.org/docs/19/explicit-locking.html
- PostgreSQL 18.1 Documentation — https://www.postgresql.org/files/documentation/pdf/18/postgresql-18-US.pdf
- …and 13 more

Prevent imports from deleting a newly started rollout.

If an experiment starts after this check, the import can delete its rollout override while the experiment remains running. The experiment then stops serving its configured rollout. Ensure imports cannot remove the override during this transition.

FeatureSegment.objects.filter(
feature=existing_feature, environment=environment
).delete()
Expand Down
48 changes: 27 additions & 21 deletions api/features/import_export/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from typing import Optional

from django.conf import settings
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from task_processor.decorators import (
Expand Down Expand Up @@ -143,27 +144,32 @@ def import_features_for_environment(feature_import_id: int) -> None:


def _import_features_for_environment(feature_import: FeatureImport) -> None:
environment = feature_import.environment
input_data: list[FeatureExportData] = json.loads(feature_import.data)
project = environment.project

for feature_data in input_data:
existing_feature = Feature.objects.filter(
name=feature_data["name"],
project=project,
).first()

if existing_feature and feature_import.strategy == SKIP:
continue

if existing_feature is None:
existing_feature = map_feature_export_data_to_feature(feature_data, project)
existing_feature.save()

overwrite_feature_for_environment(feature_data, existing_feature, environment)

feature_import.status = SUCCESS
feature_import.save()
with transaction.atomic():
environment = feature_import.environment
input_data: list[FeatureExportData] = json.loads(feature_import.data)
project = environment.project

for feature_data in input_data:
existing_feature = Feature.objects.filter(
name=feature_data["name"],
project=project,
).first()

if existing_feature and feature_import.strategy == SKIP:
continue

if existing_feature is None:
existing_feature = map_feature_export_data_to_feature(
feature_data, project
)
existing_feature.save()

overwrite_feature_for_environment(
feature_data, existing_feature, environment
)

feature_import.status = SUCCESS
feature_import.save()


# Should only run on official flagsmith instance.
Expand Down
50 changes: 49 additions & 1 deletion api/features/multivariate/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@
CREATE_FEATURE,
VIEW_PROJECT,
)
from django.db import transaction
from django.utils.decorators import method_decorator
from drf_spectacular.utils import extend_schema
from rest_framework import viewsets
from rest_framework.decorators import api_view
from rest_framework.exceptions import ValidationError
from rest_framework.generics import get_object_or_404
from rest_framework.response import Response

from experimentation.feature_state_metadata import has_live_experiment
from features.constants import LIVE_EXPERIMENT_MV_OPTIONS_MESSAGE
from features.models import Feature
from projects.permissions import NestedProjectPermissions

Expand Down Expand Up @@ -69,9 +73,53 @@ def get_permissions(self): # type: ignore[no-untyped-def]
def create(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
feature_pk = self.kwargs.get("feature_pk")
project_pk = self.kwargs.get("project_pk")
get_object_or_404(Feature.objects.filter(project__id=project_pk), pk=feature_pk)
url_feature = get_object_or_404(
Feature.objects.filter(project__id=project_pk), pk=feature_pk
)
if "feature" in request.data and str(request.data.get("feature")) != str(
url_feature.id
):
raise ValidationError({"feature": "Feature must match the URL."})
return super().create(request, *args, **kwargs)

def perform_create(self, serializer): # type: ignore[no-untyped-def]
url_feature = get_object_or_404(
Feature.objects.filter(project__id=self.kwargs["project_pk"]),
pk=self.kwargs["feature_pk"],
)
if serializer.validated_data["feature"] != url_feature:
raise ValidationError({"feature": "Feature must match the URL."})
with transaction.atomic():
Feature.objects.select_for_update().filter(pk=url_feature.pk).exists()
self._check_no_live_experiment(url_feature)
super().perform_create(serializer)

def perform_update(self, serializer): # type: ignore[no-untyped-def]
features = [serializer.instance.feature]
if "feature" in serializer.validated_data:
features.append(serializer.validated_data["feature"])
feature_ids = sorted({feature.id for feature in features})
with transaction.atomic():
list(
Feature.objects.select_for_update()
.filter(pk__in=feature_ids)
.order_by("pk")
.values_list("pk", flat=True)
)
for feature in features:
self._check_no_live_experiment(feature)
super().perform_update(serializer)

def perform_destroy(self, instance): # type: ignore[no-untyped-def]
with transaction.atomic():
Feature.objects.select_for_update().filter(pk=instance.feature_id).exists()
self._check_no_live_experiment(instance.feature)
super().perform_destroy(instance)

def _check_no_live_experiment(self, feature: Feature) -> None:
if has_live_experiment(feature.id):
raise ValidationError(LIVE_EXPERIMENT_MV_OPTIONS_MESSAGE)

def get_queryset(self): # type: ignore[no-untyped-def]
if getattr(self, "swagger_fake_view", False):
return MultivariateFeatureOption.objects.none()
Expand Down
58 changes: 56 additions & 2 deletions api/features/serializers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from datetime import datetime
from functools import cached_property
from typing import Any, Callable
from typing import Any, Callable, cast
from uuid import UUID

import django.core.exceptions
Expand All @@ -12,7 +12,7 @@
FeatureStateValueSerializer,
)
from common.projects.permissions import VIEW_PROJECT
from django.db import models
from django.db import models, transaction
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from drf_writable_nested import ( # type: ignore[attr-defined]
Expand All @@ -31,7 +31,9 @@
from evaluation.types import EvaluatedFeatureState
from experimentation.feature_state_metadata import (
get_feature_state_metadata_builder,
has_live_experiment,
)
from features.constants import LIVE_EXPERIMENT_MV_OPTIONS_MESSAGE
from integrations.vcs.services import dispatch_vcs_on_feature_state_changed
from metadata.serializers import MetadataSerializer, MetadataSerializerMixin
from projects.code_references.serializers import (
Expand Down Expand Up @@ -307,6 +309,22 @@ def validate_project_features_limit(self, project: Project) -> None:
}
)

def update(self, instance: Feature, validated_data: dict[str, Any]) -> Feature:
with transaction.atomic():
Feature.objects.select_for_update().filter(pk=instance.pk).exists()
if (
has_live_experiment(instance.id)
and "multivariate_options" in validated_data
):
if self._changes_multivariate_options(
validated_data["multivariate_options"], instance
):
raise serializers.ValidationError(
{"multivariate_options": LIVE_EXPERIMENT_MV_OPTIONS_MESSAGE}
Comment on lines +319 to +323

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor · Functional Correctness · Low value

Supported by static analysis

Script executed:

sed -n '1,180p' api/features/multivariate/serializers.py; rg -n 'class DeleteBeforeUpdateWritableNestedModelSerializer|partial=True|multivariate_options' api/tests/unit/features/test* api/core/serializers*

Repository: Flagsmith/flagsmith

Length of output: 8164


Script executed:

git diff 13539afcb99c20b4351e26f215d01b49f2a9be03 d564c8d053d14408261767f8c48432435b3adc51 -- api/features/serializers.py api/tests/unit/features/multivariate/test_unit_multivariate_live_experiment.py
nl -ba api/features/serializers.py | sed -n '1,390p'
rg -n -F -e 'DeleteBeforeUpdateWritableNestedModelSerializer' -e 'class FeatureViewSet' -e 'class NestedMultivariateFeatureOptionSerializer' -e '_changes_multivariate_options' -- api

Repository: Flagsmith/flagsmith

Length of output: 31654


Script executed:

nl -ba api/util/drf_writable_nested/serializers.py | sed -n '1,180p'
nl -ba api/features/multivariate/serializers.py | sed -n '1,55p'
rg -n -F -e 'class MultivariateFeatureOption' -e 'class FeatureViewSet' -e 'CreateFeatureSerializer' -- api/features/multivariate/models.py api/features/views.py
nl -ba api/features/multivariate/models.py | sed -n '1,180p'
nl -ba api/features/views.py | sed -n '200,440p'
rg -n -i -e 'drf-writable-nested' -e 'djangorestframework' -- api/pyproject.toml api/poetry.lock api/requirements* pyproject.toml uv.lock 2>/dev/null
rg -n -F -e 'partial' -e 'multivariate_options' -- api/tests/unit/features/test_unit_features_serializers.py api/tests/unit/features/multivariate/test_unit_multivariate_live_experiment.py

Repository: Flagsmith/flagsmith

Length of output: 26910


Script executed:

rg -n -F -e 'class UpdateFeatureSerializer' -e 'class ListFeatureSerializer' -e 'class ProjectFeatureSerializer' -- api/features/serializers.py
rg -n -F -e 'name = "drf-writable-nested"' -e 'name = "djangorestframework"' -- api/uv.lock uv.lock api/poetry.lock 2>/dev/null
rg -n -F 'class AbstractBaseFeatureValueModel' api/features/feature_states/models.py
nl -ba api/features/feature_states/models.py | sed -n '1,125p'
rg -n -F -e 'description' -e 'partial_update' -- api/tests/unit/features/test_unit_features_views.py | sed -n '1,70p'

Repository: Flagsmith/flagsmith

Length of output: 5679


Web query:

github beda-software drf-writable-nested 0.6.4 mixins.py update_or_create_reverse_relations _get_serializer partial delete_reverse_relations_if_need source

Result:

Sources consulted:
- https://raw.githubusercontent.com/beda-software/drf-writable-nested/v0.6.4/drf_writable_nested/mixins.py
- drf-writable-nested/drf_writable_nested/mixins.py at v0.6.4 · beda-software/drf-writable-nested · GitHub — https://github.com/beda-software/drf-writable-nested/blob/v0.6.4/drf_writable_nested/mixins.py
- GitHub - beda-software/drf-writable-nested: Writable nested model serializer for Django REST Framework · GitHub — https://github.com/beda-software/drf-writable-nested
- drf-writable-nested · PyPI — https://pypi.org/project/drf-writable-nested/0.5.4/
- …and 53 more

Script executed:

nl -ba api/uv.lock | sed -n '1112,1127p;1234,1250p'
nl -ba api/features/serializers.py | sed -n '470,610p'
nl -ba api/tests/unit/features/test_unit_features_views.py | sed -n '655,701p'
nl -ba api/tests/unit/features/multivariate/test_unit_multivariate_live_experiment.py | sed -n '175,233p'

Repository: Flagsmith/flagsmith

Length of output: 12996


Web query:

https://raw.githubusercontent.com/beda-software/drf-writable-nested/v0.6.4/drf_writable_nested/mixins.py "def update_or_create_reverse_relations" "def delete_reverse_relations_if_need" _get_related_pk serializer.save source code

Result:

Sources consulted:
- https://raw.githubusercontent.com/beda-software/drf-writable-nested/v0.6.4/drf_writable_nested/mixins.py

Web query:

django rest framework 3.18.3 fields.py validate_empty_values root.partial SkipField serializers.py ModelSerializer update source

Result:

Sources consulted:
- django-rest-framework/rest_framework/fields.py at 3.18.3 · encode/django-rest-framework · GitHub — https://github.com/encode/django-rest-framework/blob/3.18.3/rest_framework/fields.py
- django-rest-framework/rest_framework/serializers.py at 3.18.3 · encode/django-rest-framework · GitHub — https://github.com/encode/django-rest-framework/blob/3.18.3/rest_framework/serializers.py
- …and 33 more

Web query:

https://raw.githubusercontent.com/encode/django-rest-framework/3.18.3/rest_framework/serializers.py "def update(self, instance, validated_data)" "setattr(instance, attr, value)" ModelSerializer

Result:

Sources consulted:
- https://raw.githubusercontent.com/encode/django-rest-framework/3.18.3/rest_framework/serializers.py

Compare partial options against their stored values.

During a live experiment, a PATCH that resubmits an option’s ID and unchanged string_value can receive HTTP 400 if it omits type or allocation. The comparison treats those fields as None and blocks the description save. Resolve omitted fields from the identified option in both checks.

)
validated_data.pop("multivariate_options")
return super().update(instance, validated_data) # type: ignore[no-any-return]

def validate_multivariate_options(self, multivariate_options): # type: ignore[no-untyped-def]
if multivariate_options:
user = self.context["request"].user
Expand All @@ -324,8 +342,44 @@ def validate_multivariate_options(self, multivariate_options): # type: ignore[n
)
if total_percentage_allocation > 100:
raise serializers.ValidationError("Invalid percentage allocation")
instance = cast(Feature | None, self.instance)
if (
instance
and self._changes_multivariate_options(multivariate_options, instance)
and has_live_experiment(instance.id)
):
raise serializers.ValidationError(LIVE_EXPERIMENT_MV_OPTIONS_MESSAGE)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return multivariate_options

def _changes_multivariate_options(
self,
multivariate_options: list[dict[str, Any]] | None,
instance: Feature | None = None,
) -> bool:
instance = instance or cast(Feature | None, self.instance)
if instance is None:
return False
# The frontend resubmits the whole feature, so only a real change to
# writable option fields counts. Compare on content, not ids.
fields = (
"type",
"integer_value",
"string_value",
"boolean_value",
"default_percentage_allocation",
)
current = NestedMultivariateFeatureOptionSerializer(
instance.multivariate_options.all(), many=True
).data

def signature(options) -> list[tuple[Any, ...]]: # type: ignore[no-untyped-def]
return sorted(
(tuple(option.get(field) for field in fields) for option in options),
key=repr,
)

return signature(current) != signature(multivariate_options or [])

def validate_owners(self, owners: list[FFAdminUser]) -> list[FFAdminUser]:
project: Project = self.context["project"]
invalid_users = [
Expand Down
Loading