first commit
This commit is contained in:
1
backend/app/repositories/directus/__init__.py
Normal file
1
backend/app/repositories/directus/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Directus-backed repositories."""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
32
backend/app/repositories/directus/activity.py
Normal file
32
backend/app/repositories/directus/activity.py
Normal 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)
|
||||
84
backend/app/repositories/directus/base.py
Normal file
84
backend/app/repositories/directus/base.py
Normal 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
|
||||
168
backend/app/repositories/directus/client.py
Normal file
168
backend/app/repositories/directus/client.py
Normal 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")
|
||||
18
backend/app/repositories/directus/experiments.py
Normal file
18
backend/app/repositories/directus/experiments.py
Normal 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",
|
||||
]
|
||||
15
backend/app/repositories/directus/goals.py
Normal file
15
backend/app/repositories/directus/goals.py
Normal 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",
|
||||
]
|
||||
8
backend/app/repositories/directus/marketing_cards.py
Normal file
8
backend/app/repositories/directus/marketing_cards.py
Normal 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"
|
||||
|
||||
14
backend/app/repositories/directus/releases.py
Normal file
14
backend/app/repositories/directus/releases.py
Normal 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",
|
||||
]
|
||||
16
backend/app/repositories/directus/sdk_configs.py
Normal file
16
backend/app/repositories/directus/sdk_configs.py
Normal 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",
|
||||
]
|
||||
16
backend/app/repositories/directus/sites.py
Normal file
16
backend/app/repositories/directus/sites.py
Normal 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",
|
||||
]
|
||||
16
backend/app/repositories/directus/variant_changes.py
Normal file
16
backend/app/repositories/directus/variant_changes.py
Normal 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",
|
||||
]
|
||||
15
backend/app/repositories/directus/variants.py
Normal file
15
backend/app/repositories/directus/variants.py
Normal 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",
|
||||
]
|
||||
Reference in New Issue
Block a user