Files
consentos/apps/api/src/routers/sites.py
James Cottrill fbf26453f2 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.
2026-04-14 09:18:18 +00:00

221 lines
7.4 KiB
Python

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