feat: initial public release
ConsentOS — a privacy-first cookie consent management platform. Self-hosted, source-available alternative to OneTrust, Cookiebot, and CookieYes. Full standards coverage (IAB TCF v2.2, GPP v1, Google Consent Mode v2, GPC, Shopify Customer Privacy API), multi-tenant architecture with role-based access, configuration cascade (system → org → group → site → region), dark-pattern detection in the scanner, and a tamper-evident consent record audit trail. This is the initial public release. Prior development history is retained internally. See README.md for the feature list, architecture overview, and quick-start instructions. Licensed under the Elastic Licence 2.0 — self-host freely; do not resell as a managed service.
This commit is contained in:
0
apps/api/src/routers/__init__.py
Normal file
0
apps/api/src/routers/__init__.py
Normal file
108
apps/api/src/routers/auth.py
Normal file
108
apps/api/src/routers/auth.py
Normal file
@@ -0,0 +1,108 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from jose import JWTError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.config.settings import get_settings
|
||||
from src.db import get_db
|
||||
from src.models.user import User
|
||||
from src.schemas.auth import CurrentUser, LoginRequest, RefreshRequest, TokenResponse
|
||||
from src.services.auth import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
verify_password,
|
||||
)
|
||||
from src.services.dependencies import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)) -> TokenResponse:
|
||||
"""Authenticate a user with email and password, return JWT tokens."""
|
||||
result = await db.execute(
|
||||
select(User).where(User.email == body.email, User.deleted_at.is_(None))
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None or not verify_password(body.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password",
|
||||
)
|
||||
|
||||
settings = get_settings()
|
||||
access_token = create_access_token(
|
||||
user_id=user.id,
|
||||
organisation_id=user.organisation_id,
|
||||
role=user.role,
|
||||
email=user.email,
|
||||
)
|
||||
refresh_token = create_refresh_token(
|
||||
user_id=user.id,
|
||||
organisation_id=user.organisation_id,
|
||||
)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
expires_in=settings.jwt_access_token_expire_minutes * 60,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
async def refresh(
|
||||
body: RefreshRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TokenResponse:
|
||||
"""Exchange a valid refresh token for a new access/refresh token pair."""
|
||||
try:
|
||||
payload = decode_token(body.refresh_token)
|
||||
except JWTError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired refresh token",
|
||||
) from exc
|
||||
|
||||
if payload.get("type") != "refresh":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token is not a refresh token",
|
||||
)
|
||||
|
||||
user_id = uuid.UUID(payload["sub"])
|
||||
result = await db.execute(select(User).where(User.id == user_id, User.deleted_at.is_(None)))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User no longer exists",
|
||||
)
|
||||
|
||||
settings = get_settings()
|
||||
access_token = create_access_token(
|
||||
user_id=user.id,
|
||||
organisation_id=user.organisation_id,
|
||||
role=user.role,
|
||||
email=user.email,
|
||||
)
|
||||
new_refresh_token = create_refresh_token(
|
||||
user_id=user.id,
|
||||
organisation_id=user.organisation_id,
|
||||
)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=new_refresh_token,
|
||||
expires_in=settings.jwt_access_token_expire_minutes * 60,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=CurrentUser)
|
||||
async def get_me(current_user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
|
||||
"""Return the currently authenticated user's profile from the JWT."""
|
||||
return current_user
|
||||
135
apps/api/src/routers/compliance.py
Normal file
135
apps/api/src/routers/compliance.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""Compliance checking endpoints.
|
||||
|
||||
Evaluates a site's configuration against regulatory frameworks (GDPR, CNIL,
|
||||
CCPA, ePrivacy, LGPD) and returns per-framework compliance reports with scores,
|
||||
issues, and recommendations.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.db import get_db
|
||||
from src.models.cookie import Cookie
|
||||
from src.models.site import Site
|
||||
from src.models.site_config import SiteConfig
|
||||
from src.schemas.compliance import (
|
||||
ComplianceCheckRequest,
|
||||
ComplianceCheckResponse,
|
||||
Framework,
|
||||
)
|
||||
from src.services.compliance import (
|
||||
SiteContext,
|
||||
calculate_overall_score,
|
||||
run_compliance_check,
|
||||
)
|
||||
from src.services.dependencies import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/compliance", tags=["compliance"])
|
||||
|
||||
|
||||
async def _build_site_context(
|
||||
site_id: uuid.UUID,
|
||||
db: AsyncSession,
|
||||
) -> SiteContext:
|
||||
"""Load site config and cookie stats to build a SiteContext."""
|
||||
# Fetch site config
|
||||
result = await db.execute(
|
||||
select(SiteConfig).where(
|
||||
SiteConfig.site_id == site_id,
|
||||
SiteConfig.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
# Fetch cookie statistics
|
||||
total_q = await db.execute(
|
||||
select(func.count()).select_from(Cookie).where(Cookie.site_id == site_id)
|
||||
)
|
||||
total_cookies = total_q.scalar() or 0
|
||||
|
||||
uncat_q = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(Cookie)
|
||||
.where(
|
||||
Cookie.site_id == site_id,
|
||||
Cookie.category_id.is_(None),
|
||||
)
|
||||
)
|
||||
uncategorised_cookies = uncat_q.scalar() or 0
|
||||
|
||||
if config is None:
|
||||
return SiteContext(
|
||||
total_cookies=total_cookies,
|
||||
uncategorised_cookies=uncategorised_cookies,
|
||||
)
|
||||
|
||||
banner_config = config.banner_config or {}
|
||||
return SiteContext(
|
||||
blocking_mode=config.blocking_mode,
|
||||
regional_modes=config.regional_modes,
|
||||
tcf_enabled=config.tcf_enabled,
|
||||
gcm_enabled=config.gcm_enabled,
|
||||
consent_expiry_days=config.consent_expiry_days,
|
||||
privacy_policy_url=config.privacy_policy_url,
|
||||
display_mode=config.display_mode,
|
||||
banner_config=config.banner_config,
|
||||
total_cookies=total_cookies,
|
||||
uncategorised_cookies=uncategorised_cookies,
|
||||
has_reject_button=banner_config.get("show_reject_all", True),
|
||||
has_granular_choices=banner_config.get("show_category_toggles", True),
|
||||
has_cookie_wall=banner_config.get("cookie_wall", False),
|
||||
pre_ticked_boxes=banner_config.get("pre_ticked", False),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/check/{site_id}",
|
||||
response_model=ComplianceCheckResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
async def check_compliance(
|
||||
site_id: uuid.UUID,
|
||||
body: ComplianceCheckRequest | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user=Depends(get_current_user),
|
||||
) -> ComplianceCheckResponse:
|
||||
"""Run compliance checks against a site's configuration."""
|
||||
# Verify site exists
|
||||
site_result = await db.execute(
|
||||
select(Site).where(Site.id == site_id, Site.deleted_at.is_(None))
|
||||
)
|
||||
site = site_result.scalar_one_or_none()
|
||||
if site is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Site not found",
|
||||
)
|
||||
|
||||
ctx = await _build_site_context(site_id, db)
|
||||
frameworks = body.frameworks if body else None
|
||||
results = run_compliance_check(ctx, frameworks)
|
||||
overall_score = calculate_overall_score(results)
|
||||
|
||||
return ComplianceCheckResponse(
|
||||
site_id=str(site_id),
|
||||
results=results,
|
||||
overall_score=overall_score,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/frameworks", response_model=list[dict])
|
||||
async def list_frameworks() -> list[dict]:
|
||||
"""List all available compliance frameworks."""
|
||||
return [
|
||||
{"id": fw.value, "name": fw.value.upper(), "description": desc}
|
||||
for fw, desc in [
|
||||
(Framework.GDPR, "EU General Data Protection Regulation"),
|
||||
(Framework.CNIL, "French Data Protection Authority (stricter GDPR)"),
|
||||
(Framework.CCPA, "California Consumer Privacy Act / CPRA"),
|
||||
(Framework.EPRIVACY, "EU ePrivacy Directive"),
|
||||
(Framework.LGPD, "Brazilian General Data Protection Law"),
|
||||
]
|
||||
]
|
||||
324
apps/api/src/routers/config.py
Normal file
324
apps/api/src/routers/config.py
Normal file
@@ -0,0 +1,324 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.db import get_db
|
||||
from src.extensions.registry import get_registry
|
||||
from src.models.org_config import OrgConfig
|
||||
from src.models.site import Site
|
||||
from src.models.site_config import SiteConfig
|
||||
from src.models.site_group_config import SiteGroupConfig
|
||||
from src.schemas.auth import CurrentUser
|
||||
from src.schemas.site import SiteConfigResponse
|
||||
from src.services.config_resolver import (
|
||||
CONFIG_FIELDS,
|
||||
build_public_config,
|
||||
orm_to_config_dict,
|
||||
resolve_config,
|
||||
)
|
||||
from src.services.dependencies import require_role
|
||||
from src.services.geoip import detect_region
|
||||
from src.services.publisher import publish_site_config
|
||||
|
||||
router = APIRouter(prefix="/config", tags=["config"])
|
||||
|
||||
|
||||
@router.get("/sites/{site_id}", response_model=SiteConfigResponse)
|
||||
async def get_public_site_config(
|
||||
site_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SiteConfig:
|
||||
"""Public endpoint: retrieve site config for the banner script. No auth required."""
|
||||
result = await db.execute(
|
||||
select(SiteConfig)
|
||||
.join(Site)
|
||||
.where(
|
||||
SiteConfig.site_id == site_id,
|
||||
Site.is_active.is_(True),
|
||||
Site.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if config is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Site configuration not found",
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
@router.get("/sites/{site_id}/resolved")
|
||||
async def get_resolved_config(
|
||||
site_id: uuid.UUID,
|
||||
region: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Public endpoint: retrieve fully resolved config with regional overrides applied.
|
||||
|
||||
Applies the full cascade: System → Org → Group → Site → Regional.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(SiteConfig)
|
||||
.join(Site)
|
||||
.where(
|
||||
SiteConfig.site_id == site_id,
|
||||
Site.is_active.is_(True),
|
||||
Site.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if config is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Site configuration not found",
|
||||
)
|
||||
|
||||
config_dict = orm_to_config_dict(config, include_id=True)
|
||||
|
||||
# Load org defaults via the site
|
||||
org_id = await _get_site_org_id(site_id, db)
|
||||
org_defaults = await _load_org_defaults(org_id, db) if org_id else None
|
||||
|
||||
# Load site group defaults
|
||||
group_id = await _get_site_group_id(site_id, db)
|
||||
group_defaults = await _load_group_defaults(group_id, db) if group_id else None
|
||||
|
||||
resolved = resolve_config(
|
||||
config_dict,
|
||||
org_defaults=org_defaults,
|
||||
group_defaults=group_defaults,
|
||||
region=region,
|
||||
)
|
||||
return build_public_config(str(site_id), resolved)
|
||||
|
||||
|
||||
@router.get("/sites/{site_id}/geo-resolved")
|
||||
async def get_geo_resolved_config(
|
||||
site_id: uuid.UUID,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Public endpoint: resolve config using the visitor's detected region.
|
||||
|
||||
Detects the visitor's region from CDN headers or IP geolocation,
|
||||
then applies regional blocking mode overrides automatically.
|
||||
Uses the full cascade: System → Org → Group → Site → Regional.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(SiteConfig)
|
||||
.join(Site)
|
||||
.where(
|
||||
SiteConfig.site_id == site_id,
|
||||
Site.is_active.is_(True),
|
||||
Site.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if config is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Site configuration not found",
|
||||
)
|
||||
|
||||
# Detect region from request
|
||||
geo = await detect_region(request)
|
||||
|
||||
config_dict = orm_to_config_dict(config, include_id=True)
|
||||
org_id = await _get_site_org_id(site_id, db)
|
||||
org_defaults = await _load_org_defaults(org_id, db) if org_id else None
|
||||
group_id = await _get_site_group_id(site_id, db)
|
||||
group_defaults = await _load_group_defaults(group_id, db) if group_id else None
|
||||
|
||||
resolved = resolve_config(
|
||||
config_dict,
|
||||
org_defaults=org_defaults,
|
||||
group_defaults=group_defaults,
|
||||
region=geo.region,
|
||||
)
|
||||
public = build_public_config(str(site_id), resolved)
|
||||
|
||||
# Include detected geo info so the banner can use it
|
||||
public["detected_country"] = geo.country_code
|
||||
public["detected_region"] = geo.region
|
||||
|
||||
return public
|
||||
|
||||
|
||||
@router.get("/geo")
|
||||
async def get_visitor_geo(request: Request) -> dict:
|
||||
"""Public endpoint: return the detected region for the current visitor.
|
||||
|
||||
Useful for banner scripts that need to know the region before
|
||||
fetching the full config.
|
||||
"""
|
||||
geo = await detect_region(request)
|
||||
return {
|
||||
"country_code": geo.country_code,
|
||||
"region": geo.region,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/sites/{site_id}/inheritance")
|
||||
async def get_config_inheritance(
|
||||
site_id: uuid.UUID,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Return the full config inheritance chain for a site.
|
||||
|
||||
Shows the value at each level so the UI can display where each setting
|
||||
comes from: system, org, group, or site.
|
||||
"""
|
||||
from src.services.config_resolver import SYSTEM_DEFAULTS
|
||||
|
||||
result = await db.execute(
|
||||
select(SiteConfig)
|
||||
.join(Site)
|
||||
.where(
|
||||
SiteConfig.site_id == site_id,
|
||||
Site.organisation_id == current_user.organisation_id,
|
||||
Site.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if config is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Site configuration not found",
|
||||
)
|
||||
|
||||
site_dict = orm_to_config_dict(config)
|
||||
org_defaults = await _load_org_defaults(current_user.organisation_id, db)
|
||||
group_id = await _get_site_group_id(site_id, db)
|
||||
group_defaults = await _load_group_defaults(group_id, db) if group_id else None
|
||||
|
||||
resolved = resolve_config(
|
||||
site_dict,
|
||||
org_defaults=org_defaults,
|
||||
group_defaults=group_defaults,
|
||||
)
|
||||
|
||||
# For each config field, determine the source
|
||||
sources: dict[str, dict] = {}
|
||||
for field in CONFIG_FIELDS:
|
||||
site_val = site_dict.get(field)
|
||||
group_val = group_defaults.get(field) if group_defaults else None
|
||||
org_val = org_defaults.get(field) if org_defaults else None
|
||||
system_val = SYSTEM_DEFAULTS.get(field)
|
||||
|
||||
# Determine effective source (highest priority non-None wins)
|
||||
if site_val is not None:
|
||||
source = "site"
|
||||
elif group_val is not None:
|
||||
source = "group"
|
||||
elif org_val is not None:
|
||||
source = "org"
|
||||
elif system_val is not None:
|
||||
source = "system"
|
||||
else:
|
||||
source = "system"
|
||||
|
||||
sources[field] = {
|
||||
"resolved_value": resolved.get(field),
|
||||
"source": source,
|
||||
"site_value": site_val,
|
||||
"group_value": group_val,
|
||||
"org_value": org_val,
|
||||
"system_value": system_val,
|
||||
}
|
||||
|
||||
return {
|
||||
"site_id": str(site_id),
|
||||
"site_group_id": str(group_id) if group_id else None,
|
||||
"fields": sources,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/sites/{site_id}/publish")
|
||||
async def publish_config(
|
||||
site_id: uuid.UUID,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Publish fully-resolved site config to CDN. Requires admin role."""
|
||||
result = await db.execute(
|
||||
select(SiteConfig)
|
||||
.join(Site)
|
||||
.where(
|
||||
SiteConfig.site_id == site_id,
|
||||
Site.organisation_id == current_user.organisation_id,
|
||||
Site.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if config is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Site configuration not found",
|
||||
)
|
||||
|
||||
config_dict = orm_to_config_dict(config, include_id=True)
|
||||
org_defaults = await _load_org_defaults(current_user.organisation_id, db)
|
||||
group_id = await _get_site_group_id(site_id, db)
|
||||
group_defaults = await _load_group_defaults(group_id, db) if group_id else None
|
||||
resolved = resolve_config(
|
||||
config_dict,
|
||||
org_defaults=org_defaults,
|
||||
group_defaults=group_defaults,
|
||||
)
|
||||
|
||||
# Allow extensions to enrich the published config (e.g. A/B test data)
|
||||
registry = get_registry()
|
||||
for enricher in registry.config_enrichers:
|
||||
await enricher(site_id, db, resolved)
|
||||
|
||||
publish_result = await publish_site_config(str(site_id), resolved)
|
||||
|
||||
if not publish_result.success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Publish failed: {publish_result.error}",
|
||||
)
|
||||
|
||||
return {
|
||||
"published": True,
|
||||
"path": publish_result.path,
|
||||
"published_at": publish_result.published_at,
|
||||
}
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _get_site_org_id(site_id: uuid.UUID, db: AsyncSession) -> uuid.UUID | None:
|
||||
"""Look up the organisation_id for a site."""
|
||||
result = await db.execute(select(Site.organisation_id).where(Site.id == site_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def _get_site_group_id(site_id: uuid.UUID, db: AsyncSession) -> uuid.UUID | None:
|
||||
"""Look up the site_group_id for a site."""
|
||||
result = await db.execute(select(Site.site_group_id).where(Site.id == site_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def _load_org_defaults(organisation_id: uuid.UUID, db: AsyncSession) -> dict | None:
|
||||
"""Load the org-level config defaults, or None if not set."""
|
||||
result = await db.execute(select(OrgConfig).where(OrgConfig.organisation_id == organisation_id))
|
||||
org_config = result.scalar_one_or_none()
|
||||
if org_config is None:
|
||||
return None
|
||||
return orm_to_config_dict(org_config)
|
||||
|
||||
|
||||
async def _load_group_defaults(group_id: uuid.UUID, db: AsyncSession) -> dict | None:
|
||||
"""Load the site-group-level config defaults, or None if not set."""
|
||||
result = await db.execute(
|
||||
select(SiteGroupConfig).where(SiteGroupConfig.site_group_id == group_id)
|
||||
)
|
||||
group_config = result.scalar_one_or_none()
|
||||
if group_config is None:
|
||||
return None
|
||||
return orm_to_config_dict(group_config)
|
||||
125
apps/api/src/routers/consent.py
Normal file
125
apps/api/src/routers/consent.py
Normal file
@@ -0,0 +1,125 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.db import get_db
|
||||
from src.extensions.registry import get_registry
|
||||
from src.models.consent import ConsentRecord
|
||||
from src.models.site import Site
|
||||
from src.schemas.auth import CurrentUser
|
||||
from src.schemas.consent import (
|
||||
ConsentRecordCreate,
|
||||
ConsentRecordResponse,
|
||||
ConsentVerifyResponse,
|
||||
)
|
||||
from src.services.dependencies import require_role
|
||||
from src.services.pseudonymisation import pseudonymise
|
||||
|
||||
router = APIRouter(prefix="/consent", tags=["consent"])
|
||||
|
||||
|
||||
@router.post("/", response_model=ConsentRecordResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def record_consent(
|
||||
body: ConsentRecordCreate,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ConsentRecord:
|
||||
"""Record a consent event from the banner. Public endpoint (no auth required)."""
|
||||
# Pseudonymise IP and user agent with HMAC so the resulting values
|
||||
# cannot be reversed without the server-side secret.
|
||||
client_ip = request.client.host if request.client else ""
|
||||
user_agent = request.headers.get("user-agent", "")
|
||||
|
||||
record = ConsentRecord(
|
||||
site_id=body.site_id,
|
||||
visitor_id=body.visitor_id,
|
||||
ip_hash=pseudonymise(client_ip),
|
||||
user_agent_hash=pseudonymise(user_agent),
|
||||
action=body.action,
|
||||
categories_accepted=body.categories_accepted,
|
||||
categories_rejected=body.categories_rejected,
|
||||
tc_string=body.tc_string,
|
||||
gcm_state=body.gcm_state,
|
||||
page_url=body.page_url,
|
||||
country_code=body.country_code,
|
||||
region_code=body.region_code,
|
||||
)
|
||||
db.add(record)
|
||||
await db.flush()
|
||||
await db.refresh(record)
|
||||
|
||||
# Invoke any registered post-record hooks (EE consent receipts, etc.)
|
||||
for hook in get_registry().consent_record_hooks:
|
||||
await hook(db, record)
|
||||
|
||||
return record
|
||||
|
||||
|
||||
async def _load_record_for_org(
|
||||
consent_id: uuid.UUID,
|
||||
current_user: CurrentUser,
|
||||
db: AsyncSession,
|
||||
) -> ConsentRecord:
|
||||
"""Load a consent record and enforce tenant isolation.
|
||||
|
||||
The record's site must belong to the caller's organisation. A record
|
||||
from another tenant returns 404 rather than 403 so we don't leak
|
||||
existence across tenants.
|
||||
"""
|
||||
stmt = (
|
||||
select(ConsentRecord)
|
||||
.join(Site, Site.id == ConsentRecord.site_id)
|
||||
.where(
|
||||
ConsentRecord.id == consent_id,
|
||||
Site.organisation_id == current_user.organisation_id,
|
||||
Site.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
record = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if record is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Consent record not found",
|
||||
)
|
||||
return record
|
||||
|
||||
|
||||
@router.get("/{consent_id}", response_model=ConsentRecordResponse)
|
||||
async def get_consent(
|
||||
consent_id: uuid.UUID,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ConsentRecord:
|
||||
"""Retrieve a consent record by ID.
|
||||
|
||||
Requires authentication and tenant membership. Consent records
|
||||
contain PII-adjacent data (hashed IP, page URL, category decisions)
|
||||
and must not be readable by anyone holding a record UUID.
|
||||
"""
|
||||
return await _load_record_for_org(consent_id, current_user, db)
|
||||
|
||||
|
||||
@router.get("/verify/{consent_id}", response_model=ConsentVerifyResponse)
|
||||
async def verify_consent(
|
||||
consent_id: uuid.UUID,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Verify that a consent record exists (audit proof).
|
||||
|
||||
Same tenant-scoped auth as :func:`get_consent` — proof of consent
|
||||
is only meaningful to the organisation that owns the site, and
|
||||
leaking existence to arbitrary callers enables enumeration.
|
||||
"""
|
||||
record = await _load_record_for_org(consent_id, current_user, db)
|
||||
return {
|
||||
"id": record.id,
|
||||
"site_id": record.site_id,
|
||||
"visitor_id": record.visitor_id,
|
||||
"action": record.action,
|
||||
"categories_accepted": record.categories_accepted,
|
||||
"consented_at": record.consented_at,
|
||||
"valid": True,
|
||||
}
|
||||
582
apps/api/src/routers/cookies.py
Normal file
582
apps/api/src/routers/cookies.py
Normal file
@@ -0,0 +1,582 @@
|
||||
"""Cookie category, cookie, and allow-list management endpoints."""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.db import get_db
|
||||
from src.models.cookie import Cookie, CookieAllowListEntry, CookieCategory, KnownCookie
|
||||
from src.models.site import Site
|
||||
from src.schemas.auth import CurrentUser
|
||||
from src.schemas.cookie import (
|
||||
AllowListEntryCreate,
|
||||
AllowListEntryResponse,
|
||||
AllowListEntryUpdate,
|
||||
ClassificationResultResponse,
|
||||
ClassifySingleRequest,
|
||||
ClassifySiteResponse,
|
||||
CookieCategoryResponse,
|
||||
CookieCreate,
|
||||
CookieResponse,
|
||||
CookieUpdate,
|
||||
KnownCookieCreate,
|
||||
KnownCookieResponse,
|
||||
KnownCookieUpdate,
|
||||
ReviewStatus,
|
||||
)
|
||||
from src.services.classification import classify_single_cookie, classify_site_cookies
|
||||
from src.services.dependencies import get_current_user, require_role
|
||||
|
||||
router = APIRouter(prefix="/cookies", tags=["cookies"])
|
||||
|
||||
|
||||
# ── Cookie categories (read-only, seeded by migration) ──────────────
|
||||
|
||||
|
||||
@router.get("/categories", response_model=list[CookieCategoryResponse])
|
||||
async def list_categories(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[CookieCategory]:
|
||||
"""List all cookie categories. Public endpoint used by banner and admin."""
|
||||
result = await db.execute(select(CookieCategory).order_by(CookieCategory.display_order))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/categories/{category_id}", response_model=CookieCategoryResponse)
|
||||
async def get_category(
|
||||
category_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CookieCategory:
|
||||
"""Get a single cookie category by ID."""
|
||||
result = await db.execute(select(CookieCategory).where(CookieCategory.id == category_id))
|
||||
category = result.scalar_one_or_none()
|
||||
if not category:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found")
|
||||
return category
|
||||
|
||||
|
||||
# ── Cookies per site ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _get_org_site(
|
||||
site_id: uuid.UUID,
|
||||
current_user: CurrentUser,
|
||||
db: AsyncSession,
|
||||
) -> Site:
|
||||
"""Fetch a site ensuring it belongs to the user's organisation."""
|
||||
result = await db.execute(
|
||||
select(Site).where(
|
||||
Site.id == site_id,
|
||||
Site.organisation_id == current_user.organisation_id,
|
||||
Site.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
site = result.scalar_one_or_none()
|
||||
if not site:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Site not found")
|
||||
return site
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sites/{site_id}",
|
||||
response_model=list[CookieResponse],
|
||||
)
|
||||
async def list_cookies(
|
||||
site_id: uuid.UUID,
|
||||
review_status: ReviewStatus | None = Query(None),
|
||||
category_id: uuid.UUID | None = Query(None),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[Cookie]:
|
||||
"""List cookies discovered on a site, with optional filters."""
|
||||
await _get_org_site(site_id, current_user, db)
|
||||
|
||||
query = select(Cookie).where(Cookie.site_id == site_id)
|
||||
if review_status:
|
||||
query = query.where(Cookie.review_status == review_status.value)
|
||||
if category_id:
|
||||
query = query.where(Cookie.category_id == category_id)
|
||||
query = query.order_by(Cookie.name)
|
||||
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sites/{site_id}",
|
||||
response_model=CookieResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_cookie(
|
||||
site_id: uuid.UUID,
|
||||
body: CookieCreate,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Cookie:
|
||||
"""Create a cookie record for a site (manual entry or from scanner)."""
|
||||
await _get_org_site(site_id, current_user, db)
|
||||
|
||||
# Validate category if provided
|
||||
if body.category_id:
|
||||
cat = await db.execute(select(CookieCategory).where(CookieCategory.id == body.category_id))
|
||||
if not cat.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid category_id",
|
||||
)
|
||||
|
||||
cookie = Cookie(
|
||||
site_id=site_id,
|
||||
**body.model_dump(),
|
||||
first_seen_at=datetime.now(UTC).isoformat(),
|
||||
last_seen_at=datetime.now(UTC).isoformat(),
|
||||
)
|
||||
db.add(cookie)
|
||||
await db.flush()
|
||||
await db.refresh(cookie)
|
||||
return cookie
|
||||
|
||||
|
||||
@router.get("/sites/{site_id}/summary")
|
||||
async def cookie_summary(
|
||||
site_id: uuid.UUID,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Get a summary of cookies for a site (counts by status and category)."""
|
||||
await _get_org_site(site_id, current_user, db)
|
||||
|
||||
# Count by review status
|
||||
status_result = await db.execute(
|
||||
select(Cookie.review_status, func.count(Cookie.id))
|
||||
.where(Cookie.site_id == site_id)
|
||||
.group_by(Cookie.review_status)
|
||||
)
|
||||
by_status = {row[0]: row[1] for row in status_result.all()}
|
||||
|
||||
# Count by category
|
||||
cat_result = await db.execute(
|
||||
select(CookieCategory.slug, func.count(Cookie.id))
|
||||
.outerjoin(Cookie, Cookie.category_id == CookieCategory.id)
|
||||
.where(Cookie.site_id == site_id)
|
||||
.group_by(CookieCategory.slug)
|
||||
)
|
||||
by_category = {row[0]: row[1] for row in cat_result.all()}
|
||||
|
||||
# Uncategorised count
|
||||
uncat_result = await db.execute(
|
||||
select(func.count(Cookie.id)).where(Cookie.site_id == site_id, Cookie.category_id.is_(None))
|
||||
)
|
||||
uncategorised = uncat_result.scalar() or 0
|
||||
|
||||
return {
|
||||
"total": sum(by_status.values()),
|
||||
"by_status": by_status,
|
||||
"by_category": by_category,
|
||||
"uncategorised": uncategorised,
|
||||
}
|
||||
|
||||
|
||||
# ── Allow-list per site ──────────────────────────────────────────────
|
||||
# (Must be defined before {cookie_id} routes to avoid path conflicts)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sites/{site_id}/allow-list",
|
||||
response_model=list[AllowListEntryResponse],
|
||||
)
|
||||
async def list_allow_list(
|
||||
site_id: uuid.UUID,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[CookieAllowListEntry]:
|
||||
"""List all allow-list entries for a site."""
|
||||
await _get_org_site(site_id, current_user, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(CookieAllowListEntry)
|
||||
.where(CookieAllowListEntry.site_id == site_id)
|
||||
.order_by(CookieAllowListEntry.name_pattern)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sites/{site_id}/allow-list",
|
||||
response_model=AllowListEntryResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_allow_list_entry(
|
||||
site_id: uuid.UUID,
|
||||
body: AllowListEntryCreate,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CookieAllowListEntry:
|
||||
"""Add a cookie pattern to the allow-list for a site."""
|
||||
await _get_org_site(site_id, current_user, db)
|
||||
|
||||
# Validate category
|
||||
cat = await db.execute(select(CookieCategory).where(CookieCategory.id == body.category_id))
|
||||
if not cat.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid category_id",
|
||||
)
|
||||
|
||||
entry = CookieAllowListEntry(
|
||||
site_id=site_id,
|
||||
**body.model_dump(),
|
||||
)
|
||||
db.add(entry)
|
||||
await db.flush()
|
||||
await db.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/sites/{site_id}/allow-list/{entry_id}",
|
||||
response_model=AllowListEntryResponse,
|
||||
)
|
||||
async def update_allow_list_entry(
|
||||
site_id: uuid.UUID,
|
||||
entry_id: uuid.UUID,
|
||||
body: AllowListEntryUpdate,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CookieAllowListEntry:
|
||||
"""Update an allow-list entry."""
|
||||
await _get_org_site(site_id, current_user, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(CookieAllowListEntry).where(
|
||||
CookieAllowListEntry.id == entry_id,
|
||||
CookieAllowListEntry.site_id == site_id,
|
||||
)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
if not entry:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Allow-list entry not found",
|
||||
)
|
||||
|
||||
updates = body.model_dump(exclude_unset=True)
|
||||
|
||||
if "category_id" in updates and updates["category_id"] is not None:
|
||||
cat = await db.execute(
|
||||
select(CookieCategory).where(CookieCategory.id == updates["category_id"])
|
||||
)
|
||||
if not cat.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid category_id",
|
||||
)
|
||||
|
||||
for field, value in updates.items():
|
||||
setattr(entry, field, value)
|
||||
entry.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/sites/{site_id}/allow-list/{entry_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
async def delete_allow_list_entry(
|
||||
site_id: uuid.UUID,
|
||||
entry_id: uuid.UUID,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
"""Remove an entry from the allow-list."""
|
||||
await _get_org_site(site_id, current_user, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(CookieAllowListEntry).where(
|
||||
CookieAllowListEntry.id == entry_id,
|
||||
CookieAllowListEntry.site_id == site_id,
|
||||
)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
if not entry:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Allow-list entry not found",
|
||||
)
|
||||
|
||||
await db.delete(entry)
|
||||
|
||||
|
||||
# ── Individual cookie by ID (must come after /summary and /allow-list) ──
|
||||
|
||||
|
||||
@router.get("/sites/{site_id}/{cookie_id}", response_model=CookieResponse)
|
||||
async def get_cookie(
|
||||
site_id: uuid.UUID,
|
||||
cookie_id: uuid.UUID,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Cookie:
|
||||
"""Get a single cookie by ID."""
|
||||
await _get_org_site(site_id, current_user, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Cookie).where(Cookie.id == cookie_id, Cookie.site_id == site_id)
|
||||
)
|
||||
cookie = result.scalar_one_or_none()
|
||||
if not cookie:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cookie not found")
|
||||
return cookie
|
||||
|
||||
|
||||
@router.patch("/sites/{site_id}/{cookie_id}", response_model=CookieResponse)
|
||||
async def update_cookie(
|
||||
site_id: uuid.UUID,
|
||||
cookie_id: uuid.UUID,
|
||||
body: CookieUpdate,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Cookie:
|
||||
"""Update a cookie record (e.g. assign category, change review status)."""
|
||||
await _get_org_site(site_id, current_user, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Cookie).where(Cookie.id == cookie_id, Cookie.site_id == site_id)
|
||||
)
|
||||
cookie = result.scalar_one_or_none()
|
||||
if not cookie:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cookie not found")
|
||||
|
||||
updates = body.model_dump(exclude_unset=True)
|
||||
|
||||
# Validate category if being changed
|
||||
if "category_id" in updates and updates["category_id"] is not None:
|
||||
cat = await db.execute(
|
||||
select(CookieCategory).where(CookieCategory.id == updates["category_id"])
|
||||
)
|
||||
if not cat.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid category_id",
|
||||
)
|
||||
|
||||
for field, value in updates.items():
|
||||
setattr(cookie, field, value)
|
||||
cookie.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(cookie)
|
||||
return cookie
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/sites/{site_id}/{cookie_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
async def delete_cookie(
|
||||
site_id: uuid.UUID,
|
||||
cookie_id: uuid.UUID,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
"""Delete a cookie record."""
|
||||
await _get_org_site(site_id, current_user, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Cookie).where(Cookie.id == cookie_id, Cookie.site_id == site_id)
|
||||
)
|
||||
cookie = result.scalar_one_or_none()
|
||||
if not cookie:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cookie not found")
|
||||
|
||||
await db.delete(cookie)
|
||||
|
||||
|
||||
# ── Known cookies database ──────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/known", response_model=list[KnownCookieResponse])
|
||||
async def list_known_cookies(
|
||||
vendor: str | None = Query(None, description="Filter by vendor name"),
|
||||
search: str | None = Query(None, description="Search by name pattern"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[KnownCookie]:
|
||||
"""List known cookie patterns from the shared database."""
|
||||
query = select(KnownCookie).order_by(KnownCookie.name_pattern)
|
||||
if vendor:
|
||||
query = query.where(KnownCookie.vendor == vendor)
|
||||
if search:
|
||||
query = query.where(KnownCookie.name_pattern.ilike(f"%{search}%"))
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post(
|
||||
"/known",
|
||||
response_model=KnownCookieResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_known_cookie(
|
||||
body: KnownCookieCreate,
|
||||
_user: CurrentUser = Depends(require_role("owner", "admin")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> KnownCookie:
|
||||
"""Add a new pattern to the known cookies database."""
|
||||
# Validate category
|
||||
cat = await db.execute(select(CookieCategory).where(CookieCategory.id == body.category_id))
|
||||
if not cat.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid category_id",
|
||||
)
|
||||
|
||||
known = KnownCookie(**body.model_dump())
|
||||
db.add(known)
|
||||
await db.flush()
|
||||
await db.refresh(known)
|
||||
return known
|
||||
|
||||
|
||||
@router.get("/known/{known_id}", response_model=KnownCookieResponse)
|
||||
async def get_known_cookie(
|
||||
known_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(get_current_user),
|
||||
) -> KnownCookie:
|
||||
"""Get a single known cookie pattern by ID."""
|
||||
result = await db.execute(select(KnownCookie).where(KnownCookie.id == known_id))
|
||||
known = result.scalar_one_or_none()
|
||||
if not known:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Known cookie not found",
|
||||
)
|
||||
return known
|
||||
|
||||
|
||||
@router.patch("/known/{known_id}", response_model=KnownCookieResponse)
|
||||
async def update_known_cookie(
|
||||
known_id: uuid.UUID,
|
||||
body: KnownCookieUpdate,
|
||||
_user: CurrentUser = Depends(require_role("owner", "admin")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> KnownCookie:
|
||||
"""Update a known cookie pattern."""
|
||||
result = await db.execute(select(KnownCookie).where(KnownCookie.id == known_id))
|
||||
known = result.scalar_one_or_none()
|
||||
if not known:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Known cookie not found",
|
||||
)
|
||||
|
||||
updates = body.model_dump(exclude_unset=True)
|
||||
if "category_id" in updates and updates["category_id"] is not None:
|
||||
cat = await db.execute(
|
||||
select(CookieCategory).where(CookieCategory.id == updates["category_id"])
|
||||
)
|
||||
if not cat.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid category_id",
|
||||
)
|
||||
|
||||
for field, value in updates.items():
|
||||
setattr(known, field, value)
|
||||
known.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(known)
|
||||
return known
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/known/{known_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
async def delete_known_cookie(
|
||||
known_id: uuid.UUID,
|
||||
_user: CurrentUser = Depends(require_role("owner", "admin")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
"""Delete a known cookie pattern."""
|
||||
result = await db.execute(select(KnownCookie).where(KnownCookie.id == known_id))
|
||||
known = result.scalar_one_or_none()
|
||||
if not known:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Known cookie not found",
|
||||
)
|
||||
await db.delete(known)
|
||||
|
||||
|
||||
# ── Classification endpoints ────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sites/{site_id}/classify",
|
||||
response_model=ClassifySiteResponse,
|
||||
)
|
||||
async def classify_cookies(
|
||||
site_id: uuid.UUID,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ClassifySiteResponse:
|
||||
"""Auto-classify pending cookies for a site against known patterns."""
|
||||
await _get_org_site(site_id, current_user, db)
|
||||
|
||||
results = await classify_site_cookies(db, site_id, only_pending=True)
|
||||
matched_count = sum(1 for r in results if r.matched)
|
||||
|
||||
return ClassifySiteResponse(
|
||||
site_id=str(site_id),
|
||||
total=len(results),
|
||||
matched=matched_count,
|
||||
unmatched=len(results) - matched_count,
|
||||
results=[
|
||||
ClassificationResultResponse(
|
||||
cookie_name=r.cookie_name,
|
||||
cookie_domain=r.cookie_domain,
|
||||
category_id=r.category_id,
|
||||
category_slug=r.category_slug,
|
||||
vendor=r.vendor,
|
||||
description=r.description,
|
||||
match_source=r.match_source,
|
||||
matched=r.matched,
|
||||
)
|
||||
for r in results
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sites/{site_id}/classify/preview",
|
||||
response_model=ClassificationResultResponse,
|
||||
)
|
||||
async def classify_preview(
|
||||
site_id: uuid.UUID,
|
||||
body: ClassifySingleRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ClassificationResultResponse:
|
||||
"""Preview classification for a single cookie without saving."""
|
||||
await _get_org_site(site_id, current_user, db)
|
||||
|
||||
result = await classify_single_cookie(db, site_id, body.cookie_name, body.cookie_domain)
|
||||
return ClassificationResultResponse(
|
||||
cookie_name=result.cookie_name,
|
||||
cookie_domain=result.cookie_domain,
|
||||
category_id=result.category_id,
|
||||
category_slug=result.category_slug,
|
||||
vendor=result.vendor,
|
||||
description=result.description,
|
||||
match_source=result.match_source,
|
||||
matched=result.matched,
|
||||
)
|
||||
69
apps/api/src/routers/org_config.py
Normal file
69
apps/api/src/routers/org_config.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Organisation-level default configuration endpoints.
|
||||
|
||||
Provides GET and PUT for the organisation's global config defaults.
|
||||
These defaults sit between system defaults and site config in the cascade.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.db import get_db
|
||||
from src.models.org_config import OrgConfig
|
||||
from src.schemas.auth import CurrentUser
|
||||
from src.schemas.org_config import OrgConfigResponse, OrgConfigUpdate
|
||||
from src.services.dependencies import require_role
|
||||
|
||||
router = APIRouter(prefix="/org-config", tags=["organisations"])
|
||||
|
||||
|
||||
@router.get("/", response_model=OrgConfigResponse)
|
||||
async def get_org_config(
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> OrgConfig:
|
||||
"""Retrieve the organisation's global configuration defaults."""
|
||||
result = await db.execute(
|
||||
select(OrgConfig).where(OrgConfig.organisation_id == current_user.organisation_id)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if config is None:
|
||||
# Auto-create an empty config row so the response is always valid
|
||||
config = OrgConfig(organisation_id=current_user.organisation_id)
|
||||
db.add(config)
|
||||
await db.flush()
|
||||
await db.refresh(config)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@router.put("/", response_model=OrgConfigResponse)
|
||||
async def update_org_config(
|
||||
body: OrgConfigUpdate,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> OrgConfig:
|
||||
"""Create or update the organisation's global configuration defaults.
|
||||
|
||||
Only non-None fields will override system defaults when resolving site config.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(OrgConfig).where(OrgConfig.organisation_id == current_user.organisation_id)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if config is None:
|
||||
config = OrgConfig(
|
||||
organisation_id=current_user.organisation_id,
|
||||
**body.model_dump(exclude_unset=True),
|
||||
)
|
||||
db.add(config)
|
||||
else:
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(config, field, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(config)
|
||||
return config
|
||||
118
apps/api/src/routers/organisations.py
Normal file
118
apps/api/src/routers/organisations.py
Normal file
@@ -0,0 +1,118 @@
|
||||
import hmac
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.config.settings import get_settings
|
||||
from src.db import get_db
|
||||
from src.models.organisation import Organisation
|
||||
from src.schemas.auth import CurrentUser
|
||||
from src.schemas.organisation import (
|
||||
OrganisationCreate,
|
||||
OrganisationResponse,
|
||||
OrganisationUpdate,
|
||||
)
|
||||
from src.services.dependencies import require_role
|
||||
|
||||
router = APIRouter(prefix="/organisations", tags=["organisations"])
|
||||
|
||||
|
||||
def _require_bootstrap_token(
|
||||
x_admin_bootstrap_token: str | None = Header(default=None),
|
||||
) -> None:
|
||||
"""Gate organisation creation behind a static bootstrap token.
|
||||
|
||||
The token is configured via ``ADMIN_BOOTSTRAP_TOKEN``. When unset
|
||||
(the default), the endpoint is disabled entirely — operators must
|
||||
explicitly opt in and should rotate or unset the value after their
|
||||
initial org is provisioned.
|
||||
"""
|
||||
expected = get_settings().admin_bootstrap_token
|
||||
if not expected:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=(
|
||||
"Organisation creation is disabled. Set ADMIN_BOOTSTRAP_TOKEN "
|
||||
"in the environment to enable it."
|
||||
),
|
||||
)
|
||||
if not x_admin_bootstrap_token or not hmac.compare_digest(
|
||||
x_admin_bootstrap_token,
|
||||
expected,
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or missing admin bootstrap token",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=OrganisationResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_organisation(
|
||||
body: OrganisationCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: None = Depends(_require_bootstrap_token),
|
||||
) -> Organisation:
|
||||
"""Create a new organisation. Gated by ``X-Admin-Bootstrap-Token``.
|
||||
|
||||
See :func:`_require_bootstrap_token` for the gating semantics. Once
|
||||
your initial organisation exists, rotate or unset
|
||||
``ADMIN_BOOTSTRAP_TOKEN`` to disable further tenant creation.
|
||||
"""
|
||||
# Check slug uniqueness
|
||||
existing = await db.execute(select(Organisation).where(Organisation.slug == body.slug))
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Organisation with slug '{body.slug}' already exists",
|
||||
)
|
||||
|
||||
org = Organisation(**body.model_dump())
|
||||
db.add(org)
|
||||
await db.flush()
|
||||
await db.refresh(org)
|
||||
return org
|
||||
|
||||
|
||||
@router.get("/me", response_model=OrganisationResponse)
|
||||
async def get_my_organisation(
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Organisation:
|
||||
"""Get the current user's organisation."""
|
||||
result = await db.execute(
|
||||
select(Organisation).where(
|
||||
Organisation.id == current_user.organisation_id,
|
||||
Organisation.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
org = result.scalar_one_or_none()
|
||||
if org is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organisation not found")
|
||||
return org
|
||||
|
||||
|
||||
@router.patch("/me", response_model=OrganisationResponse)
|
||||
async def update_my_organisation(
|
||||
body: OrganisationUpdate,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Organisation:
|
||||
"""Update the current user's organisation. Requires owner or admin role."""
|
||||
result = await db.execute(
|
||||
select(Organisation).where(
|
||||
Organisation.id == current_user.organisation_id,
|
||||
Organisation.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
org = result.scalar_one_or_none()
|
||||
if org is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organisation not found")
|
||||
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(org, field, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(org)
|
||||
return org
|
||||
310
apps/api/src/routers/scanner.py
Normal file
310
apps/api/src/routers/scanner.py
Normal file
@@ -0,0 +1,310 @@
|
||||
"""Scanner and client-side cookie report endpoints.
|
||||
|
||||
Accepts cookie reports from the client-side reporter embedded in the banner
|
||||
bundle, upserts discovered cookies into the site's cookie inventory, and
|
||||
provides scan job management (trigger, list, detail, diff).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.db import get_db
|
||||
from src.models.cookie import Cookie
|
||||
from src.models.scan import ScanJob, ScanResult
|
||||
from src.models.site import Site
|
||||
from src.schemas.auth import CurrentUser
|
||||
from src.schemas.scanner import (
|
||||
CookieReportRequest,
|
||||
CookieReportResponse,
|
||||
ScanDiffResponse,
|
||||
ScanJobDetailResponse,
|
||||
ScanJobResponse,
|
||||
TriggerScanRequest,
|
||||
)
|
||||
from src.services.dependencies import get_current_user
|
||||
from src.services.scanner import (
|
||||
compute_scan_diff,
|
||||
create_scan_job,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/scanner", tags=["scanner"])
|
||||
|
||||
|
||||
# ── Client-side cookie report (public, no auth) ─────────────────────
|
||||
|
||||
|
||||
@router.post(
|
||||
"/report",
|
||||
response_model=CookieReportResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
async def receive_cookie_report(
|
||||
body: CookieReportRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CookieReportResponse:
|
||||
"""Receive a cookie report from the client-side reporter.
|
||||
|
||||
This is a public endpoint (no auth) since it's called from the banner
|
||||
script running on end-user browsers. The site_id acts as implicit auth.
|
||||
"""
|
||||
# Verify site exists
|
||||
site_result = await db.execute(
|
||||
select(Site).where(
|
||||
Site.id == body.site_id,
|
||||
Site.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if site_result.scalar_one_or_none() is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Site not found",
|
||||
)
|
||||
|
||||
new_cookies = 0
|
||||
now_iso = datetime.now(UTC).isoformat()
|
||||
|
||||
for reported in body.cookies:
|
||||
# Check if this cookie already exists for the site
|
||||
existing = await db.execute(
|
||||
select(Cookie).where(
|
||||
Cookie.site_id == body.site_id,
|
||||
Cookie.name == reported.name,
|
||||
Cookie.domain == reported.domain,
|
||||
Cookie.storage_type == reported.storage_type,
|
||||
)
|
||||
)
|
||||
cookie = existing.scalar_one_or_none()
|
||||
|
||||
if cookie:
|
||||
# Update last_seen_at timestamp
|
||||
cookie.last_seen_at = now_iso
|
||||
else:
|
||||
# Create new cookie record
|
||||
cookie = Cookie(
|
||||
site_id=body.site_id,
|
||||
name=reported.name,
|
||||
domain=reported.domain,
|
||||
storage_type=reported.storage_type,
|
||||
path=reported.path,
|
||||
is_secure=reported.is_secure,
|
||||
same_site=reported.same_site,
|
||||
review_status="pending",
|
||||
first_seen_at=now_iso,
|
||||
last_seen_at=now_iso,
|
||||
)
|
||||
db.add(cookie)
|
||||
new_cookies += 1
|
||||
|
||||
await db.flush()
|
||||
|
||||
return CookieReportResponse(
|
||||
accepted=True,
|
||||
cookies_received=len(body.cookies),
|
||||
new_cookies=new_cookies,
|
||||
)
|
||||
|
||||
|
||||
# ── Scan job management (authenticated) ─────────────────────────────
|
||||
|
||||
|
||||
async def _verify_site_access(
|
||||
site_id: uuid.UUID,
|
||||
user: CurrentUser,
|
||||
db: AsyncSession,
|
||||
) -> Site:
|
||||
"""Verify site exists and belongs to the user's organisation."""
|
||||
result = await db.execute(
|
||||
select(Site).where(
|
||||
Site.id == site_id,
|
||||
Site.organisation_id == user.organisation_id,
|
||||
Site.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
site = result.scalar_one_or_none()
|
||||
if site is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Site not found",
|
||||
)
|
||||
return site
|
||||
|
||||
|
||||
@router.post(
|
||||
"/scans",
|
||||
response_model=ScanJobResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def trigger_scan(
|
||||
body: TriggerScanRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> ScanJob:
|
||||
"""Trigger a new cookie scan for a site.
|
||||
|
||||
Creates a scan job in 'pending' state and dispatches it to the
|
||||
Celery worker queue for execution.
|
||||
"""
|
||||
from src.services.scanner import complete_scan_job
|
||||
|
||||
await _verify_site_access(body.site_id, user, db)
|
||||
|
||||
# Check for an already-running scan
|
||||
active_result = await db.execute(
|
||||
select(ScanJob).where(
|
||||
ScanJob.site_id == body.site_id,
|
||||
ScanJob.status.in_(["pending", "running"]),
|
||||
)
|
||||
)
|
||||
active_jobs = list(active_result.scalars().all())
|
||||
|
||||
now = datetime.now(UTC)
|
||||
stale_pending_cutoff = now - timedelta(minutes=5)
|
||||
stale_running_cutoff = now - timedelta(minutes=10)
|
||||
|
||||
for active_job in active_jobs:
|
||||
is_stale_pending = (
|
||||
active_job.status == "pending"
|
||||
and active_job.created_at.replace(tzinfo=UTC) < stale_pending_cutoff
|
||||
)
|
||||
is_stale_running = (
|
||||
active_job.status == "running"
|
||||
and active_job.started_at
|
||||
and active_job.started_at.replace(tzinfo=UTC) < stale_running_cutoff
|
||||
)
|
||||
if is_stale_pending or is_stale_running:
|
||||
logger.warning(
|
||||
"Failing stale %s scan job %s for site %s",
|
||||
active_job.status,
|
||||
active_job.id,
|
||||
body.site_id,
|
||||
)
|
||||
await complete_scan_job(
|
||||
db,
|
||||
active_job,
|
||||
error_message=(
|
||||
f"Job was stale ({active_job.status} too long), superseded by new scan"
|
||||
),
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A scan is already in progress for this site",
|
||||
)
|
||||
|
||||
job = await create_scan_job(
|
||||
db,
|
||||
site_id=body.site_id,
|
||||
trigger="manual",
|
||||
max_pages=body.max_pages,
|
||||
)
|
||||
|
||||
# Commit before dispatching to Celery so the worker can find the
|
||||
# job in the database immediately (avoids race condition).
|
||||
await db.commit()
|
||||
|
||||
# Dispatch to Celery (import here to avoid import at module level
|
||||
# when Celery broker is unavailable during testing)
|
||||
try:
|
||||
from src.tasks.scanner import run_scan
|
||||
|
||||
run_scan.delay(str(job.id), str(body.site_id))
|
||||
except Exception:
|
||||
logger.exception("Failed to dispatch scan job %s to Celery", job.id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=(
|
||||
"Background task queue is unavailable — scan job"
|
||||
" created but cannot be processed. Please try again later."
|
||||
),
|
||||
) from None
|
||||
|
||||
return job
|
||||
|
||||
|
||||
@router.get("/scans/site/{site_id}", response_model=list[ScanJobResponse])
|
||||
async def list_scans(
|
||||
site_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> list[ScanJob]:
|
||||
"""List scan jobs for a site, most recent first."""
|
||||
await _verify_site_access(site_id, user, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(ScanJob)
|
||||
.where(ScanJob.site_id == site_id)
|
||||
.order_by(ScanJob.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/scans/{scan_id}", response_model=ScanJobDetailResponse)
|
||||
async def get_scan(
|
||||
scan_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""Retrieve a scan job with its results."""
|
||||
result = await db.execute(select(ScanJob).where(ScanJob.id == scan_id))
|
||||
job = result.scalar_one_or_none()
|
||||
if job is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Scan job not found",
|
||||
)
|
||||
|
||||
# Verify org access
|
||||
await _verify_site_access(job.site_id, user, db)
|
||||
|
||||
# Load results
|
||||
results = await db.execute(
|
||||
select(ScanResult).where(ScanResult.scan_job_id == scan_id).order_by(ScanResult.cookie_name)
|
||||
)
|
||||
scan_results = list(results.scalars().all())
|
||||
|
||||
return {
|
||||
"id": job.id,
|
||||
"site_id": job.site_id,
|
||||
"status": job.status,
|
||||
"trigger": job.trigger,
|
||||
"pages_scanned": job.pages_scanned,
|
||||
"pages_total": job.pages_total,
|
||||
"cookies_found": job.cookies_found,
|
||||
"error_message": job.error_message,
|
||||
"started_at": job.started_at,
|
||||
"completed_at": job.completed_at,
|
||||
"created_at": job.created_at,
|
||||
"updated_at": job.updated_at,
|
||||
"results": scan_results,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/scans/{scan_id}/diff", response_model=ScanDiffResponse)
|
||||
async def get_scan_diff(
|
||||
scan_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> ScanDiffResponse:
|
||||
"""Get the diff between a scan and its predecessor."""
|
||||
result = await db.execute(select(ScanJob).where(ScanJob.id == scan_id))
|
||||
job = result.scalar_one_or_none()
|
||||
if job is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Scan job not found",
|
||||
)
|
||||
|
||||
await _verify_site_access(job.site_id, user, db)
|
||||
|
||||
return await compute_scan_diff(db, current_scan_id=scan_id, site_id=job.site_id)
|
||||
101
apps/api/src/routers/site_group_config.py
Normal file
101
apps/api/src/routers/site_group_config.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Site-group-level default configuration endpoints.
|
||||
|
||||
Provides GET and PUT for a site group's config defaults.
|
||||
These defaults sit between org defaults and site config in the cascade.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.db import get_db
|
||||
from src.models.site_group import SiteGroup
|
||||
from src.models.site_group_config import SiteGroupConfig
|
||||
from src.schemas.auth import CurrentUser
|
||||
from src.schemas.site_group_config import SiteGroupConfigResponse, SiteGroupConfigUpdate
|
||||
from src.services.dependencies import require_role
|
||||
|
||||
router = APIRouter(prefix="/site-groups", tags=["site-groups"])
|
||||
|
||||
|
||||
@router.get("/{group_id}/config", response_model=SiteGroupConfigResponse)
|
||||
async def get_site_group_config(
|
||||
group_id: uuid.UUID,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SiteGroupConfig:
|
||||
"""Retrieve configuration defaults for a site group."""
|
||||
await _verify_group_ownership(group_id, current_user.organisation_id, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(SiteGroupConfig).where(SiteGroupConfig.site_group_id == group_id)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if config is None:
|
||||
# Auto-create an empty config row so the response is always valid
|
||||
config = SiteGroupConfig(site_group_id=group_id)
|
||||
db.add(config)
|
||||
await db.flush()
|
||||
await db.refresh(config)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@router.put("/{group_id}/config", response_model=SiteGroupConfigResponse)
|
||||
async def update_site_group_config(
|
||||
group_id: uuid.UUID,
|
||||
body: SiteGroupConfigUpdate,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SiteGroupConfig:
|
||||
"""Create or update configuration defaults for a site group.
|
||||
|
||||
Only non-None fields will override org/system defaults when resolving site config.
|
||||
"""
|
||||
await _verify_group_ownership(group_id, current_user.organisation_id, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(SiteGroupConfig).where(SiteGroupConfig.site_group_id == group_id)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if config is None:
|
||||
config = SiteGroupConfig(
|
||||
site_group_id=group_id,
|
||||
**body.model_dump(exclude_unset=True),
|
||||
)
|
||||
db.add(config)
|
||||
else:
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(config, field, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(config)
|
||||
return config
|
||||
|
||||
|
||||
# -- Helpers ------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _verify_group_ownership(
|
||||
group_id: uuid.UUID,
|
||||
organisation_id: uuid.UUID,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
"""Ensure the site group belongs to the user's organisation."""
|
||||
result = await db.execute(
|
||||
select(SiteGroup).where(
|
||||
SiteGroup.id == group_id,
|
||||
SiteGroup.organisation_id == organisation_id,
|
||||
SiteGroup.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if result.scalar_one_or_none() is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Site group not found",
|
||||
)
|
||||
198
apps/api/src/routers/site_groups.py
Normal file
198
apps/api/src/routers/site_groups.py
Normal file
@@ -0,0 +1,198 @@
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.db import get_db
|
||||
from src.models.site import Site
|
||||
from src.models.site_group import SiteGroup
|
||||
from src.schemas.auth import CurrentUser
|
||||
from src.schemas.site_group import SiteGroupCreate, SiteGroupResponse, SiteGroupUpdate
|
||||
from src.services.dependencies import require_role
|
||||
|
||||
router = APIRouter(prefix="/site-groups", tags=["site-groups"])
|
||||
|
||||
|
||||
@router.post("/", response_model=SiteGroupResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_site_group(
|
||||
body: SiteGroupCreate,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Create a new site group within the current organisation."""
|
||||
# Check name uniqueness within the org
|
||||
existing = await db.execute(
|
||||
select(SiteGroup).where(
|
||||
SiteGroup.organisation_id == current_user.organisation_id,
|
||||
SiteGroup.name == body.name,
|
||||
SiteGroup.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Site group '{body.name}' already exists in this organisation",
|
||||
)
|
||||
|
||||
group = SiteGroup(
|
||||
organisation_id=current_user.organisation_id,
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
)
|
||||
db.add(group)
|
||||
await db.flush()
|
||||
await db.refresh(group)
|
||||
return _to_response(group, site_count=0)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[SiteGroupResponse])
|
||||
async def list_site_groups(
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[dict]:
|
||||
"""List all site groups in the current organisation with site counts."""
|
||||
# Subquery for site counts
|
||||
site_count_sq = (
|
||||
select(
|
||||
Site.site_group_id,
|
||||
func.count(Site.id).label("cnt"),
|
||||
)
|
||||
.where(Site.deleted_at.is_(None))
|
||||
.group_by(Site.site_group_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
select(SiteGroup, func.coalesce(site_count_sq.c.cnt, 0).label("site_count"))
|
||||
.outerjoin(site_count_sq, SiteGroup.id == site_count_sq.c.site_group_id)
|
||||
.where(
|
||||
SiteGroup.organisation_id == current_user.organisation_id,
|
||||
SiteGroup.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(SiteGroup.name)
|
||||
)
|
||||
|
||||
return [_to_response(row.SiteGroup, site_count=row.site_count) for row in result.all()]
|
||||
|
||||
|
||||
@router.get("/{group_id}", response_model=SiteGroupResponse)
|
||||
async def get_site_group(
|
||||
group_id: uuid.UUID,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Get a specific site group by ID."""
|
||||
group = await _get_org_group(group_id, current_user.organisation_id, db)
|
||||
site_count = await _count_sites(group_id, db)
|
||||
return _to_response(group, site_count=site_count)
|
||||
|
||||
|
||||
@router.patch("/{group_id}", response_model=SiteGroupResponse)
|
||||
async def update_site_group(
|
||||
group_id: uuid.UUID,
|
||||
body: SiteGroupUpdate,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Update a site group's name or description."""
|
||||
group = await _get_org_group(group_id, current_user.organisation_id, db)
|
||||
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
|
||||
# Check name uniqueness if name is being changed
|
||||
if "name" in update_data and update_data["name"] != group.name:
|
||||
existing = await db.execute(
|
||||
select(SiteGroup).where(
|
||||
SiteGroup.organisation_id == current_user.organisation_id,
|
||||
SiteGroup.name == update_data["name"],
|
||||
SiteGroup.deleted_at.is_(None),
|
||||
SiteGroup.id != group_id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Site group '{update_data['name']}' already exists",
|
||||
)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(group, field, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(group)
|
||||
site_count = await _count_sites(group_id, db)
|
||||
return _to_response(group, site_count=site_count)
|
||||
|
||||
|
||||
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_site_group(
|
||||
group_id: uuid.UUID,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
"""Soft-delete a site group. Sites in this group become ungrouped."""
|
||||
group = await _get_org_group(group_id, current_user.organisation_id, db)
|
||||
|
||||
# Ungroup all sites in this group
|
||||
result = await db.execute(
|
||||
select(Site).where(
|
||||
Site.site_group_id == group_id,
|
||||
Site.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
for site in result.scalars().all():
|
||||
site.site_group_id = None
|
||||
|
||||
group.deleted_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _get_org_group(
|
||||
group_id: uuid.UUID,
|
||||
organisation_id: uuid.UUID,
|
||||
db: AsyncSession,
|
||||
) -> SiteGroup:
|
||||
"""Fetch a site group ensuring it belongs to the given organisation."""
|
||||
result = await db.execute(
|
||||
select(SiteGroup).where(
|
||||
SiteGroup.id == group_id,
|
||||
SiteGroup.organisation_id == organisation_id,
|
||||
SiteGroup.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
group = result.scalar_one_or_none()
|
||||
if group is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Site group not found",
|
||||
)
|
||||
return group
|
||||
|
||||
|
||||
async def _count_sites(group_id: uuid.UUID, db: AsyncSession) -> int:
|
||||
"""Count active sites in a group."""
|
||||
result = await db.execute(
|
||||
select(func.count(Site.id)).where(
|
||||
Site.site_group_id == group_id,
|
||||
Site.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
def _to_response(group: SiteGroup, *, site_count: int) -> dict:
|
||||
"""Convert a SiteGroup model to a response dict with site_count."""
|
||||
return {
|
||||
"id": group.id,
|
||||
"organisation_id": group.organisation_id,
|
||||
"name": group.name,
|
||||
"description": group.description,
|
||||
"created_at": group.created_at,
|
||||
"updated_at": group.updated_at,
|
||||
"site_count": site_count,
|
||||
}
|
||||
220
apps/api/src/routers/sites.py
Normal file
220
apps/api/src/routers/sites.py
Normal file
@@ -0,0 +1,220 @@
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.db import get_db
|
||||
from src.models.site import Site
|
||||
from src.models.site_config import SiteConfig
|
||||
from src.schemas.auth import CurrentUser
|
||||
from src.schemas.site import (
|
||||
SiteConfigCreate,
|
||||
SiteConfigResponse,
|
||||
SiteConfigUpdate,
|
||||
SiteCreate,
|
||||
SiteResponse,
|
||||
SiteUpdate,
|
||||
)
|
||||
from src.services.dependencies import require_role
|
||||
|
||||
router = APIRouter(prefix="/sites", tags=["sites"])
|
||||
|
||||
|
||||
# ── Site CRUD ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/", response_model=SiteResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_site(
|
||||
body: SiteCreate,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Site:
|
||||
"""Create a new site within the current organisation."""
|
||||
# Check domain uniqueness within the org
|
||||
existing = await db.execute(
|
||||
select(Site).where(
|
||||
Site.organisation_id == current_user.organisation_id,
|
||||
Site.domain == body.domain,
|
||||
Site.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Site with domain '{body.domain}' already exists in this organisation",
|
||||
)
|
||||
|
||||
site = Site(
|
||||
organisation_id=current_user.organisation_id,
|
||||
domain=body.domain,
|
||||
display_name=body.display_name,
|
||||
site_group_id=body.site_group_id,
|
||||
)
|
||||
db.add(site)
|
||||
await db.flush()
|
||||
|
||||
# Auto-create a default site configuration
|
||||
default_config = SiteConfig(site_id=site.id)
|
||||
db.add(default_config)
|
||||
await db.flush()
|
||||
|
||||
await db.refresh(site)
|
||||
return site
|
||||
|
||||
|
||||
@router.get("/", response_model=list[SiteResponse])
|
||||
async def list_sites(
|
||||
site_group_id: uuid.UUID | None = Query(default=None),
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[Site]:
|
||||
"""List all active sites in the current organisation, optionally filtered by group."""
|
||||
query = select(Site).where(
|
||||
Site.organisation_id == current_user.organisation_id,
|
||||
Site.deleted_at.is_(None),
|
||||
)
|
||||
if site_group_id is not None:
|
||||
query = query.where(Site.site_group_id == site_group_id)
|
||||
result = await db.execute(query.order_by(Site.domain))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/{site_id}", response_model=SiteResponse)
|
||||
async def get_site(
|
||||
site_id: uuid.UUID,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Site:
|
||||
"""Get a specific site by ID."""
|
||||
site = await _get_org_site(site_id, current_user.organisation_id, db)
|
||||
return site
|
||||
|
||||
|
||||
@router.patch("/{site_id}", response_model=SiteResponse)
|
||||
async def update_site(
|
||||
site_id: uuid.UUID,
|
||||
body: SiteUpdate,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Site:
|
||||
"""Update a site's display name or active status."""
|
||||
site = await _get_org_site(site_id, current_user.organisation_id, db)
|
||||
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(site, field, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(site)
|
||||
return site
|
||||
|
||||
|
||||
@router.delete("/{site_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def deactivate_site(
|
||||
site_id: uuid.UUID,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
"""Soft-delete a site."""
|
||||
site = await _get_org_site(site_id, current_user.organisation_id, db)
|
||||
site.deleted_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
|
||||
# ── Site config CRUD ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/{site_id}/config", response_model=SiteConfigResponse)
|
||||
async def get_site_config(
|
||||
site_id: uuid.UUID,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SiteConfig:
|
||||
"""Get the configuration for a site."""
|
||||
await _get_org_site(site_id, current_user.organisation_id, db)
|
||||
result = await db.execute(select(SiteConfig).where(SiteConfig.site_id == site_id))
|
||||
config = result.scalar_one_or_none()
|
||||
if config is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Site configuration not found. Create one first.",
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
@router.put("/{site_id}/config", response_model=SiteConfigResponse)
|
||||
async def create_or_replace_site_config(
|
||||
site_id: uuid.UUID,
|
||||
body: SiteConfigCreate,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SiteConfig:
|
||||
"""Create or replace the full configuration for a site."""
|
||||
await _get_org_site(site_id, current_user.organisation_id, db)
|
||||
|
||||
result = await db.execute(select(SiteConfig).where(SiteConfig.site_id == site_id))
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing is not None:
|
||||
for field, value in body.model_dump().items():
|
||||
setattr(existing, field, value)
|
||||
await db.flush()
|
||||
await db.refresh(existing)
|
||||
return existing
|
||||
|
||||
config = SiteConfig(site_id=site_id, **body.model_dump())
|
||||
db.add(config)
|
||||
await db.flush()
|
||||
await db.refresh(config)
|
||||
return config
|
||||
|
||||
|
||||
@router.patch("/{site_id}/config", response_model=SiteConfigResponse)
|
||||
async def update_site_config(
|
||||
site_id: uuid.UUID,
|
||||
body: SiteConfigUpdate,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SiteConfig:
|
||||
"""Partially update the configuration for a site."""
|
||||
await _get_org_site(site_id, current_user.organisation_id, db)
|
||||
|
||||
result = await db.execute(select(SiteConfig).where(SiteConfig.site_id == site_id))
|
||||
config = result.scalar_one_or_none()
|
||||
if config is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Site configuration not found. Create one first.",
|
||||
)
|
||||
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(config, field, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(config)
|
||||
return config
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _get_org_site(
|
||||
site_id: uuid.UUID,
|
||||
organisation_id: uuid.UUID,
|
||||
db: AsyncSession,
|
||||
) -> Site:
|
||||
"""Fetch a site ensuring it belongs to the given organisation."""
|
||||
result = await db.execute(
|
||||
select(Site).where(
|
||||
Site.id == site_id,
|
||||
Site.organisation_id == organisation_id,
|
||||
Site.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
site = result.scalar_one_or_none()
|
||||
if site is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Site not found")
|
||||
return site
|
||||
195
apps/api/src/routers/translations.py
Normal file
195
apps/api/src/routers/translations.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""Translation management endpoints.
|
||||
|
||||
CRUD for per-site, per-locale translation strings used by the banner script.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.db import get_db
|
||||
from src.models.site import Site
|
||||
from src.models.translation import Translation
|
||||
from src.schemas.auth import CurrentUser
|
||||
from src.schemas.translation import TranslationCreate, TranslationResponse, TranslationUpdate
|
||||
from src.services.dependencies import require_role
|
||||
|
||||
router = APIRouter(prefix="/sites/{site_id}/translations", tags=["translations"])
|
||||
|
||||
|
||||
async def _get_org_site(site_id: uuid.UUID, organisation_id: uuid.UUID, db: AsyncSession) -> Site:
|
||||
"""Ensure site belongs to the current organisation."""
|
||||
result = await db.execute(
|
||||
select(Site).where(
|
||||
Site.id == site_id,
|
||||
Site.organisation_id == organisation_id,
|
||||
Site.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
site = result.scalar_one_or_none()
|
||||
if site is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Site not found")
|
||||
return site
|
||||
|
||||
|
||||
@router.get("/", response_model=list[TranslationResponse])
|
||||
async def list_translations(
|
||||
site_id: uuid.UUID,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[Translation]:
|
||||
"""List all translations for a site."""
|
||||
await _get_org_site(site_id, current_user.organisation_id, db)
|
||||
result = await db.execute(
|
||||
select(Translation).where(Translation.site_id == site_id).order_by(Translation.locale)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/{locale}", response_model=TranslationResponse)
|
||||
async def get_translation(
|
||||
site_id: uuid.UUID,
|
||||
locale: str,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Translation:
|
||||
"""Get translation strings for a specific locale."""
|
||||
await _get_org_site(site_id, current_user.organisation_id, db)
|
||||
result = await db.execute(
|
||||
select(Translation).where(
|
||||
Translation.site_id == site_id,
|
||||
Translation.locale == locale,
|
||||
)
|
||||
)
|
||||
translation = result.scalar_one_or_none()
|
||||
if translation is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"No translation found for locale '{locale}'",
|
||||
)
|
||||
return translation
|
||||
|
||||
|
||||
@router.post("/", response_model=TranslationResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_translation(
|
||||
site_id: uuid.UUID,
|
||||
body: TranslationCreate,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Translation:
|
||||
"""Create a translation for a new locale."""
|
||||
await _get_org_site(site_id, current_user.organisation_id, db)
|
||||
|
||||
# Check for duplicate locale
|
||||
existing = await db.execute(
|
||||
select(Translation).where(
|
||||
Translation.site_id == site_id,
|
||||
Translation.locale == body.locale,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Translation for locale '{body.locale}' already exists",
|
||||
)
|
||||
|
||||
translation = Translation(
|
||||
site_id=site_id,
|
||||
locale=body.locale,
|
||||
strings=body.strings,
|
||||
)
|
||||
db.add(translation)
|
||||
await db.flush()
|
||||
await db.refresh(translation)
|
||||
return translation
|
||||
|
||||
|
||||
@router.put("/{locale}", response_model=TranslationResponse)
|
||||
async def update_translation(
|
||||
site_id: uuid.UUID,
|
||||
locale: str,
|
||||
body: TranslationUpdate,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Translation:
|
||||
"""Replace the strings for an existing locale translation."""
|
||||
await _get_org_site(site_id, current_user.organisation_id, db)
|
||||
result = await db.execute(
|
||||
select(Translation).where(
|
||||
Translation.site_id == site_id,
|
||||
Translation.locale == locale,
|
||||
)
|
||||
)
|
||||
translation = result.scalar_one_or_none()
|
||||
if translation is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"No translation found for locale '{locale}'",
|
||||
)
|
||||
|
||||
translation.strings = body.strings
|
||||
await db.flush()
|
||||
await db.refresh(translation)
|
||||
return translation
|
||||
|
||||
|
||||
@router.delete("/{locale}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_translation(
|
||||
site_id: uuid.UUID,
|
||||
locale: str,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
"""Delete a translation for a specific locale."""
|
||||
await _get_org_site(site_id, current_user.organisation_id, db)
|
||||
result = await db.execute(
|
||||
select(Translation).where(
|
||||
Translation.site_id == site_id,
|
||||
Translation.locale == locale,
|
||||
)
|
||||
)
|
||||
translation = result.scalar_one_or_none()
|
||||
if translation is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"No translation found for locale '{locale}'",
|
||||
)
|
||||
await db.delete(translation)
|
||||
await db.flush()
|
||||
|
||||
|
||||
# ── Public endpoint for the banner script ────────────────────────────
|
||||
|
||||
public_router = APIRouter(prefix="/translations", tags=["translations"])
|
||||
|
||||
|
||||
@public_router.get("/{site_id}/{locale}")
|
||||
async def get_public_translation(
|
||||
site_id: uuid.UUID,
|
||||
locale: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict[str, str]:
|
||||
"""Public endpoint: return translation strings for the banner script.
|
||||
|
||||
No auth required. Returns the raw strings dict for a given site and locale.
|
||||
Returns 404 if no translation exists (banner falls back to English defaults).
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Translation)
|
||||
.join(Site)
|
||||
.where(
|
||||
Translation.site_id == site_id,
|
||||
Translation.locale == locale,
|
||||
Site.is_active.is_(True),
|
||||
Site.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
translation = result.scalar_one_or_none()
|
||||
if translation is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Translation not found",
|
||||
)
|
||||
return translation.strings
|
||||
136
apps/api/src/routers/users.py
Normal file
136
apps/api/src/routers/users.py
Normal file
@@ -0,0 +1,136 @@
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.db import get_db
|
||||
from src.models.user import User
|
||||
from src.schemas.auth import CurrentUser
|
||||
from src.schemas.user import UserCreate, UserResponse, UserUpdate
|
||||
from src.services.auth import hash_password
|
||||
from src.services.dependencies import require_role
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
body: UserCreate,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
"""Invite/create a new user within the current organisation."""
|
||||
# Check email uniqueness
|
||||
existing = await db.execute(select(User).where(User.email == body.email))
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"User with email '{body.email}' already exists",
|
||||
)
|
||||
|
||||
user = User(
|
||||
organisation_id=current_user.organisation_id,
|
||||
email=body.email,
|
||||
password_hash=hash_password(body.password),
|
||||
full_name=body.full_name,
|
||||
role=body.role,
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/", response_model=list[UserResponse])
|
||||
async def list_users(
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[User]:
|
||||
"""List all active users in the current organisation."""
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(
|
||||
User.organisation_id == current_user.organisation_id,
|
||||
User.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(User.created_at)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponse)
|
||||
async def get_user(
|
||||
user_id: uuid.UUID,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
"""Get a specific user by ID within the current organisation."""
|
||||
result = await db.execute(
|
||||
select(User).where(
|
||||
User.id == user_id,
|
||||
User.organisation_id == current_user.organisation_id,
|
||||
User.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserResponse)
|
||||
async def update_user(
|
||||
user_id: uuid.UUID,
|
||||
body: UserUpdate,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
"""Update a user's name or role. Requires owner or admin."""
|
||||
result = await db.execute(
|
||||
select(User).where(
|
||||
User.id == user_id,
|
||||
User.organisation_id == current_user.organisation_id,
|
||||
User.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(user, field, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def deactivate_user(
|
||||
user_id: uuid.UUID,
|
||||
current_user: CurrentUser = Depends(require_role("owner", "admin")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
"""Soft-delete (deactivate) a user. Requires owner or admin."""
|
||||
if user_id == current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot deactivate yourself",
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
select(User).where(
|
||||
User.id == user_id,
|
||||
User.organisation_id == current_user.organisation_id,
|
||||
User.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
|
||||
user.deleted_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
Reference in New Issue
Block a user