Skip to content

Commit

Permalink
Add random identifiers, service layer, refactor `IdentifyWithTraitsSe…
Browse files Browse the repository at this point in the history
…rializer`
  • Loading branch information
khvn26 committed Aug 4, 2024
1 parent 87ffc2c commit a1a34d7
Show file tree
Hide file tree
Showing 6 changed files with 191 additions and 28 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=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
55 changes: 30 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 @@ -142,45 +147,45 @@ 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") or ""
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 or not identifier:
identity = Identity(
created_date=timezone.now(),
identifier=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=identifier, environment=environment
elif transient:
# Get presently stored traits and identity overrides
# but don't persist incoming data.
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 non-local settings
# and individual trait transiency.
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,
"traits": traits,
"flags": all_feature_states,
}

Expand Down
86 changes: 86 additions & 0 deletions api/environments/sdk/services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import uuid
from itertools import chain
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(
environment: Environment,
identifier: str,
) -> Identity:
return Identity(
created_date=timezone.now(),
environment=environment,
identifier=identifier,
)


def get_transient_identity_and_traits(
environment: Environment,
sdk_trait_data: list[SDKTraitData],
) -> IdentityAndTraits:
return (
(
identity := _get_transient_identity(
environment=environment,
identifier=str(uuid.uuid4()),
)
),
identity.generate_traits(sdk_trait_data, persist=False),
)


def get_identified_transient_identity_and_traits(
environment: Environment,
identifier: str,
sdk_trait_data: list[SDKTraitData],
) -> IdentityAndTraits:
if identity := Identity.objects.filter(
environment=environment,
identifier=identifier,
).first():
for sdk_trait_data_item in sdk_trait_data:
sdk_trait_data_item["transient"] = True
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:
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()
)
9 changes: 9 additions & 0 deletions api/environments/sdk/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import typing

from typing_extensions import NotRequired


class SDKTraitData(typing.TypedDict):
trait_key: str
trait_value: typing.Any
transient: NotRequired[bool]
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ def existing_identity_identifier_data(
id="existing-identifier",
),
pytest.param({"identifier": "unseen"}, id="new-identifier"),
pytest.param({"identifier": ""}, id="blank-identifier"),
pytest.param({"identifier": None}, id="null-identifier"),
pytest.param({}, id="missing-identifier"),
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1183,6 +1183,64 @@ def test_post_identities__transient__no_persistence(
assert not Trait.objects.filter(trait_key=trait_key).exists()


@pytest.mark.parametrize(
"trait_transiency_data",
[
pytest.param({"transient": True}, id="trait-transient-true"),
pytest.param({"transient": False}, id="trait-transient-false"),
pytest.param({}, id="trait-default"),
],
)
def test_post_identities__existing__transient__no_persistence(
environment: Environment,
identity: Identity,
trait: Trait,
identity_featurestate: FeatureState,
api_client: APIClient,
trait_transiency_data: dict[str, Any],
) -> None:
# Given
feature_state_value = "identity override"
identity_featurestate.feature_state_value.string_value = feature_state_value
identity_featurestate.feature_state_value.save()

trait_key = "trait_key"

api_client.credentials(HTTP_X_ENVIRONMENT_KEY=environment.api_key)
url = reverse("api-v1:sdk-identities")
data = {
"identifier": identity.identifier,
"transient": True,
"traits": [
{"trait_key": trait_key, "trait_value": "bar", **trait_transiency_data}
],
}

# When
response = api_client.post(
url, data=json.dumps(data), content_type="application/json"
)

# Then
assert response.status_code == status.HTTP_200_OK
response_json = response.json()

assert response_json["flags"][0]["feature_state_value"] == feature_state_value
assert response_json["traits"][0]["trait_key"] == trait.trait_key
assert not response_json["traits"][0].get("transient")

assert response_json["traits"][1]["trait_key"] == trait_key
assert response_json["traits"][1]["transient"]

assert (
persisted_trait := Trait.objects.filter(
identity=identity, trait_key=trait.trait_key
).first()
)
assert persisted_trait.trait_value == trait.trait_value
assert not Trait.objects.filter(identity=identity, trait_key=trait_key).exists()


def test_post_identities__transient_traits__no_persistence(
environment: Environment,
api_client: APIClient,
Expand Down

0 comments on commit a1a34d7

Please sign in to comment.