first commit
This commit is contained in:
2
backend/app/domain/__init__.py
Normal file
2
backend/app/domain/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Domain layer types and mappers."""
|
||||
|
||||
BIN
backend/app/domain/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
backend/app/domain/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/domain/__pycache__/activity.cpython-312.pyc
Normal file
BIN
backend/app/domain/__pycache__/activity.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/domain/__pycache__/admin.cpython-312.pyc
Normal file
BIN
backend/app/domain/__pycache__/admin.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/domain/__pycache__/auth.cpython-312.pyc
Normal file
BIN
backend/app/domain/__pycache__/auth.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/domain/__pycache__/editor.cpython-312.pyc
Normal file
BIN
backend/app/domain/__pycache__/editor.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/domain/__pycache__/editor_builder.cpython-312.pyc
Normal file
BIN
backend/app/domain/__pycache__/editor_builder.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/domain/__pycache__/keys.cpython-312.pyc
Normal file
BIN
backend/app/domain/__pycache__/keys.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/domain/__pycache__/mappers.cpython-312.pyc
Normal file
BIN
backend/app/domain/__pycache__/mappers.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/domain/__pycache__/observability.cpython-312.pyc
Normal file
BIN
backend/app/domain/__pycache__/observability.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/domain/__pycache__/permissions.cpython-312.pyc
Normal file
BIN
backend/app/domain/__pycache__/permissions.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/domain/__pycache__/runtime.cpython-312.pyc
Normal file
BIN
backend/app/domain/__pycache__/runtime.cpython-312.pyc
Normal file
Binary file not shown.
69
backend/app/domain/admin.py
Normal file
69
backend/app/domain/admin.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Site:
|
||||
id: str
|
||||
site_key: str
|
||||
name: str
|
||||
primary_domain: str
|
||||
status: str
|
||||
settings: dict[str, Any] | list[Any] | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Experiment:
|
||||
id: str
|
||||
site_id: str
|
||||
experiment_key: str
|
||||
name: str
|
||||
module_type: str
|
||||
status: str
|
||||
start_at: datetime | None = None
|
||||
end_at: datetime | None = None
|
||||
targeting_config: dict[str, Any] | list[Any] | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Variant:
|
||||
id: str
|
||||
experiment_id: str
|
||||
variant_key: str
|
||||
name: str
|
||||
traffic_weight: int
|
||||
content_config: dict[str, Any] | list[Any] | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ExperimentRelease:
|
||||
id: str
|
||||
experiment_id: str
|
||||
version_no: int
|
||||
status: str
|
||||
runtime_payload: dict[str, Any] | list[Any] | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Goal:
|
||||
id: str
|
||||
site_id: str
|
||||
goal_key: str
|
||||
name: str
|
||||
goal_type: str
|
||||
match_rule: dict[str, Any] | list[Any] | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SdkConfig:
|
||||
id: str
|
||||
site_id: str
|
||||
sdk_key: str
|
||||
status: str
|
||||
origin_url: str | None = None
|
||||
cdn_url: str | None = None
|
||||
sdk_config: dict[str, Any] | list[Any] | None = None
|
||||
|
||||
35
backend/app/domain/auth.py
Normal file
35
backend/app/domain/auth.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PermissionContext:
|
||||
"""Normalized business permission context derived from Directus identity."""
|
||||
|
||||
is_admin: bool = False
|
||||
can_manage_sites: bool = False
|
||||
can_manage_experiments: bool = False
|
||||
can_manage_variants: bool = False
|
||||
can_manage_releases: bool = False
|
||||
can_manage_goals: bool = False
|
||||
can_manage_sdk_configs: bool = False
|
||||
can_use_editor: bool = False
|
||||
can_read_runtime: bool = False
|
||||
raw_permissions: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AuthenticatedPrincipal:
|
||||
id: str
|
||||
email: str | None = None
|
||||
first_name: str | None = None
|
||||
status: str | None = None
|
||||
fb_token: str | None = None
|
||||
role_id: str | None = None
|
||||
role_name: str | None = None
|
||||
user_group_id: str | None = None
|
||||
user_group_name: str | None = None
|
||||
domain_permissions: list[str] = field(default_factory=list)
|
||||
permissions: PermissionContext = field(default_factory=PermissionContext)
|
||||
|
||||
51
backend/app/domain/editor.py
Normal file
51
backend/app/domain/editor.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class VariantChange:
|
||||
id: str
|
||||
variant_id: str
|
||||
change_type: str
|
||||
selector_type: str
|
||||
selector_value: str
|
||||
sort_order: int
|
||||
payload: dict[str, Any] | list[Any] | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class EditorSession:
|
||||
variant_id: str
|
||||
mode: str
|
||||
base_url: str
|
||||
actor_id: str
|
||||
actor_email: str | None = None
|
||||
status: str = "active"
|
||||
draft_changes: list[dict[str, Any]] = field(default_factory=list)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
updated_at: datetime = field(default_factory=utcnow)
|
||||
id: str = field(default_factory=lambda: str(uuid4()))
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RuntimeOperation:
|
||||
selector_type: str
|
||||
selector_value: str
|
||||
action: str
|
||||
payload: dict[str, Any] | list[Any] | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class VariantRuntimePayload:
|
||||
variant_id: str
|
||||
operations: list[RuntimeOperation]
|
||||
generated_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
33
backend/app/domain/editor_builder.py
Normal file
33
backend/app/domain/editor_builder.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.domain.editor import RuntimeOperation, VariantChange, VariantRuntimePayload
|
||||
|
||||
|
||||
def build_runtime_payload_from_changes(
|
||||
variant_id: str,
|
||||
changes: list[VariantChange],
|
||||
) -> VariantRuntimePayload:
|
||||
"""Convert editor-facing change records into runtime-facing operations.
|
||||
|
||||
The builder keeps the runtime contract stable even if the editor UI later
|
||||
changes how it stores intermediate draft state.
|
||||
"""
|
||||
|
||||
operations: list[RuntimeOperation] = []
|
||||
|
||||
for change in sorted(changes, key=lambda item: item.sort_order):
|
||||
action = change.payload.get("action") if isinstance(change.payload, dict) else None
|
||||
operations.append(
|
||||
RuntimeOperation(
|
||||
selector_type=change.selector_type,
|
||||
selector_value=change.selector_value,
|
||||
action=action or change.change_type,
|
||||
payload=change.payload,
|
||||
)
|
||||
)
|
||||
|
||||
return VariantRuntimePayload(
|
||||
variant_id=variant_id,
|
||||
operations=operations,
|
||||
)
|
||||
|
||||
17
backend/app/domain/keys.py
Normal file
17
backend/app/domain/keys.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
|
||||
def _timestamp_ms() -> str:
|
||||
return str(int(time.time() * 1000))
|
||||
|
||||
|
||||
def generate_experiment_key() -> str:
|
||||
"""Generate a system-managed experiment key, e.g. EX1742601234567."""
|
||||
return f"EX{_timestamp_ms()}"
|
||||
|
||||
|
||||
def generate_variant_key() -> str:
|
||||
"""Generate a system-managed variant key, e.g. VA1742601234567."""
|
||||
return f"VA{_timestamp_ms()}"
|
||||
73
backend/app/domain/mappers.py
Normal file
73
backend/app/domain/mappers.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.domain.admin import Experiment, ExperimentRelease, Goal, SdkConfig, Site, Variant
|
||||
|
||||
|
||||
def to_site(raw: dict) -> Site:
|
||||
return Site(
|
||||
id=str(raw["id"]),
|
||||
site_key=str(raw["site_key"]),
|
||||
name=str(raw["name"]),
|
||||
primary_domain=str(raw["primary_domain"]),
|
||||
status=str(raw["status"]),
|
||||
settings=raw.get("site_settings"),
|
||||
)
|
||||
|
||||
|
||||
def to_experiment(raw: dict) -> Experiment:
|
||||
return Experiment(
|
||||
id=str(raw["id"]),
|
||||
site_id=str(raw["site_id"]),
|
||||
experiment_key=str(raw["experiment_key"]),
|
||||
name=str(raw["name"]),
|
||||
module_type=str(raw["module_type"]),
|
||||
status=str(raw["status"]),
|
||||
start_at=raw.get("start_at"),
|
||||
end_at=raw.get("end_at"),
|
||||
targeting_config=raw.get("targeting_config"),
|
||||
)
|
||||
|
||||
|
||||
def to_variant(raw: dict) -> Variant:
|
||||
return Variant(
|
||||
id=str(raw["id"]),
|
||||
experiment_id=str(raw["experiment_id"]),
|
||||
variant_key=str(raw.get("variant_key", "")),
|
||||
name=str(raw.get("name", "")),
|
||||
traffic_weight=int(raw.get("traffic_weight", 0)),
|
||||
content_config=raw.get("content_config"),
|
||||
)
|
||||
|
||||
|
||||
def to_release(raw: dict) -> ExperimentRelease:
|
||||
return ExperimentRelease(
|
||||
id=str(raw["id"]),
|
||||
experiment_id=str(raw["experiment_id"]),
|
||||
version_no=int(raw["version_no"]),
|
||||
status=str(raw["status"]),
|
||||
runtime_payload=raw.get("runtime_payload"),
|
||||
)
|
||||
|
||||
|
||||
def to_goal(raw: dict) -> Goal:
|
||||
return Goal(
|
||||
id=str(raw["id"]),
|
||||
site_id=str(raw["site_id"]),
|
||||
goal_key=str(raw["goal_key"]),
|
||||
name=str(raw["name"]),
|
||||
goal_type=str(raw["goal_type"]),
|
||||
match_rule=raw.get("match_rule"),
|
||||
)
|
||||
|
||||
|
||||
def to_sdk_config(raw: dict) -> SdkConfig:
|
||||
return SdkConfig(
|
||||
id=str(raw["id"]),
|
||||
site_id=str(raw["site_id"]),
|
||||
sdk_key=str(raw["sdk_key"]),
|
||||
status=str(raw["status"]),
|
||||
origin_url=raw.get("origin_url"),
|
||||
cdn_url=raw.get("cdn_url"),
|
||||
sdk_config=raw.get("sdk_config"),
|
||||
)
|
||||
|
||||
47
backend/app/domain/observability.py
Normal file
47
backend/app/domain/observability.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AuditLogEntry:
|
||||
"""Business-level audit record for operator actions."""
|
||||
|
||||
action: str
|
||||
actor_id: str | None = None
|
||||
actor_email: str | None = None
|
||||
target_type: str | None = None
|
||||
target_id: str | None = None
|
||||
source: str = "fastapi"
|
||||
result: str = "accepted"
|
||||
meta: dict[str, Any] | list[Any] | None = None
|
||||
request_id: str | None = None
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
id: str = field(default_factory=lambda: str(uuid4()))
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SystemEvent:
|
||||
"""System/runtime event used for telemetry and forwarding pipelines."""
|
||||
|
||||
event_name: str
|
||||
category: str
|
||||
request_id: str | None = None
|
||||
site_id: str | None = None
|
||||
site_key: str | None = None
|
||||
experiment_id: str | None = None
|
||||
experiment_key: str | None = None
|
||||
variant_id: str | None = None
|
||||
variant_key: str | None = None
|
||||
visitor_id: str | None = None
|
||||
payload: dict[str, Any] | list[Any] | None = None
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
id: str = field(default_factory=lambda: str(uuid4()))
|
||||
|
||||
35
backend/app/domain/permissions.py
Normal file
35
backend/app/domain/permissions.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.domain.auth import PermissionContext
|
||||
|
||||
|
||||
def build_permission_context(
|
||||
role_name: str | None,
|
||||
domain_permissions: list[str],
|
||||
) -> PermissionContext:
|
||||
"""Translate Directus role/group permissions into product capabilities.
|
||||
|
||||
We keep the first version intentionally explicit instead of clever:
|
||||
reviewers can see exactly which strings unlock which capabilities.
|
||||
"""
|
||||
|
||||
normalized = {permission.strip().lower() for permission in domain_permissions if permission}
|
||||
role_normalized = (role_name or "").strip().lower()
|
||||
is_admin = role_normalized in {"administrator", "admin"}
|
||||
|
||||
def has_any(*values: str) -> bool:
|
||||
return is_admin or any(value in normalized for value in values)
|
||||
|
||||
return PermissionContext(
|
||||
is_admin=is_admin,
|
||||
can_manage_sites=has_any("sites.manage", "sites.write"),
|
||||
can_manage_experiments=has_any("experiments.manage", "experiments.write"),
|
||||
can_manage_variants=has_any("variants.manage", "variants.write"),
|
||||
can_manage_releases=has_any("releases.manage", "releases.write"),
|
||||
can_manage_goals=has_any("goals.manage", "goals.write"),
|
||||
can_manage_sdk_configs=has_any("sdk_configs.manage", "sdk_configs.write"),
|
||||
can_use_editor=has_any("editor.manage", "editor.use"),
|
||||
can_read_runtime=has_any("runtime.read", "runtime.manage"),
|
||||
raw_permissions=sorted(normalized),
|
||||
)
|
||||
|
||||
93
backend/app/domain/runtime.py
Normal file
93
backend/app/domain/runtime.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from hashlib import sha256
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RuntimeVariantCandidate:
|
||||
id: str
|
||||
variant_key: str
|
||||
traffic_weight: int
|
||||
content_config: dict[str, Any] | list[Any] | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AssignmentDecision:
|
||||
experiment_id: str
|
||||
experiment_key: str
|
||||
variant_id: str
|
||||
variant_key: str
|
||||
bucket: int
|
||||
reason: str = "weighted_assignment"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RuntimeExperimentContext:
|
||||
experiment_id: str
|
||||
experiment_key: str
|
||||
status: str
|
||||
site_key: str | None = None
|
||||
assignment_salt: str | None = None
|
||||
release_id: str | None = None
|
||||
release_version: int | None = None
|
||||
payload: dict[str, Any] | list[Any] | None = None
|
||||
variants: list[RuntimeVariantCandidate] = field(default_factory=list)
|
||||
|
||||
|
||||
def build_bucket(seed: str) -> int:
|
||||
"""Create a stable 0-9999 bucket from a visitor+experiment seed."""
|
||||
|
||||
digest = sha256(seed.encode("utf-8")).hexdigest()
|
||||
return int(digest[:8], 16) % 10000
|
||||
|
||||
|
||||
def choose_variant(
|
||||
*,
|
||||
experiment_id: str,
|
||||
experiment_key: str,
|
||||
visitor_id: str,
|
||||
site_key: str | None = None,
|
||||
assignment_salt: str | None = None,
|
||||
variants: list[RuntimeVariantCandidate],
|
||||
) -> AssignmentDecision | None:
|
||||
"""Deterministically assign a visitor based on weighted variants.
|
||||
|
||||
This first version intentionally lives in pure Python with no storage
|
||||
dependency so we can review the bucketing rule before wiring persistence.
|
||||
"""
|
||||
|
||||
active_variants = [variant for variant in variants if variant.traffic_weight > 0]
|
||||
if not active_variants:
|
||||
return None
|
||||
|
||||
total_weight = sum(variant.traffic_weight for variant in active_variants)
|
||||
if total_weight <= 0:
|
||||
return None
|
||||
|
||||
seed = f"{site_key or 'global'}:{assignment_salt or experiment_key}:{visitor_id}:{experiment_key}"
|
||||
bucket = build_bucket(seed)
|
||||
scaled_bucket = bucket % total_weight
|
||||
|
||||
running_total = 0
|
||||
for variant in active_variants:
|
||||
running_total += variant.traffic_weight
|
||||
if scaled_bucket < running_total:
|
||||
return AssignmentDecision(
|
||||
experiment_id=experiment_id,
|
||||
experiment_key=experiment_key,
|
||||
variant_id=variant.id,
|
||||
variant_key=variant.variant_key,
|
||||
bucket=bucket,
|
||||
)
|
||||
|
||||
chosen = active_variants[-1]
|
||||
return AssignmentDecision(
|
||||
experiment_id=experiment_id,
|
||||
experiment_key=experiment_key,
|
||||
variant_id=chosen.id,
|
||||
variant_key=chosen.variant_key,
|
||||
bucket=bucket,
|
||||
reason="weighted_assignment_fallback",
|
||||
)
|
||||
Reference in New Issue
Block a user