From a56947bd26fd6eab5509f536a89964bdb697b5b9 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 17 Jun 2026 17:33:09 +0200 Subject: [PATCH 1/4] fix(experiments): resolve environment-level variant allocations and order by newest ExperimentFeatureSerializer now looks up the environment's feature state to return the actual percentage allocations instead of the feature-level defaults. Prefetch the related state values to avoid N+1 queries. Order experiments list by newest first. --- api/experimentation/serializers.py | 36 +++++++++++++++++++++++++++--- api/experimentation/views.py | 3 ++- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/api/experimentation/serializers.py b/api/experimentation/serializers.py index 6b7e2db5f7f9..c24664a2ed1e 100644 --- a/api/experimentation/serializers.py +++ b/api/experimentation/serializers.py @@ -285,15 +285,45 @@ def create(self, validated_data: dict[str, Any]) -> Experiment: class ExperimentFeatureSerializer(serializers.ModelSerializer): # type: ignore[type-arg] - multivariate_options = NestedMultivariateFeatureOptionSerializer( - many=True, read_only=True - ) + multivariate_options = serializers.SerializerMethodField() class Meta: model = Feature fields = ("id", "name", "type", "initial_value", "multivariate_options") read_only_fields = fields + def get_multivariate_options(self, feature: Feature) -> list[dict[str, Any]]: + options = NestedMultivariateFeatureOptionSerializer( + feature.multivariate_options.all(), many=True + ).data + + environment: Environment | None = self.context.get("environment") + if not environment: + return options # type: ignore[return-value] + + env_state = ( + feature.feature_states.filter( + environment=environment, + identity__isnull=True, + feature_segment__isnull=True, + ) + .order_by("-live_from", "-version") + .first() + ) + if not env_state: + return options # type: ignore[return-value] + + alloc_map = dict( + env_state.multivariate_feature_state_values.values_list( + "multivariate_feature_option_id", "percentage_allocation" + ) + ) + for option in options: + if option["id"] in alloc_map: + option["default_percentage_allocation"] = alloc_map[option["id"]] + + return options # type: ignore[return-value] + class ExperimentListSerializer(ExperimentSerializer): feature = ExperimentFeatureSerializer(read_only=True) diff --git a/api/experimentation/views.py b/api/experimentation/views.py index bc2008881657..bbf55f87e72c 100644 --- a/api/experimentation/views.py +++ b/api/experimentation/views.py @@ -175,6 +175,7 @@ def get_queryset(self) -> "QuerySet[Experiment]": if self.action in ("list", "retrieve"): qs = qs.select_related("feature").prefetch_related( "feature__multivariate_options", + "feature__feature_states__multivariate_feature_state_values", "experiment_metrics__metric", ) status_filter = self.request.query_params.get("status") @@ -189,7 +190,7 @@ def get_queryset(self) -> "QuerySet[Experiment]": if q: qs = qs.filter(Q(name__icontains=q) | Q(feature__name__icontains=q)) - return qs + return qs.order_by("-created_at") def list(self, request: Request, *args: object, **kwargs: object) -> Response: response = super().list(request, *args, **kwargs) From d5c1e2f7ee1b86386d9221ce7f3331cc6610d0f1 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 17 Jun 2026 17:42:14 +0200 Subject: [PATCH 2/4] test(experiments): verify env-level allocations in experiment detail response --- .../experimentation/test_experiment_views.py | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/api/tests/unit/experimentation/test_experiment_views.py b/api/tests/unit/experimentation/test_experiment_views.py index ef63ab9b1ff6..65552a686fdc 100644 --- a/api/tests/unit/experimentation/test_experiment_views.py +++ b/api/tests/unit/experimentation/test_experiment_views.py @@ -29,7 +29,8 @@ Metric, ) from features.feature_types import MULTIVARIATE -from features.models import Feature +from features.models import Feature, FeatureState +from features.multivariate.models import MultivariateFeatureStateValue from tests.types import EnableFeaturesFixture if TYPE_CHECKING: @@ -1373,3 +1374,38 @@ def test_post__concurrent_create_race__returns_409( # Then assert response.status_code == status.HTTP_409_CONFLICT + + +def test_get_detail__env_level_allocations__returns_environment_percentages( + admin_client_new: APIClient, + environment: Environment, + experiment: Experiment, + multivariate_feature: Feature, + enable_features: EnableFeaturesFixture, +) -> None: + # Given + enable_features(EXPERIMENT_FLAG) + env_fs = FeatureState.objects.get( + feature=multivariate_feature, + environment=environment, + identity__isnull=True, + feature_segment__isnull=True, + ) + env_allocations = [10.0, 20.0, 70.0] + for mv_fsv, alloc in zip( + MultivariateFeatureStateValue.objects.filter(feature_state=env_fs).order_by( + "multivariate_feature_option_id" + ), + env_allocations, + ): + mv_fsv.percentage_allocation = alloc + mv_fsv.save() + + # When + response = admin_client_new.get(_detail_url(environment, experiment)) + + # Then + assert response.status_code == status.HTTP_200_OK + options = response.json()["feature"]["multivariate_options"] + returned_allocs = sorted(o["default_percentage_allocation"] for o in options) + assert returned_allocs == sorted(env_allocations) From 758b907796d9f898f7d2264e7053fa12d1ae129b Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 18 Jun 2026 10:18:26 +0200 Subject: [PATCH 3/4] test(experiments): cover serializer fallback branches for env allocations --- .../experimentation/test_experiment_views.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/api/tests/unit/experimentation/test_experiment_views.py b/api/tests/unit/experimentation/test_experiment_views.py index 65552a686fdc..565fc7eee690 100644 --- a/api/tests/unit/experimentation/test_experiment_views.py +++ b/api/tests/unit/experimentation/test_experiment_views.py @@ -28,6 +28,7 @@ ExperimentStatus, Metric, ) +from experimentation.serializers import ExperimentFeatureSerializer from features.feature_types import MULTIVARIATE from features.models import Feature, FeatureState from features.multivariate.models import MultivariateFeatureStateValue @@ -1409,3 +1410,50 @@ def test_get_detail__env_level_allocations__returns_environment_percentages( options = response.json()["feature"]["multivariate_options"] returned_allocs = sorted(o["default_percentage_allocation"] for o in options) assert returned_allocs == sorted(env_allocations) + + +@pytest.mark.django_db() +def test_experiment_feature_serializer__no_environment_context__returns_default_allocations( + multivariate_feature: Feature, +) -> None: + # Given + serializer = ExperimentFeatureSerializer(multivariate_feature, context={}) + + # When + data = serializer.data + + # Then + for option in data["multivariate_options"]: + assert option["default_percentage_allocation"] == pytest.approx( + multivariate_feature.multivariate_options.get( + id=option["id"] + ).default_percentage_allocation + ) + + +@pytest.mark.django_db() +def test_experiment_feature_serializer__no_env_feature_state__returns_default_allocations( + environment: Environment, + multivariate_feature: Feature, +) -> None: + # Given + FeatureState.objects.filter( + feature=multivariate_feature, + environment=environment, + identity__isnull=True, + feature_segment__isnull=True, + ).delete() + serializer = ExperimentFeatureSerializer( + multivariate_feature, context={"environment": environment} + ) + + # When + data = serializer.data + + # Then + for option in data["multivariate_options"]: + assert option["default_percentage_allocation"] == pytest.approx( + multivariate_feature.multivariate_options.get( + id=option["id"] + ).default_percentage_allocation + ) From 9c8b3f196d42bdd511ced80799a593f308837137 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 19 Jun 2026 09:48:10 +0200 Subject: [PATCH 4/4] fix: raise if no env --- api/experimentation/serializers.py | 9 ++++-- .../experimentation/test_experiment_views.py | 32 +++++-------------- 2 files changed, 15 insertions(+), 26 deletions(-) diff --git a/api/experimentation/serializers.py b/api/experimentation/serializers.py index c24664a2ed1e..9ca5449fe7f1 100644 --- a/api/experimentation/serializers.py +++ b/api/experimentation/serializers.py @@ -299,7 +299,9 @@ def get_multivariate_options(self, feature: Feature) -> list[dict[str, Any]]: environment: Environment | None = self.context.get("environment") if not environment: - return options # type: ignore[return-value] + raise ValueError( + "ExperimentFeatureSerializer requires 'environment' in context." + ) env_state = ( feature.feature_states.filter( @@ -311,7 +313,10 @@ def get_multivariate_options(self, feature: Feature) -> list[dict[str, Any]]: .first() ) if not env_state: - return options # type: ignore[return-value] + raise ValueError( + f"No environment feature state found for feature {feature.id} " + f"in environment {environment.id}." + ) alloc_map = dict( env_state.multivariate_feature_state_values.values_list( diff --git a/api/tests/unit/experimentation/test_experiment_views.py b/api/tests/unit/experimentation/test_experiment_views.py index 565fc7eee690..f2aed13d94f3 100644 --- a/api/tests/unit/experimentation/test_experiment_views.py +++ b/api/tests/unit/experimentation/test_experiment_views.py @@ -1412,27 +1412,18 @@ def test_get_detail__env_level_allocations__returns_environment_percentages( assert returned_allocs == sorted(env_allocations) -@pytest.mark.django_db() -def test_experiment_feature_serializer__no_environment_context__returns_default_allocations( +def test_experiment_feature_serializer__no_environment_context__raises( multivariate_feature: Feature, ) -> None: # Given serializer = ExperimentFeatureSerializer(multivariate_feature, context={}) - # When - data = serializer.data - - # Then - for option in data["multivariate_options"]: - assert option["default_percentage_allocation"] == pytest.approx( - multivariate_feature.multivariate_options.get( - id=option["id"] - ).default_percentage_allocation - ) + # When / Then + with pytest.raises(ValueError, match="requires 'environment' in context"): + serializer.data -@pytest.mark.django_db() -def test_experiment_feature_serializer__no_env_feature_state__returns_default_allocations( +def test_experiment_feature_serializer__no_env_feature_state__raises( environment: Environment, multivariate_feature: Feature, ) -> None: @@ -1447,13 +1438,6 @@ def test_experiment_feature_serializer__no_env_feature_state__returns_default_al multivariate_feature, context={"environment": environment} ) - # When - data = serializer.data - - # Then - for option in data["multivariate_options"]: - assert option["default_percentage_allocation"] == pytest.approx( - multivariate_feature.multivariate_options.get( - id=option["id"] - ).default_percentage_allocation - ) + # When / Then + with pytest.raises(ValueError, match="No environment feature state found"): + serializer.data