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 @@
"""Repository packages for Directus-backed and native persistence."""

View File

@@ -0,0 +1 @@
"""Directus-backed repositories."""

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from typing import Any
from app.repositories.directus.client import DirectusClient
class DirectusActivityRepository:
"""Read Directus built-in /activity for experiment-related collections."""
TRACKED_COLLECTIONS = {"experiments", "variants", "experiment_releases", "variant_changes"}
def __init__(self, client: DirectusClient | None = None) -> None:
self.client = client or DirectusClient()
async def list_for_items(
self,
item_ids: list[str],
access_token: str | None = None,
) -> list[dict[str, Any]]:
"""Return Directus activity entries for the given item IDs across tracked collections."""
if not item_ids:
return []
params: dict[str, Any] = {
"filter[collection][_in]": ",".join(self.TRACKED_COLLECTIONS),
"filter[item][_in]": ",".join(item_ids),
"sort": "-timestamp",
"limit": "200",
"fields": "id,action,collection,item,timestamp,user.email,user.id",
}
return await self.client.list_activity(params=params, access_token=access_token)

View File

@@ -0,0 +1,84 @@
from __future__ import annotations
from typing import Any
from app.repositories.directus.client import DirectusClient
class DirectusCollectionRepository:
"""Base repository for content-like resources stored in Directus.
Subclasses only need to set `collection_name`.
This keeps collection access consistent and review-friendly.
"""
collection_name: str = ""
default_fields: list[str] | None = None
def __init__(self, client: DirectusClient | None = None) -> None:
self.client = client or DirectusClient()
async def list(
self,
params: dict[str, Any] | None = None,
access_token: str | None = None,
) -> list[dict[str, Any]]:
merged_params = self._merge_fields(params)
return await self.client.list_items(
self.collection_name,
params=merged_params,
access_token=access_token,
)
async def get(
self,
item_id: str,
params: dict[str, Any] | None = None,
access_token: str | None = None,
) -> dict[str, Any] | None:
merged_params = self._merge_fields(params)
return await self.client.get_item(
self.collection_name,
item_id=item_id,
params=merged_params,
access_token=access_token,
)
async def create(
self,
data: dict[str, Any],
access_token: str | None = None,
) -> dict[str, Any]:
return await self.client.create_item(
self.collection_name,
data=data,
access_token=access_token,
)
async def update(
self,
item_id: str,
data: dict[str, Any],
access_token: str | None = None,
) -> dict[str, Any]:
return await self.client.update_item(
self.collection_name,
item_id=item_id,
data=data,
access_token=access_token,
)
async def delete(self, item_id: str, access_token: str | None = None) -> None:
await self.client.delete_item(
self.collection_name,
item_id=item_id,
access_token=access_token,
)
def _merge_fields(self, params: dict[str, Any] | None) -> dict[str, Any] | None:
if not self.default_fields:
return params
merged = dict(params or {})
merged.setdefault("fields", ",".join(self.default_fields))
return merged

View File

@@ -0,0 +1,168 @@
from __future__ import annotations
from typing import Any
import httpx
from app.core.config import settings
class DirectusClient:
"""Small wrapper around Directus HTTP APIs used by FastAPI repositories.
We keep this adapter thin on purpose:
- business rules stay in services/use-cases
- repository classes decide which collection to access
- this client only knows how to talk to Directus
"""
def __init__(
self,
base_url: str | None = None,
token: str | None = None,
timeout: float = 15.0,
) -> None:
self.base_url = (base_url or settings.directus_base_url).rstrip("/")
self.token = token or settings.directus_admin_token
self.timeout = timeout or settings.directus_timeout
def _headers(self) -> dict[str, str]:
headers = {"Content-Type": "application/json"}
if self.token:
headers["Authorization"] = f"Bearer {self.token}"
return headers
def _headers_for_token(self, token: str | None = None) -> dict[str, str]:
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
elif self.token:
headers["Authorization"] = f"Bearer {self.token}"
return headers
async def list_items(
self,
collection: str,
params: dict[str, Any] | None = None,
access_token: str | None = None,
) -> list[dict[str, Any]]:
"""Fetch a collection list from Directus."""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/items/{collection}",
headers=self._headers_for_token(access_token),
params=params,
)
response.raise_for_status()
payload = response.json()
return payload.get("data", [])
async def get_item(
self,
collection: str,
item_id: str,
params: dict[str, Any] | None = None,
access_token: str | None = None,
) -> dict[str, Any] | None:
"""Fetch a single item from Directus."""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/items/{collection}/{item_id}",
headers=self._headers_for_token(access_token),
params=params,
)
if response.status_code == 404:
return None
response.raise_for_status()
payload = response.json()
return payload.get("data")
async def create_item(
self,
collection: str,
data: dict[str, Any],
access_token: str | None = None,
) -> dict[str, Any]:
"""Create a Directus item so activity/revision can still be preserved."""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/items/{collection}",
headers=self._headers_for_token(access_token),
json=data,
)
response.raise_for_status()
payload = response.json()
return payload["data"]
async def update_item(
self,
collection: str,
item_id: str,
data: dict[str, Any],
access_token: str | None = None,
) -> dict[str, Any]:
"""Update a Directus item after FastAPI finishes validation and orchestration."""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.patch(
f"{self.base_url}/items/{collection}/{item_id}",
headers=self._headers_for_token(access_token),
json=data,
)
response.raise_for_status()
payload = response.json()
return payload["data"]
async def delete_item(
self,
collection: str,
item_id: str,
access_token: str | None = None,
) -> None:
"""Delete a Directus item."""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.delete(
f"{self.base_url}/items/{collection}/{item_id}",
headers=self._headers_for_token(access_token),
)
response.raise_for_status()
async def list_activity(
self,
params: dict[str, Any] | None = None,
access_token: str | None = None,
) -> list[dict[str, Any]]:
"""Query Directus /activity — the built-in audit trail for all collections."""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/activity",
headers=self._headers_for_token(access_token),
params=params,
)
response.raise_for_status()
payload = response.json()
return payload.get("data", [])
async def get_current_user(
self,
access_token: str,
fields: list[str] | None = None,
) -> dict[str, Any] | None:
"""Resolve Directus /users/me with a bearer token issued by Directus."""
url = f"{self.base_url}/users/me"
if fields:
# Directus accepts comma-delimited `fields=...` query strings, but in
# this environment it falls back to returning only `{id}` when the
# commas are percent-encoded by the HTTP client. Build the query
# string explicitly so auth/role bootstrap remains deterministic.
url = f"{url}?fields={','.join(fields)}"
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
url,
headers=self._headers_for_token(access_token),
)
if response.status_code == 401:
return None
response.raise_for_status()
payload = response.json()
return payload.get("data")

View File

@@ -0,0 +1,18 @@
from app.repositories.directus.base import DirectusCollectionRepository
class ExperimentRepository(DirectusCollectionRepository):
"""Directus-backed repository for experiment content records."""
collection_name = "experiments"
default_fields = [
"id",
"site_id",
"experiment_key",
"name",
"module_type",
"status",
"start_at",
"end_at",
"targeting_config",
]

View File

@@ -0,0 +1,15 @@
from app.repositories.directus.base import DirectusCollectionRepository
class GoalRepository(DirectusCollectionRepository):
"""Directus-backed repository for goal definitions."""
collection_name = "goals"
default_fields = [
"id",
"site_id",
"goal_key",
"name",
"goal_type",
"match_rule",
]

View File

@@ -0,0 +1,8 @@
from app.repositories.directus.base import DirectusCollectionRepository
class MarketingCardRepository(DirectusCollectionRepository):
"""Directus-backed repository for legacy marketing card records."""
collection_name = "marketing_card"

View File

@@ -0,0 +1,14 @@
from app.repositories.directus.base import DirectusCollectionRepository
class ReleaseRepository(DirectusCollectionRepository):
"""Directus-backed repository for experiment release snapshots."""
collection_name = "experiment_releases"
default_fields = [
"id",
"experiment_id",
"version_no",
"status",
"runtime_payload",
]

View File

@@ -0,0 +1,16 @@
from app.repositories.directus.base import DirectusCollectionRepository
class SdkConfigRepository(DirectusCollectionRepository):
"""Directus-backed repository for SDK/snippet configuration records."""
collection_name = "sdk_configs"
default_fields = [
"id",
"site_id",
"sdk_key",
"status",
"origin_url",
"cdn_url",
"sdk_config",
]

View File

@@ -0,0 +1,16 @@
from app.repositories.directus.base import DirectusCollectionRepository
class SiteRepository(DirectusCollectionRepository):
"""Directus-backed repository for managed site records."""
collection_name = "sites"
default_fields = [
"id",
"site_key",
"name",
"primary_domain",
"status",
"site_settings",
"corporate_customer",
]

View File

@@ -0,0 +1,16 @@
from app.repositories.directus.base import DirectusCollectionRepository
class VariantChangeRepository(DirectusCollectionRepository):
"""Directus-backed repository for editor change records."""
collection_name = "variant_changes"
default_fields = [
"id",
"variant_id",
"change_type",
"selector_type",
"selector_value",
"sort_order",
"payload",
]

View File

@@ -0,0 +1,15 @@
from app.repositories.directus.base import DirectusCollectionRepository
class VariantRepository(DirectusCollectionRepository):
"""Directus-backed repository for experiment variant records."""
collection_name = "variants"
default_fields = [
"id",
"experiment_id",
"variant_key",
"name",
"traffic_weight",
"content_config",
]

View File

@@ -0,0 +1,2 @@
"""Native repositories for FastAPI-owned system data."""

View File

@@ -0,0 +1,24 @@
from __future__ import annotations
from collections.abc import Sequence
from app.domain.observability import AuditLogEntry
class AuditLogRepository:
"""Temporary in-process audit repository.
This keeps the service and data shape stable while we decide the final
native table/migration layout. It should later be replaced by a PostgreSQL-
backed implementation without changing the application layer API.
"""
_entries: list[AuditLogEntry] = []
async def add(self, entry: AuditLogEntry) -> AuditLogEntry:
self._entries.append(entry)
return entry
async def list_recent(self, limit: int = 100) -> Sequence[AuditLogEntry]:
return self._entries[-limit:]

View File

@@ -0,0 +1,24 @@
from __future__ import annotations
from app.domain.editor import EditorSession
class EditorSessionRepository:
"""Temporary in-memory repository for editor sessions."""
_sessions: dict[str, EditorSession] = {}
async def create(self, session: EditorSession) -> EditorSession:
self._sessions[session.id] = session
return session
async def get(self, session_id: str) -> EditorSession | None:
return self._sessions.get(session_id)
async def update(self, session: EditorSession) -> EditorSession:
self._sessions[session.id] = session
return session
async def delete(self, session_id: str) -> None:
self._sessions.pop(session_id, None)

View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from collections.abc import Sequence
from app.domain.observability import SystemEvent
class SystemEventRepository:
"""Temporary event repository for runtime/system telemetry."""
_events: list[SystemEvent] = []
async def add(self, event: SystemEvent) -> SystemEvent:
self._events.append(event)
return event
async def list_recent(self, limit: int = 100) -> Sequence[SystemEvent]:
return self._events[-limit:]