first commit

This commit is contained in:
Chris
2026-03-23 20:23:58 +08:00
commit 74d612aca1
3193 changed files with 692056 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
"""Domain layer types and mappers."""

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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

View 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)

View 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)

View 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,
)

View 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()}"

View 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"),
)

View 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()))

View 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),
)

View 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",
)