Skip to content
Merged
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
6 changes: 4 additions & 2 deletions api/environments/identities/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
IdentifyWithTraitsSerializer,
IdentitySerializerWithTraitsAndSegments,
)
from features.serializers import SDKFeatureStateSerializer
from features.serializers import SDKIdentityFeatureStateSerializer
from integrations.integration import identify_integrations
from util.views import SDKAPIView

Expand Down Expand Up @@ -290,7 +290,9 @@ def _get_single_feature_state_response(
additional_filters=self._get_additional_filters(),
):
if feature_state.feature.name == feature_name:
serializer = SDKFeatureStateSerializer(feature_state, context=context)
serializer = SDKIdentityFeatureStateSerializer(
feature_state, context=context
)
return Response(
data=serializer.data, status=status.HTTP_200_OK, headers=headers
)
Expand Down
4 changes: 2 additions & 2 deletions api/environments/sdk/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from environments.sdk.types import SDKTraitData
from features.serializers import (
FeatureStateSerializerFull,
SDKFeatureStateSerializer,
SDKIdentityFeatureStateSerializer,
)
from integrations.integration import identify_integrations
from segments.serializers import SegmentSerializerBasic
Expand Down Expand Up @@ -138,7 +138,7 @@ class IdentifyWithTraitsSerializer(
)
transient = serializers.BooleanField(write_only=True, default=False)
traits = TraitSerializerBasic(required=False, many=True)
flags = SDKFeatureStateSerializer(read_only=True, many=True)
flags = SDKIdentityFeatureStateSerializer(read_only=True, many=True)

sensitive_fields = ("traits",)

Expand Down
3 changes: 3 additions & 0 deletions api/features/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
COMMITTED = "COMMITTED"
DRAFT = "DRAFT"

# Multivariate variant reported when an identity falls through to the control value
CONTROL_VARIANT_KEY = "control"

# Tag filtering strategy
UNION = "UNION"
INTERSECTION = "INTERSECTION"
Expand Down
26 changes: 24 additions & 2 deletions api/features/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,17 @@
DeleteBeforeUpdateWritableNestedModelSerializer,
)

from .constants import INTERSECTION, UNION
from .constants import CONTROL_VARIANT_KEY, INTERSECTION, UNION
from .feature_segments.limits import (
SEGMENT_OVERRIDE_LIMIT_EXCEEDED_MESSAGE,
exceeds_segment_override_limit,
)
from .feature_segments.serializers import (
CustomCreateSegmentOverrideFeatureSegmentSerializer,
)
from .feature_types import FEATURE_TYPE_CHOICES
from .feature_types import FEATURE_TYPE_CHOICES, MULTIVARIATE
from .models import Feature, FeatureState
from .multivariate.models import MultivariateFeatureOption
from .multivariate.serializers import NestedMultivariateFeatureOptionSerializer


Expand Down Expand Up @@ -627,6 +628,27 @@ class SDKFeatureStateSerializer(
)


class SDKIdentityFeatureStateSerializer(SDKFeatureStateSerializer):
variant = serializers.SerializerMethodField()

class Meta(SDKFeatureStateSerializer.Meta):
fields = SDKFeatureStateSerializer.Meta.fields + ("variant",) # type: ignore[assignment]

@extend_schema_field({"type": "string", "nullable": True})
def get_variant(self, obj: FeatureState) -> str | None:
if obj.feature.type != MULTIVARIATE:
return None
identity = self.context["identity"]
value_object = obj.get_multivariate_feature_state_value(
identity.get_hash_key(
identity.environment.use_identity_composite_key_for_hashing
)
)
Comment thread
gagantrivedi marked this conversation as resolved.
if isinstance(value_object, MultivariateFeatureOption):
return value_object.key
return CONTROL_VARIANT_KEY


class FeatureStateSerializerBasic(WritableNestedModelSerializer):
feature_state_value = serializers.SerializerMethodField()
multivariate_feature_state_values = MultivariateFeatureStateValueSerializer(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,111 @@ def test_get_feature_states_for_identity__mv_percentage_allocation__returns_corr
assert values_dict[multivariate_feature_id] == variant_2_value


@pytest.mark.parametrize(
"hashed_percentage, expected_variant",
(
(variant_1_percentage_allocation - 1, "variant-1"),
(total_variance_percentage - 1, "variant-2"),
(total_variance_percentage + 1, "control"),
),
)
@mock.patch("features.models.get_hashed_percentage_for_object_ids")
def test_get_feature_states_for_identity__mv_allocation__returns_variant( # type: ignore[no-untyped-def]
mock_get_hashed_percentage_value,
hashed_percentage,
expected_variant,
sdk_client,
admin_client,
project,
environment_api_key,
environment,
identity,
identity_identifier,
):
# Given
# a standard (non-multivariate) feature
standard_feature_id = create_feature_with_api(
client=admin_client,
project_id=project,
feature_name="standard_feature",
initial_value="control",
)

# and a multivariate feature with two keyed variants spanning part of the range,
# so the remainder falls through to the control
multivariate_feature_id = create_feature_with_api(
client=admin_client,
project_id=project,
feature_name="multivariate_feature",
initial_value=control_value,
feature_type=MULTIVARIATE,
)
create_mv_option_with_api(
admin_client,
project,
multivariate_feature_id, # type: ignore[arg-type]
variant_1_percentage_allocation,
variant_1_value,
key="variant-1",
)
create_mv_option_with_api(
admin_client,
project,
multivariate_feature_id, # type: ignore[arg-type]
variant_2_percentage_allocation,
variant_2_value,
key="variant-2",
)

# When
# the identity hashes into a known allocation band
mock_get_hashed_percentage_value.return_value = hashed_percentage
base_url = reverse("api-v1:sdk-identities")
url = f"{base_url}?identifier={identity_identifier}"
response = sdk_client.get(url)

# Then
assert response.status_code == status.HTTP_200_OK
variant_by_feature = {
flag["feature"]["id"]: flag["variant"] for flag in response.json()["flags"]
}
# the multivariate flag reports the variant key (or "control" on fall-through)
assert variant_by_feature[multivariate_feature_id] == expected_variant
# and the standard flag has no variant
assert variant_by_feature[standard_feature_id] is None


def test_get_flags__multivariate_feature__response_excludes_variant( # type: ignore[no-untyped-def]
sdk_client,
admin_client,
project,
environment,
):
# Given - a multivariate feature with a keyed variant
multivariate_feature_id = create_feature_with_api(
client=admin_client,
project_id=project,
feature_name="multivariate_feature",
initial_value=control_value,
feature_type=MULTIVARIATE,
)
create_mv_option_with_api(
admin_client,
project,
multivariate_feature_id, # type: ignore[arg-type]
100,
variant_1_value,
key="variant-1",
)

# When - the environment flags are fetched (no identity / remote evaluation)
response = sdk_client.get(reverse("api-v1:flags"))

# Then - the variant field is scoped to the identities endpoint only
assert response.status_code == status.HTTP_200_OK
assert all("variant" not in flag for flag in response.json())


def test_get_feature_states_for_identity__multiple_mv_features__single_mv_query( # type: ignore[no-untyped-def]
sdk_client,
admin_client,
Expand Down
6 changes: 5 additions & 1 deletion api/tests/integration/helpers.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import typing

from django.urls import reverse
from django.utils.http import urlencode
Expand Down Expand Up @@ -47,17 +48,20 @@ def create_mv_option_with_api(
feature_id: str,
default_percentage_allocation: float,
value: str,
key: str | None = None,
) -> int:
url = reverse(
"api-v1:projects:feature-mv-options-list",
args=[project_id, feature_id],
)
data = {
data: dict[str, typing.Any] = {
"type": STRING,
"feature": feature_id,
"string_value": value,
"default_percentage_allocation": default_percentage_allocation,
}
if key is not None:
data["key"] = key
response = client.post(
url,
data=json.dumps(data),
Expand Down
18 changes: 17 additions & 1 deletion api/tests/unit/features/test_unit_features_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from core.constants import BOOLEAN, INTEGER, STRING
from environments.models import Environment
from features.feature_types import STANDARD
from features.models import Feature, FeatureState
from features.multivariate.models import (
MultivariateFeatureOption,
Expand All @@ -12,7 +13,22 @@
FeatureMVOptionsValuesResponseSerializer,
MultivariateOptionValuesSerializer,
)
from features.serializers import FeatureStateSerializerBasic
from features.serializers import (
FeatureStateSerializerBasic,
SDKIdentityFeatureStateSerializer,
)


def test_sdk_identity_feature_state_serializer__non_multivariate_feature__variant_is_none(
mocker: MockerFixture,
) -> None:
# Given - a standard (non-multivariate) feature state
feature_state = mocker.MagicMock()
feature_state.feature.type = STANDARD
serializer = SDKIdentityFeatureStateSerializer(context={})

# When / Then - non-multivariate features are not part of an experiment
assert serializer.get_variant(feature_state) is None


@pytest.mark.parametrize(
Expand Down
Loading