This commit is contained in:
Andras Schmelczer 2026-05-28 21:24:47 +01:00
parent 3ad2766f82
commit f74ee43cb4
196 changed files with 18949 additions and 32173 deletions

View file

@ -0,0 +1 @@
# Life Towers FastAPI backend

View file

@ -0,0 +1,243 @@
"""APIRouter with all Life Towers endpoints."""
from __future__ import annotations
import json
import time
from typing import Annotated
import structlog
from fastapi import APIRouter, Depends, HTTPException, Request
from .auth import get_current_user
from .db import db_connection
from .limits import limiter
from .models import (
BlockOut,
DataIn,
DataOut,
HealthResponse,
HslColor,
PageOut,
RegisterRequest,
RegisterResponse,
TowerOut,
)
router = APIRouter(prefix="/api/v1")
logger = structlog.get_logger(__name__)
@router.get("/health", response_model=HealthResponse)
async def health() -> HealthResponse:
return HealthResponse(status="ok")
@router.post("/register", response_model=RegisterResponse)
@limiter.limit("30/hour", key_func=lambda request: request.client.host if request.client else "unknown")
async def register(request: Request, body: RegisterRequest) -> RegisterResponse:
token = body.token
now = int(time.time())
with db_connection() as conn:
existing = conn.execute(
"SELECT id FROM users WHERE id = ?", (token,)
).fetchone()
if existing is None:
conn.execute(
"INSERT INTO users (id, created_at, last_seen_at) VALUES (?, ?, ?)",
(token, now, now),
)
else:
conn.execute(
"UPDATE users SET last_seen_at = ? WHERE id = ?",
(now, token),
)
conn.commit()
logger.info("user_registered", user_id=token, new=existing is None)
return RegisterResponse(user_id=token)
@router.get("/data", response_model=DataOut)
@limiter.limit("60/minute")
async def get_data(
request: Request,
user_id: Annotated[str, Depends(get_current_user)],
) -> DataOut:
with db_connection() as conn:
pages_rows = conn.execute(
"""
SELECT id, name, hide_create_tower_button, keep_tasks_open, default_date_from, default_date_to
FROM pages
WHERE user_id = ?
ORDER BY position
""",
(user_id,),
).fetchall()
pages_out: list[PageOut] = []
for page_row in pages_rows:
page_id = page_row["id"]
tower_rows = conn.execute(
"""
SELECT id, name, base_color_h, base_color_s, base_color_l
FROM towers
WHERE page_id = ?
ORDER BY position
""",
(page_id,),
).fetchall()
towers_out: list[TowerOut] = []
for tower_row in tower_rows:
tower_id = tower_row["id"]
block_rows = conn.execute(
"""
SELECT id, tag, description, is_done, created_at
FROM blocks
WHERE tower_id = ?
ORDER BY position
""",
(tower_id,),
).fetchall()
blocks_out = [
BlockOut(
id=b["id"],
tag=b["tag"],
description=b["description"],
is_done=bool(b["is_done"]),
created_at=b["created_at"],
)
for b in block_rows
]
towers_out.append(
TowerOut(
id=tower_row["id"],
name=tower_row["name"],
base_color=HslColor(
h=tower_row["base_color_h"],
s=tower_row["base_color_s"],
l=tower_row["base_color_l"],
),
blocks=blocks_out,
)
)
pages_out.append(
PageOut(
id=page_row["id"],
name=page_row["name"],
hide_create_tower_button=bool(
page_row["hide_create_tower_button"]
),
keep_tasks_open=bool(page_row["keep_tasks_open"]),
default_date_from=page_row["default_date_from"],
default_date_to=page_row["default_date_to"],
towers=towers_out,
)
)
return DataOut(pages=pages_out)
@router.put("/data", status_code=204)
@limiter.limit("30/minute")
async def put_data(
request: Request,
body: DataIn,
user_id: Annotated[str, Depends(get_current_user)],
) -> None:
# Tree-replace semantics mean the request size IS the user's total storage,
# so the 2 MiB request cap (enforced by payload_size_middleware) is the only
# quota we need.
now = int(time.time())
with db_connection() as conn:
conn.execute("BEGIN IMMEDIATE")
try:
# Delete existing data for this user (cascades to towers + blocks)
conn.execute("DELETE FROM pages WHERE user_id = ?", (user_id,))
for page_pos, page in enumerate(body.pages):
conn.execute(
"""
INSERT INTO pages
(id, user_id, position, name, hide_create_tower_button,
keep_tasks_open, default_date_from, default_date_to,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
page.id,
user_id,
page_pos,
page.name,
1 if page.hide_create_tower_button else 0,
1 if page.keep_tasks_open else 0,
page.default_date_from,
page.default_date_to,
now,
now,
),
)
for tower_pos, tower in enumerate(page.towers):
conn.execute(
"""
INSERT INTO towers
(id, page_id, user_id, position, name,
base_color_h, base_color_s, base_color_l,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
tower.id,
page.id,
user_id,
tower_pos,
tower.name,
tower.base_color.h,
tower.base_color.s,
tower.base_color.l,
now,
now,
),
)
for block_pos, block in enumerate(tower.blocks):
created_at = block.created_at if block.created_at is not None else now
conn.execute(
"""
INSERT INTO blocks
(id, tower_id, user_id, position, tag,
description, is_done, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
block.id,
tower.id,
user_id,
block_pos,
block.tag,
block.description,
1 if block.is_done else 0,
created_at,
now,
),
)
# Update last_seen_at
conn.execute(
"UPDATE users SET last_seen_at = ? WHERE id = ?",
(now, user_id),
)
conn.commit()
except Exception:
conn.rollback()
raise
logger.info("data_replaced", user_id=user_id, pages=len(body.pages))

View file

@ -0,0 +1,53 @@
"""Bearer token extraction, UUIDv4 validation, and DB lookup."""
from __future__ import annotations
import uuid
from fastapi import HTTPException, Request
from .db import db_connection
# Single generic detail used for ALL 401 responses. Per spec, the response
# must not distinguish between missing / malformed / unknown tokens — that
# would let an attacker enumerate registered tokens.
_UNAUTHORIZED_DETAIL = "Authentication required"
def _unauthorized() -> HTTPException:
return HTTPException(
status_code=401,
detail={"error": "unauthorized", "detail": _UNAUTHORIZED_DETAIL},
)
def get_current_user(request: Request) -> str:
"""Dependency that extracts and validates a Bearer token, returns user_id."""
auth_header = request.headers.get("Authorization") or request.headers.get(
"authorization"
)
if not auth_header:
raise _unauthorized()
parts = auth_header.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
raise _unauthorized()
token = parts[1]
try:
u = uuid.UUID(token)
if u.version != 4:
raise ValueError("Not v4")
except (ValueError, AttributeError):
raise _unauthorized()
with db_connection() as conn:
row = conn.execute(
"SELECT id FROM users WHERE id = ?", (token,)
).fetchone()
if row is None:
raise _unauthorized()
return token

View file

@ -0,0 +1,95 @@
"""SQLite connection factory, WAL/FK pragmas, and migration runner."""
from __future__ import annotations
import os
import sqlite3
import time
from contextlib import contextmanager
from importlib import resources
from pathlib import Path
from typing import Generator
import structlog
logger = structlog.get_logger(__name__)
_DB_PATH: Path | None = None
def get_db_path() -> Path:
global _DB_PATH
if _DB_PATH is None:
_DB_PATH = Path(os.environ.get("LIFE_TOWERS_DB_PATH", "/data/life-towers.db"))
return _DB_PATH
def _apply_pragmas(conn: sqlite3.Connection) -> None:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
conn.execute("PRAGMA busy_timeout=5000")
def get_connection() -> sqlite3.Connection:
"""Open a new connection with required pragmas applied."""
path = get_db_path()
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(path), check_same_thread=False)
conn.row_factory = sqlite3.Row
_apply_pragmas(conn)
return conn
@contextmanager
def db_connection() -> Generator[sqlite3.Connection, None, None]:
"""Context manager yielding a connection that is closed on exit."""
conn = get_connection()
try:
yield conn
finally:
conn.close()
def _migration_files() -> list[tuple[str, str]]:
"""Return [(filename, sql)] for every migration, in lexical order.
Migrations ship as package data under `life_towers/migrations/`, so they
travel with the wheel and are resolvable regardless of cwd or how the
package is installed (editable, wheel, or PYTHONPATH).
"""
pkg_files = resources.files("life_towers").joinpath("migrations")
out: list[tuple[str, str]] = []
for entry in sorted(pkg_files.iterdir(), key=lambda p: p.name):
if entry.name.endswith(".sql"):
out.append((entry.name, entry.read_text()))
return out
def run_migrations(conn: sqlite3.Connection) -> None:
"""Apply pending SQL migrations in lexical order."""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS schema_migrations (
filename TEXT PRIMARY KEY,
applied_at INTEGER NOT NULL
)
"""
)
conn.commit()
for filename, sql in _migration_files():
row = conn.execute(
"SELECT filename FROM schema_migrations WHERE filename = ?",
(filename,),
).fetchone()
if row is not None:
continue
logger.info("applying_migration", filename=filename)
conn.executescript(sql)
conn.execute(
"INSERT INTO schema_migrations (filename, applied_at) VALUES (?, ?)",
(filename, int(time.time())),
)
conn.commit()
logger.info("migration_applied", filename=filename)

View file

@ -0,0 +1,86 @@
"""Payload size middleware and rate-limit setup."""
from __future__ import annotations
import json
from fastapi import Request, Response
from slowapi import Limiter
from slowapi.util import get_remote_address
PAYLOAD_LIMIT_BYTES = 2 * 1024 * 1024 # 2 MiB
_TOO_LARGE_BODY = json.dumps(
{
"error": "payload_too_large",
"detail": f"Request body exceeds {PAYLOAD_LIMIT_BYTES} bytes",
}
).encode()
def _get_token_or_ip(request: Request) -> str:
"""Key function for rate limiting: use Bearer token if present, else IP."""
auth = request.headers.get("Authorization") or request.headers.get("authorization")
if auth:
parts = auth.split()
if len(parts) == 2 and parts[0].lower() == "bearer":
return parts[1]
return get_remote_address(request)
limiter = Limiter(key_func=_get_token_or_ip, default_limits=[])
def _too_large() -> Response:
return Response(
content=_TOO_LARGE_BODY,
status_code=413,
media_type="application/json",
)
async def payload_size_middleware(request: Request, call_next) -> Response:
"""Reject requests larger than PAYLOAD_LIMIT_BYTES.
Two-layer enforcement:
1. If `Content-Length` is present, reject up front (cheap and avoids
buffering anything).
2. Otherwise (chunked encoding / missing header) wrap `request.receive`
so the body stream is tallied as it arrives; abort the moment the
running total exceeds the cap. This is defense-in-depth against
clients that omit Content-Length.
"""
content_length = request.headers.get("content-length")
if content_length is not None:
try:
length = int(content_length)
except ValueError:
length = 0
if length > PAYLOAD_LIMIT_BYTES:
return _too_large()
# Trust the declared length; the ASGI server enforces it.
return await call_next(request)
# No Content-Length: tally bytes as they arrive.
received_total = 0
too_large = False
original_receive = request.receive
async def guarded_receive() -> dict:
nonlocal received_total, too_large
message = await original_receive()
if message.get("type") == "http.request":
body = message.get("body") or b""
received_total += len(body)
if received_total > PAYLOAD_LIMIT_BYTES:
too_large = True
# Tell downstream "no more body" — they may still try to
# parse what's been seen, but we'll override the response.
return {"type": "http.disconnect"}
return message
request._receive = guarded_receive # type: ignore[attr-defined]
response = await call_next(request)
if too_large:
return _too_large()
return response

View file

@ -0,0 +1,63 @@
"""structlog JSON config and request logging middleware."""
from __future__ import annotations
import time
import uuid as _uuid_mod
import structlog
from fastapi import Request, Response
def configure_logging() -> None:
"""Configure structlog for JSON output."""
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.JSONRenderer(),
],
wrapper_class=structlog.make_filtering_bound_logger(0),
context_class=dict,
logger_factory=structlog.PrintLoggerFactory(),
cache_logger_on_first_use=True,
)
async def request_logging_middleware(request: Request, call_next) -> Response:
"""Log each request with method, path, status, duration_ms, user_id, request_id."""
request_id = str(_uuid_mod.uuid4())
structlog.contextvars.clear_contextvars()
structlog.contextvars.bind_contextvars(request_id=request_id)
# Extract user_id from Authorization header for logging (no DB call here)
auth = request.headers.get("Authorization") or request.headers.get("authorization")
user_id: str | None = None
if auth:
parts = auth.split()
if len(parts) == 2 and parts[0].lower() == "bearer":
user_id = parts[1]
start = time.monotonic()
response = await call_next(request)
duration_ms = round((time.monotonic() - start) * 1000, 2)
# request.client is rewritten by uvicorn's ProxyHeadersMiddleware
# (--proxy-headers) to the real client IP when behind a trusted reverse proxy.
client_ip = request.client.host if request.client else None
log = structlog.get_logger("access")
log.info(
"request",
method=request.method,
path=request.url.path,
status=response.status_code,
duration_ms=duration_ms,
user_id=user_id,
client_ip=client_ip,
)
response.headers["X-Request-Id"] = request_id
return response

View file

@ -0,0 +1,187 @@
"""ASGI app, lifespan, static files mount, route registration."""
from __future__ import annotations
import json
import os
from contextlib import asynccontextmanager
from pathlib import Path
from typing import AsyncGenerator
import structlog
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from starlette.middleware.base import BaseHTTPMiddleware
from .api import router
from .db import db_connection, run_migrations
from .limits import limiter, payload_size_middleware
from .logging import configure_logging, request_logging_middleware
logger = structlog.get_logger(__name__)
# Map HTTP status codes to error code strings
STATUS_CODE_MAP: dict[int, str] = {
400: "bad_request",
401: "unauthorized",
403: "forbidden",
404: "not_found",
405: "method_not_allowed",
409: "conflict",
413: "payload_too_large",
422: "bad_request",
429: "rate_limited",
507: "quota_exceeded",
500: "server_error",
}
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
configure_logging()
logger.info("starting_up")
with db_connection() as conn:
run_migrations(conn)
logger.info("migrations_complete")
yield
logger.info("shutting_down")
def create_app() -> FastAPI:
app = FastAPI(
title="Life Towers",
lifespan=lifespan,
docs_url="/api/docs",
redoc_url="/api/redoc",
)
# Rate limiter state
app.state.limiter = limiter
# CORS (only if env var set)
allowed_origin = os.environ.get("LIFE_TOWERS_ALLOWED_ORIGIN")
if allowed_origin:
app.add_middleware(
CORSMiddleware,
allow_origins=[allowed_origin],
allow_credentials=False,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type"],
)
# Payload size middleware
app.add_middleware(BaseHTTPMiddleware, dispatch=payload_size_middleware)
# Request logging middleware
app.add_middleware(BaseHTTPMiddleware, dispatch=request_logging_middleware)
# Rate limit exceeded handler
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# Pydantic / FastAPI validation errors → 400 bad_request.
# We do NOT echo the user-supplied input back in the detail string —
# pydantic's default messages include the offending value, which would
# reflect arbitrary attacker-controlled content. Instead we just list
# the failing field paths.
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request, exc: RequestValidationError
) -> JSONResponse:
fields = sorted(
{".".join(str(loc) for loc in e.get("loc", ()) if loc != "body") for e in exc.errors()}
)
if fields:
detail_str = "Validation failed for: " + ", ".join(f for f in fields if f)
else:
detail_str = "Validation failed"
return JSONResponse(
status_code=400,
content={"error": "bad_request", "detail": detail_str},
)
# HTTP exception handler → normalized JSON
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
if isinstance(exc.detail, dict):
# Already shaped correctly (from auth.py etc.)
detail = exc.detail
else:
code = STATUS_CODE_MAP.get(exc.status_code, "server_error")
detail = {"error": code, "detail": str(exc.detail)}
headers = getattr(exc, "headers", None) or {}
return JSONResponse(status_code=exc.status_code, content=detail, headers=headers)
# Generic 500 handler
@app.exception_handler(Exception)
async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse:
logger.exception("unhandled_exception", exc_info=exc)
return JSONResponse(
status_code=500,
content={"error": "server_error", "detail": "Internal server error"},
)
# Register API router
app.include_router(router)
# Static files
static_dir = Path(os.environ.get("LIFE_TOWERS_STATIC_DIR", "/app/static"))
if static_dir.exists() and static_dir.is_dir():
_mount_static(app, static_dir)
else:
logger.warning("static_dir_missing", path=str(static_dir))
return app
def _mount_static(app: FastAPI, static_dir: Path) -> None:
"""Mount static files with SPA fallback and proper cache headers."""
import re
# Matches Angular's content-hashed asset filenames, e.g.
# main-L5B7PG5E.js, chunk-deadbeef.css, styles-6JWNHNC2.css, font.AbCdEf12.woff2
# The hash is at least 8 chars of alphanumerics, separated from the base name
# by either '-' (modern Angular) or '.' (older Angular / generic).
HASHED_PATTERN = re.compile(
r"[-.][A-Za-z0-9]{8,}\.(?:js|css|woff2?|png|jpe?g|svg|ico|map)$"
)
def _serve_file(file_path: Path) -> FileResponse:
resp = FileResponse(str(file_path))
if HASHED_PATTERN.search(file_path.name):
resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"
else:
resp.headers["Cache-Control"] = "no-cache"
return resp
@app.get("/{full_path:path}", include_in_schema=False)
async def spa_fallback(full_path: str) -> Response:
# API routes are handled by the API router (registered before this);
# if execution reaches here for an /api/* path, it really is unknown.
if full_path.startswith("api/"):
raise HTTPException(status_code=404, detail="Not found")
# Real file? Serve it (with cache headers based on hash detection).
# Guard against path traversal by resolving and re-checking containment.
candidate = (static_dir / full_path).resolve()
try:
candidate.relative_to(static_dir.resolve())
except ValueError:
raise HTTPException(status_code=404, detail="Not found")
if candidate.is_file():
return _serve_file(candidate)
# SPA fallback to index.html.
index = static_dir / "index.html"
if index.is_file():
return _serve_file(index)
raise HTTPException(status_code=404, detail="Not found")
app = create_app()

View file

@ -0,0 +1,54 @@
-- Life Towers v4 initial schema.
-- SQLite with WAL mode and foreign keys enabled at connection time.
-- All timestamps are unix epoch seconds (INTEGER).
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
created_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS pages (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
name TEXT NOT NULL,
hide_create_tower_button INTEGER NOT NULL DEFAULT 0 CHECK (hide_create_tower_button IN (0, 1)),
default_date_from INTEGER,
default_date_to INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS idx_pages_user_position ON pages(user_id, position);
CREATE TABLE IF NOT EXISTS towers (
id TEXT PRIMARY KEY,
page_id TEXT NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
name TEXT NOT NULL,
base_color_h REAL NOT NULL CHECK (base_color_h BETWEEN 0 AND 1),
base_color_s REAL NOT NULL CHECK (base_color_s BETWEEN 0 AND 1),
base_color_l REAL NOT NULL CHECK (base_color_l BETWEEN 0 AND 1),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS idx_towers_page_position ON towers(page_id, position);
CREATE INDEX IF NOT EXISTS idx_towers_user ON towers(user_id);
CREATE TABLE IF NOT EXISTS blocks (
id TEXT PRIMARY KEY,
tower_id TEXT NOT NULL REFERENCES towers(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
tag TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
is_done INTEGER NOT NULL DEFAULT 0 CHECK (is_done IN (0, 1)),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS idx_blocks_tower_position ON blocks(tower_id, position);
CREATE INDEX IF NOT EXISTS idx_blocks_user ON blocks(user_id);

View file

@ -0,0 +1,2 @@
ALTER TABLE pages ADD COLUMN keep_tasks_open INTEGER NOT NULL DEFAULT 0
CHECK (keep_tasks_open IN (0, 1));

View file

@ -0,0 +1,150 @@
"""Pydantic v2 models matching the API spec exactly."""
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel, Field, field_validator, model_validator
import uuid as _uuid_mod
def _is_uuidv4(value: str) -> bool:
try:
u = _uuid_mod.UUID(value)
return u.version == 4
except (ValueError, AttributeError):
return False
class HslColor(BaseModel):
h: float = Field(ge=0.0, le=1.0)
s: float = Field(ge=0.0, le=1.0)
l: float = Field(ge=0.0, le=1.0)
class BlockIn(BaseModel):
id: str
tag: str = Field(max_length=200)
description: str = Field(max_length=10_000)
is_done: bool
created_at: Optional[int] = None
@field_validator("id")
@classmethod
def validate_id(cls, v: str) -> str:
if not _is_uuidv4(v):
raise ValueError(f"id must be a UUIDv4, got: {v!r}")
return v
class BlockOut(BaseModel):
id: str
tag: str
description: str
is_done: bool
created_at: int
class TowerIn(BaseModel):
id: str
name: str = Field(max_length=200)
base_color: HslColor
blocks: list[BlockIn] = Field(max_length=1000)
@field_validator("id")
@classmethod
def validate_id(cls, v: str) -> str:
if not _is_uuidv4(v):
raise ValueError(f"id must be a UUIDv4, got: {v!r}")
return v
class TowerOut(BaseModel):
id: str
name: str
base_color: HslColor
blocks: list[BlockOut]
class PageIn(BaseModel):
id: str
name: str = Field(max_length=200)
hide_create_tower_button: bool = False
keep_tasks_open: bool = False
default_date_from: Optional[int] = None
default_date_to: Optional[int] = None
towers: list[TowerIn] = Field(max_length=100)
@field_validator("id")
@classmethod
def validate_id(cls, v: str) -> str:
if not _is_uuidv4(v):
raise ValueError(f"id must be a UUIDv4, got: {v!r}")
return v
class PageOut(BaseModel):
id: str
name: str
hide_create_tower_button: bool
keep_tasks_open: bool
default_date_from: Optional[int]
default_date_to: Optional[int]
towers: list[TowerOut]
class DataIn(BaseModel):
pages: list[PageIn] = Field(max_length=100)
@model_validator(mode="after")
def check_unique_ids(self) -> "DataIn":
page_ids: set[str] = set()
tower_ids: set[str] = set()
block_ids: set[str] = set()
total_blocks = 0
for page in self.pages:
if page.id in page_ids:
raise ValueError(f"Duplicate page id: {page.id}")
page_ids.add(page.id)
for tower in page.towers:
if tower.id in tower_ids:
raise ValueError(f"Duplicate tower id: {tower.id}")
tower_ids.add(tower.id)
for block in tower.blocks:
if block.id in block_ids:
raise ValueError(f"Duplicate block id: {block.id}")
block_ids.add(block.id)
total_blocks += 1
if total_blocks > 50_000:
raise ValueError(
f"Total blocks ({total_blocks}) exceeds maximum of 50,000"
)
return self
class DataOut(BaseModel):
pages: list[PageOut]
class RegisterRequest(BaseModel):
token: str
@field_validator("token")
@classmethod
def validate_token(cls, v: str) -> str:
if not _is_uuidv4(v):
raise ValueError("token must be a UUIDv4")
return v
class RegisterResponse(BaseModel):
user_id: str
class HealthResponse(BaseModel):
status: str