Skip to content

Commit

Permalink
feat: Support blank identifiers, assume transient (#4449)
Browse files Browse the repository at this point in the history
Co-authored-by: Matthew Elwell <[email protected]>
  • Loading branch information
khvn26 and matthewelwell authored Aug 9, 2024
1 parent 2ab73ed commit 0014a5b
Show file tree
Hide file tree
Showing 9 changed files with 347 additions and 38 deletions.
10 changes: 7 additions & 3 deletions api/environments/identities/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
from django.db import models
from django.db.models import Prefetch, Q
from django.utils import timezone
from flag_engine.identities.traits.types import TraitValue
from flag_engine.segments.evaluator import evaluate_identity_in_segment

from environments.identities.managers import IdentityManager
from environments.identities.traits.models import Trait
from environments.models import Environment
from environments.sdk.types import SDKTraitData
from features.models import FeatureState
from features.multivariate.models import MultivariateFeatureStateValue
from segments.models import Segment
Expand Down Expand Up @@ -196,7 +196,11 @@ def get_all_user_traits(self):
def __str__(self):
return "Account %s" % self.identifier

def generate_traits(self, trait_data_items, persist=False):
def generate_traits(
self,
trait_data_items: list[SDKTraitData],
persist: bool = False,
) -> list[Trait]:
"""
Given a list of trait data items, validated by TraitSerializerFull, generate
a list of TraitModel objects for the given identity.
Expand Down Expand Up @@ -232,7 +236,7 @@ def generate_traits(self, trait_data_items, persist=False):

def update_traits(
self,
trait_data_items: list[dict[str, TraitValue]],
trait_data_items: list[SDKTraitData],
) -> list[Trait]:
"""
Given a list of traits, update any that already exist and create any new ones.
Expand Down
1 change: 1 addition & 0 deletions api/environments/identities/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ class _TraitSerializer(serializers.Serializer):
help_text="Can be of type string, boolean, float or integer."
)

identifier = serializers.CharField()
flags = serializers.ListField(child=SDKFeatureStateSerializer())
traits = serializers.ListSerializer(child=_TraitSerializer())

Expand Down
6 changes: 4 additions & 2 deletions api/environments/identities/traits/fields.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import logging
from typing import Any

from rest_framework import serializers

from environments.identities.traits.constants import (
ACCEPTED_TRAIT_VALUE_TYPES,
TRAIT_STRING_VALUE_MAX_LENGTH,
)
from environments.sdk.types import SDKTraitValueData
from features.value_types import STRING

logger = logging.getLogger(__name__)
Expand All @@ -16,7 +18,7 @@ class TraitValueField(serializers.Field):
Custom field to extract the type of the field on deserialization.
"""

def to_internal_value(self, data):
def to_internal_value(self, data: Any) -> SDKTraitValueData:
data_type = type(data).__name__

if data_type not in ACCEPTED_TRAIT_VALUE_TYPES:
Expand All @@ -28,7 +30,7 @@ def to_internal_value(self, data):
)
return {"type": data_type, "value": data}

def to_representation(self, value):
def to_representation(self, value: Any) -> Any:
return_value = value.get("value") if isinstance(value, dict) else value

if return_value is None:
Expand Down
1 change: 1 addition & 0 deletions api/environments/identities/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ def _get_all_feature_states_for_user_response(
serializer = serializer_class(
{
"flags": all_feature_states,
"identifier": identity.identifier,
"traits": identity.identity_traits.all(),
},
context=self.get_serializer_context(),
Expand Down
61 changes: 36 additions & 25 deletions api/environments/sdk/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
from collections import defaultdict

from core.constants import BOOLEAN, FLOAT, INTEGER, STRING
from django.utils import timezone
from rest_framework import serializers

from environments.identities.models import Identity
Expand All @@ -12,6 +11,12 @@
from environments.identities.traits.fields import TraitValueField
from environments.identities.traits.models import Trait
from environments.identities.traits.serializers import TraitSerializerBasic
from environments.sdk.services import (
get_identified_transient_identity_and_traits,
get_persisted_identity_and_traits,
get_transient_identity_and_traits,
)
from environments.sdk.types import SDKTraitData
from features.serializers import (
FeatureStateSerializerFull,
SDKFeatureStateSerializer,
Expand Down Expand Up @@ -125,7 +130,11 @@ def create(self, validated_data):
class IdentifyWithTraitsSerializer(
HideSensitiveFieldsSerializerMixin, serializers.Serializer
):
identifier = serializers.CharField(write_only=True, required=True)
identifier = serializers.CharField(
required=False,
allow_blank=True,
allow_null=True,
)
transient = serializers.BooleanField(write_only=True, default=False)
traits = TraitSerializerBasic(required=False, many=True)
flags = SDKFeatureStateSerializer(read_only=True, many=True)
Expand All @@ -137,44 +146,46 @@ def save(self, **kwargs):
Create the identity with the associated traits
(optionally store traits if flag set on org)
"""
identifier = self.validated_data.get("identifier")
environment = self.context["environment"]

transient = self.validated_data["transient"]
trait_data_items = self.validated_data.get("traits", [])
sdk_trait_data: list[SDKTraitData] = self.validated_data.get("traits", [])

if transient:
identity = Identity(
created_date=timezone.now(),
identifier=self.validated_data["identifier"],
if not identifier:
# We have a fully transient identity that should never be persisted.
identity, traits = get_transient_identity_and_traits(
environment=environment,
sdk_trait_data=sdk_trait_data,
)
trait_models = identity.generate_traits(trait_data_items, persist=False)

else:
identity, created = Identity.objects.get_or_create(
identifier=self.validated_data["identifier"], environment=environment
elif transient:
# Don't persist incoming data but load presently stored
# overrides and traits, if any.
identity, traits = get_identified_transient_identity_and_traits(
environment=environment,
identifier=identifier,
sdk_trait_data=sdk_trait_data,
)

if not created and environment.project.organisation.persist_trait_data:
# if this is an update and we're persisting traits, then we need to
# partially update any traits and return the full list
trait_models = identity.update_traits(trait_data_items)
else:
# generate traits for the identity and store them if configured to do so
trait_models = identity.generate_traits(
trait_data_items,
persist=environment.project.organisation.persist_trait_data,
)
else:
# Persist the identity in accordance with individual trait transiency
# and persistence settings outside of request context.
identity, traits = get_persisted_identity_and_traits(
environment=environment,
identifier=identifier,
sdk_trait_data=sdk_trait_data,
)

all_feature_states = identity.get_all_feature_states(
traits=trait_models,
traits=traits,
additional_filters=self.context.get("feature_states_additional_filters"),
)
identify_integrations(identity, all_feature_states, trait_models)
identify_integrations(identity, all_feature_states, traits)

return {
"identity": identity,
"traits": trait_models,
"identifier": identity.identifier,
"traits": traits,
"flags": all_feature_states,
}

Expand Down
120 changes: 120 additions & 0 deletions api/environments/sdk/services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import hashlib
import uuid
from itertools import chain
from operator import itemgetter
from typing import TypeAlias

from django.utils import timezone

from environments.identities.models import Identity
from environments.identities.traits.models import Trait
from environments.models import Environment
from environments.sdk.types import SDKTraitData

IdentityAndTraits: TypeAlias = tuple[Identity, list[Trait]]


def get_transient_identity_and_traits(
environment: Environment,
sdk_trait_data: list[SDKTraitData],
) -> IdentityAndTraits:
"""
Get a transient `Identity` instance with a randomly generated identifier.
All traits are marked as transient.
"""
return (
(
identity := _get_transient_identity(
environment=environment,
identifier=get_transient_identifier(sdk_trait_data),
)
),
identity.generate_traits(_ensure_transient(sdk_trait_data), persist=False),
)


def get_identified_transient_identity_and_traits(
environment: Environment,
identifier: str,
sdk_trait_data: list[SDKTraitData],
) -> IdentityAndTraits:
"""
Get a transient `Identity` instance.
If present in storage, it's a previously persisted identity with its traits,
combined with incoming traits provided to `sdk_trait_data` argument.
All traits constructed from `sdk_trait_data` are marked as transient.
"""
sdk_trait_data = _ensure_transient(sdk_trait_data)
if identity := Identity.objects.filter(
environment=environment,
identifier=identifier,
).first():
return identity, identity.update_traits(sdk_trait_data)
return (
identity := _get_transient_identity(
environment=environment,
identifier=identifier,
)
), identity.generate_traits(sdk_trait_data, persist=False)


def get_persisted_identity_and_traits(
environment: Environment,
identifier: str,
sdk_trait_data: list[SDKTraitData],
) -> IdentityAndTraits:
"""
Retrieve a previously persisted `Identity` instance or persist a new one.
Traits are persisted based on the organisation-level setting or a
`"transient"` attribute provided with each individual trait.
"""
identity, created = Identity.objects.get_or_create(
environment=environment,
identifier=identifier,
)
persist_trait_data = environment.project.organisation.persist_trait_data
if created:
return identity, identity.generate_traits(
sdk_trait_data,
persist=persist_trait_data,
)
if persist_trait_data:
return identity, identity.update_traits(sdk_trait_data)
return identity, list(
{
trait.trait_key: trait
for trait in chain(
identity.identity_traits.all(),
identity.generate_traits(sdk_trait_data, persist=False),
)
}.values()
)


def get_transient_identifier(sdk_trait_data: list[SDKTraitData]) -> str:
if sdk_trait_data:
return hashlib.sha256(
"".join(
f'{trait["trait_key"]}{trait["trait_value"]["value"]}'
for trait in sorted(sdk_trait_data, key=itemgetter("trait_key"))
).encode(),
usedforsecurity=False,
).hexdigest()
return uuid.uuid4().hex


def _get_transient_identity(
environment: Environment,
identifier: str,
) -> Identity:
return Identity(
created_date=timezone.now(),
environment=environment,
identifier=identifier,
)


def _ensure_transient(sdk_trait_data: list[SDKTraitData]) -> list[SDKTraitData]:
for sdk_trait_data_item in sdk_trait_data:
sdk_trait_data_item["transient"] = True
return sdk_trait_data
14 changes: 14 additions & 0 deletions api/environments/sdk/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import typing

from typing_extensions import NotRequired


class SDKTraitValueData(typing.TypedDict):
type: str
value: str


class SDKTraitData(typing.TypedDict):
trait_key: str
trait_value: SDKTraitValueData
transient: NotRequired[bool]
Loading

0 comments on commit 0014a5b

Please sign in to comment.