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 @@
"""Pydantic schemas used to keep API contracts explicit and review-friendly."""

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,178 @@
from __future__ import annotations
from dataclasses import asdict
from datetime import datetime
from typing import Any
from app.domain.admin import Experiment, ExperimentRelease, Goal, SdkConfig, Site, Variant
from app.schemas.common import ApiModel, ItemListResponse
class SiteRead(ApiModel):
id: str
site_key: str
name: str
primary_domain: str
status: str
site_settings: dict[str, Any] | list[Any] | None = None
class SiteListResponse(ItemListResponse):
items: list[SiteRead]
class ExperimentRead(ApiModel):
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
class ExperimentListResponse(ItemListResponse):
items: list[ExperimentRead]
class ExperimentCreate(ApiModel):
site_id: str
name: str
module_type: str = "visual"
status: str = "draft"
start_at: datetime | None = None
end_at: datetime | None = None
targeting_config: dict[str, Any] | None = None
class ExperimentUpdate(ApiModel):
name: str | None = None
module_type: str | None = None
status: str | None = None
start_at: datetime | None = None
end_at: datetime | None = None
targeting_config: dict[str, Any] | None = None
class VariantRead(ApiModel):
id: str
experiment_id: str
variant_key: str
name: str
traffic_weight: int
content_config: dict[str, Any] | list[Any] | None = None
class VariantListResponse(ItemListResponse):
items: list[VariantRead]
class VariantCreate(ApiModel):
experiment_id: str
name: str
traffic_weight: int = 0
content_config: dict[str, Any] | None = None
class VariantUpdate(ApiModel):
name: str | None = None
traffic_weight: int | None = None
content_config: dict[str, Any] | None = None
class ReleaseRead(ApiModel):
id: str
experiment_id: str
version_no: int
status: str
runtime_payload: dict[str, Any] | list[Any] | None = None
class ReleaseListResponse(ItemListResponse):
items: list[ReleaseRead]
class BuildReleaseRequest(ApiModel):
experiment_id: str
class ReleaseLifecycleResponse(ApiModel):
id: str
status: str
version_no: int
class GoalRead(ApiModel):
id: str
site_id: str
goal_key: str
name: str
goal_type: str
match_rule: dict[str, Any] | list[Any] | None = None
class GoalListResponse(ItemListResponse):
items: list[GoalRead]
class SdkConfigRead(ApiModel):
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
class SdkConfigListResponse(ItemListResponse):
items: list[SdkConfigRead]
def site_to_read_model(site: Site) -> SiteRead:
return SiteRead(
id=site.id,
site_key=site.site_key,
name=site.name,
primary_domain=site.primary_domain,
status=site.status,
site_settings=site.settings,
)
def experiment_to_read_model(experiment: Experiment) -> ExperimentRead:
return ExperimentRead(**asdict(experiment))
def variant_to_read_model(variant: Variant) -> VariantRead:
return VariantRead(**asdict(variant))
def release_to_read_model(release: ExperimentRelease) -> ReleaseRead:
return ReleaseRead(**asdict(release))
def goal_to_read_model(goal: Goal) -> GoalRead:
return GoalRead(**asdict(goal))
def sdk_config_to_read_model(sdk_config: SdkConfig) -> SdkConfigRead:
return SdkConfigRead(**asdict(sdk_config))
class ActivityLogRead(ApiModel):
id: int
action: str
action_label: str = ""
collection: str
collection_label: str = ""
item: str
timestamp: datetime | None = None
actor_email: str | None = None
actor_id: str | None = None
class ActivityLogListResponse(ApiModel):
items: list[ActivityLogRead]

View File

@@ -0,0 +1,40 @@
from __future__ import annotations
from typing import Any
from app.schemas.common import ApiModel
class PermissionContextRead(ApiModel):
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] = []
class AuthenticatedUser(ApiModel):
"""Normalized current-user payload used by FastAPI.
We keep the shape close to current frontend needs so migration can happen
incrementally without losing role/group context.
"""
id: str
email: str | None = None
first_name: str | None = None
status: str | None = None
fb_token: str | None = None
role: dict[str, Any] | None = None
user_group: dict[str, Any] | None = None
domain_permissions: list[str] = []
permissions: PermissionContextRead
class AuthMeResponse(ApiModel):
user: AuthenticatedUser

View File

@@ -0,0 +1,23 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, ConfigDict
class ApiModel(BaseModel):
"""Base schema with loose extra handling for Directus-backed records.
Directus can include additional fields such as accountability metadata or
relational expansions. Allowing extra keys keeps the DTO layer resilient
while we gradually tighten contracts collection by collection.
"""
model_config = ConfigDict(extra="allow")
class ItemListResponse(ApiModel):
"""Common list envelope so admin routes all respond in the same shape."""
items: list[Any]

View File

@@ -0,0 +1,76 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from app.schemas.common import ApiModel
class EditorChangeRead(ApiModel):
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
class EditorChangeWrite(ApiModel):
id: str | None = None
change_type: str
selector_type: str = "css"
selector_value: str
sort_order: int = 0
payload: dict[str, Any] | list[Any] | None = None
class EditorChangeListResponse(ApiModel):
items: list[EditorChangeRead]
class SaveVariantChangesRequest(ApiModel):
items: list[EditorChangeWrite]
class EditorSessionCreateRequest(ApiModel):
variant_id: str
base_url: str
mode: str = "edit"
class EditorSessionUpdateRequest(ApiModel):
status: str | None = None
draft_changes: list[dict[str, Any]] | None = None
class EditorSessionRead(ApiModel):
id: str
variant_id: str
mode: str
base_url: str
actor_id: str
actor_email: str | None = None
status: str
draft_changes: list[dict[str, Any]]
created_at: datetime
updated_at: datetime
class BuildPreviewRequest(ApiModel):
variant_id: str
items: list[EditorChangeWrite]
class PreviewOperationRead(ApiModel):
selector_type: str
selector_value: str
action: str
payload: dict[str, Any] | list[Any] | None = None
class BuildPreviewResponse(ApiModel):
variant_id: str
generated_at: datetime
operations: list[PreviewOperationRead]

View File

@@ -0,0 +1,20 @@
from __future__ import annotations
from app.schemas.common import ApiModel
class HealthStatusResponse(ApiModel):
status: str
class ReadinessDependency(ApiModel):
name: str
configured: bool
detail: str
class ReadinessStatusResponse(ApiModel):
status: str
app_env: str
app_name: str
dependencies: list[ReadinessDependency]

View File

@@ -0,0 +1,22 @@
from __future__ import annotations
from typing import Any
from app.schemas.common import ApiModel, ItemListResponse
class MarketingCardRead(ApiModel):
id: str
card_name: str | None = None
card_code: str | None = None
origin: str | None = None
landing_page: str | None = None
coupon_code_url: str | None = None
start_date: str | None = None
end_date: str | None = None
ose_user_updated: str | None = None
class MarketingCardListResponse(ItemListResponse):
items: list[MarketingCardRead]

View File

@@ -0,0 +1,86 @@
from __future__ import annotations
from typing import Any
from app.schemas.common import ApiModel
class RuntimeVariantCandidateInput(ApiModel):
id: str
variant_key: str
traffic_weight: int
content_config: dict[str, Any] | list[Any] | None = None
class RuntimeExperimentInput(ApiModel):
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[RuntimeVariantCandidateInput] = []
class RuntimeBootstrapRequest(ApiModel):
site_id: str | None = None
site_key: str | None = None
url: str
visitor_id: str
user_agent: str | None = None
class RuntimeBootstrapResponse(ApiModel):
site_id: str | None = None
site_key: str | None = None
url: str
visitor_id: str
candidate_experiments: list[RuntimeExperimentInput] = []
class RuntimeAssignRequest(ApiModel):
visitor_id: str
experiment: RuntimeExperimentInput
class RuntimeAssignResponse(ApiModel):
experiment_id: str
experiment_key: str
variant_id: str
variant_key: str
bucket: int
reason: str
class RuntimePayloadRequest(ApiModel):
visitor_id: str
experiment: RuntimeExperimentInput
class RuntimePayloadResponse(ApiModel):
experiment_id: str
experiment_key: str
release_id: str | None = None
release_version: int | None = None
assigned_variant_id: str | None = None
assigned_variant_key: str | None = None
payload: dict[str, Any] | list[Any] | None = None
class RuntimeEventRequest(ApiModel):
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
event_name: str
payload: dict[str, Any] | list[Any] | None = None
class RuntimeEventResponse(ApiModel):
accepted: bool
event_name: str