Skip to content

Commit fc33002

Browse files
authored
fix(Multivariate): API accepts multivariate percentage splits over 100% (#8151)
1 parent 67a3272 commit fc33002

2 files changed

Lines changed: 126 additions & 3 deletions

File tree

api/features/multivariate/serializers.py

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77
from core.constants import BOOLEAN, INTEGER
88
from features.constants import CONTROL_VARIANT_KEY, RESERVED_VARIANT_KEY_MESSAGE
99
from features.models import Feature, FeatureState
10-
from features.multivariate.models import MultivariateFeatureOption
10+
from features.multivariate.models import (
11+
MultivariateFeatureOption,
12+
MultivariateFeatureStateValue,
13+
)
1114

1215

1316
class NestedMultivariateFeatureOptionSerializer(serializers.ModelSerializer): # type: ignore[type-arg]
@@ -79,25 +82,59 @@ def validate_key(self, value: str | None) -> str | None:
7982

8083
def validate(self, attrs): # type: ignore[no-untyped-def]
8184
attrs = super().validate(attrs)
85+
feature = attrs["feature"]
86+
default_percentage_allocation = attrs["default_percentage_allocation"]
87+
8288
total_sibling_percentage_allocation = (
83-
self._get_siblings(attrs["feature"]).aggregate(
89+
self._get_siblings(feature).aggregate(
8490
total_percentage_allocation=Sum("default_percentage_allocation")
8591
)["total_percentage_allocation"]
8692
or 0
8793
)
8894
total_percentage_allocation = (
89-
total_sibling_percentage_allocation + attrs["default_percentage_allocation"]
95+
total_sibling_percentage_allocation + default_percentage_allocation
9096
)
9197

9298
if total_percentage_allocation > 100:
9399
raise ValidationError(
94100
{"default_percentage_allocation": "Invalid percentage allocation"}
95101
)
96102

103+
if self.instance is None:
104+
# Creating an option adds `default_percentage_allocation` to every
105+
# environment's default feature state (see `MultivariateFeatureOption
106+
# .create_multivariate_feature_state_values`). A given environment's
107+
# actual feature state values can have drifted from the option-level
108+
# defaults checked above (e.g. via a per-environment edit), so check
109+
# those too rather than allowing that step to silently push one
110+
# environment over 100%.
111+
self._validate_environment_allocations(
112+
feature, default_percentage_allocation
113+
)
114+
97115
self._validate_key_is_unique(attrs)
98116

99117
return attrs
100118

119+
def _validate_environment_allocations(
120+
self, feature: Feature, default_percentage_allocation: float
121+
) -> None:
122+
environment_overflows = (
123+
MultivariateFeatureStateValue.objects.filter(
124+
feature_state__in=feature.feature_states.filter(
125+
identity=None, feature_segment=None
126+
),
127+
)
128+
.values("feature_state_id")
129+
.annotate(total_percentage_allocation=Sum("percentage_allocation"))
130+
.filter(total_percentage_allocation__gt=100 - default_percentage_allocation)
131+
.exists()
132+
)
133+
if environment_overflows:
134+
raise ValidationError(
135+
{"default_percentage_allocation": "Invalid percentage allocation"}
136+
)
137+
101138
def _validate_key_is_unique(self, attrs: dict[str, typing.Any]) -> None:
102139
key = attrs.get("key")
103140
if key is None:

api/tests/integration/features/multivariate/test_integration_multivariate.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from features.constants import RESERVED_VARIANT_KEY_MESSAGE
1010
from features.models import Feature
11+
from features.multivariate.models import MultivariateFeatureOption
1112
from organisations.models import Organisation
1213
from projects.models import Project
1314
from users.models import FFAdminUser
@@ -420,6 +421,91 @@ def test_create_mv_option__total_allocation_exceeds_100__returns_bad_request( #
420421
]
421422

422423

424+
@pytest.mark.parametrize(
425+
"client",
426+
[lazy_fixture("admin_master_api_key_client"), lazy_fixture("admin_client")],
427+
)
428+
def test_create_mv_option__environment_allocation_drifted_from_option_default__returns_bad_request( # type: ignore[no-untyped-def] # noqa: E501
429+
project, environment, environment_api_key, mv_option_50_percent, client, feature
430+
):
431+
"""
432+
Reproduces https://github.com/Flagsmith/flagsmith/issues/7369: an
433+
environment's actual `MultivariateFeatureStateValue.percentage_allocation`
434+
can drift from the option's `default_percentage_allocation` via a
435+
per-environment edit. Adding a new option that only overflows 100% for
436+
that one environment must still be rejected, not just options that
437+
overflow the option-level defaults.
438+
"""
439+
# Given - the existing option's allocation is bumped from 50% to 100% for
440+
# this environment only, so this environment's actual feature state values
441+
# (100%) have diverged from the option-level default (50%).
442+
get_feature_states_url = reverse(
443+
"api-v1:environments:environment-featurestates-list", args=[environment_api_key]
444+
)
445+
feature_state = next(
446+
filter(
447+
lambda fs: fs["feature"] == feature,
448+
client.get(get_feature_states_url).json()["results"],
449+
)
450+
)
451+
mv_fsv = feature_state["multivariate_feature_state_values"][0]
452+
update_url = reverse(
453+
"api-v1:environments:environment-featurestates-detail",
454+
args=[environment_api_key, feature_state["id"]],
455+
)
456+
update_response = client.put(
457+
update_url,
458+
data=json.dumps(
459+
{
460+
"id": feature_state["id"],
461+
"feature_state_value": "big",
462+
"multivariate_feature_state_values": [
463+
{
464+
"multivariate_feature_option": mv_fsv[
465+
"multivariate_feature_option"
466+
],
467+
"id": mv_fsv["id"],
468+
"percentage_allocation": 100,
469+
}
470+
],
471+
"identity": None,
472+
"enabled": False,
473+
"feature": feature,
474+
"environment": environment,
475+
"feature_segment": None,
476+
}
477+
),
478+
content_type="application/json",
479+
)
480+
assert update_response.status_code == status.HTTP_200_OK
481+
482+
url = reverse(
483+
"api-v1:projects:feature-mv-options-list",
484+
args=[project, feature],
485+
)
486+
data = {
487+
"type": "unicode",
488+
"feature": feature,
489+
"string_value": "bigger",
490+
"default_percentage_allocation": 10,
491+
}
492+
493+
# When - a new option is added. At the option level, 50% + 10% = 60% <= 100%,
494+
# so this looks valid, but it would push this environment's actual
495+
# allocation to 110%.
496+
response = client.post(
497+
url,
498+
data=json.dumps(data),
499+
content_type="application/json",
500+
)
501+
502+
# Then
503+
assert response.status_code == status.HTTP_400_BAD_REQUEST
504+
# The option must not be left half wired-up (created, but missing feature
505+
# state values for the environment that rejected it).
506+
assert MultivariateFeatureOption.objects.filter(feature=feature).count() == 1
507+
508+
423509
@pytest.mark.parametrize(
424510
"client",
425511
[lazy_fixture("admin_master_api_key_client"), lazy_fixture("admin_client")],

0 commit comments

Comments
 (0)