"""Admin: departments and stations, people's profiles (one at a time and the CSV import), checklist templates. Every route needs the admin role and a password session. Every change is audited (`admin.*`). """ from __future__ import annotations import re import uuid from typing import Any from fastapi import APIRouter, Depends, Request from fastapi.encoders import jsonable_encoder from psycopg.types.json import Jsonb from pydantic import BaseModel, ConfigDict, Field from .. import audit from ..auth import groups as groupmap from ..domain import checklists, profiles from ..domain import departments as depts from .admin import groups_in_ou from .deps import Db, Principal, admin_principal, get_db from .errors import ApiError from .packets import WRITE router = APIRouter(prefix="/api/admin", tags=["admin"]) IMPORT_MAX_BODY = profiles.CSV_MAX_BYTES * 2 + 10_000 # JSON escaping can double the text def import_limit(request: Request) -> None: length = request.headers.get("content-length") if length is not None and length.strip().isdigit() and int(length) > IMPORT_MAX_BODY: raise ApiError(413, "too_large", "The file is larger than 1 MB.") def reload_groups(request: Request, conn: Any) -> None: """Rebuild the group map from the departments table and drop the cached OU listing.""" found = groupmap.load_departments(conn) if found: groupmap.use_departments(found) request.app.state.groups_cache = None # ---------------------------------------------------------------- departments class StationIn(BaseModel): model_config = ConfigDict(extra="forbid") id: int | None = None name: str = Field(max_length=200) sort: int = Field(0, ge=0, le=100_000) active: bool = True class DepartmentPatch(BaseModel): model_config = ConfigDict(extra="forbid") name: str | None = Field(None, max_length=200) active: bool | None = None stations: list[StationIn] | None = Field(None, max_length=depts.STATIONS_MAX) class DepartmentNew(BaseModel): model_config = ConfigDict(extra="forbid") code: str = Field(max_length=10) slug: str = Field(max_length=40) name: str = Field(max_length=200) kind: str = Field(pattern="^(shop|support)$") weld: bool = False supervisor_group: bool = True stations: list[str] = Field(default_factory=list, max_length=depts.STATIONS_MAX) @router.get("/departments") def department_list( request: Request, p: Principal = Depends(admin_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> list[dict[str, Any]]: return depts.list_all(d.conn, groups_in_ou(request)) @router.patch("/departments/{code}", dependencies=WRITE) def department_update( code: str, body: DepartmentPatch, request: Request, p: Principal = Depends(admin_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: conn = d.conn depts.update( conn, p, code[:10], name=body.name, active=body.active, stations=[s.model_dump() for s in body.stations] if body.stations is not None else None, ) reload_groups(request, conn) out = depts.get(conn, code[:10], groups_in_ou(request)) assert out is not None return out @router.post("/departments", status_code=201, dependencies=WRITE) def department_add( body: DepartmentNew, request: Request, p: Principal = Depends(admin_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: conn = d.conn _, groups = depts.add( conn, p, code=body.code, slug=body.slug, name=body.name, kind=body.kind, weld=body.weld, supervisor_group=body.supervisor_group, stations=body.stations, ) reload_groups(request, conn) dept = depts.get(conn, body.code.strip(), groups_in_ou(request)) return {"department": dept, "groups_to_create": groups} # ---------------------------------------------------------------- profiles class ProfilePatch(BaseModel): model_config = ConfigDict(extra="forbid") welder_stamp: str | None = Field(None, max_length=40) profile_active: bool | None = None station_limits: dict[str, list[int]] | None = None class ImportBody(BaseModel): model_config = ConfigDict(extra="forbid") csv: str apply: bool = False @router.patch("/users/{user_id}/profile", dependencies=WRITE) def profile_update( user_id: uuid.UUID, body: ProfilePatch, p: Principal = Depends(admin_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: fields = body.model_dump(exclude_unset=True) out = profiles.update(d.conn, p, user_id, fields) return {"user_id": str(user_id), **out} @router.post("/users/import", dependencies=[Depends(import_limit)]) def profile_import( body: ImportBody, p: Principal = Depends(admin_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: return profiles.import_profiles(d.conn, p, body.csv, apply=body.apply) # ---------------------------------------------------------------- checklists RX_BRACES = re.compile(r"\{[^{}]*\}") ALLOWED_PLACEHOLDERS = ("{drawing}", "{rev}", "{wps}") class ChecklistsPut(BaseModel): model_config = ConfigDict(extra="forbid") value: dict[str, Any] version: int = Field(ge=0) def check_templates(value: Any) -> tuple[dict[str, list[dict[str, str]]], list[str]]: """The checklist templates cleaned, and one error line per problem.""" errors: list[str] = [] out: dict[str, list[dict[str, str]]] = {} if not isinstance(value, dict): return {}, ["value must be an object with weld and plain lists"] extra = sorted(k for k in value if k not in ("weld", "plain")) if extra: errors.append(f"unknown list: {', '.join(extra)}") for name in ("weld", "plain"): items = value.get(name) if not isinstance(items, list) or not items: errors.append(f"{name}: at least one item") continue if len(items) > checklists.ITEMS_MAX: errors.append(f"{name}: at most {checklists.ITEMS_MAX} items") seen: set[str] = set() clean: list[dict[str, str]] = [] for i, it in enumerate(items, start=1): where = f"{name} item {i}" if not isinstance(it, dict): errors.append(f"{where}: must be an object with group, key and text") continue group = " ".join(str(it.get("group") or "").split()) key = str(it.get("key") or "").strip() text = " ".join(str(it.get("text") or "").split()) if not checklists.RX_KEY.match(key): errors.append(f"{where}: the key is a lower-case letter, then letters, digits or _ (at most 30)") elif key in seen: errors.append(f"{where}: key {key} is used twice") seen.add(key) if not group: errors.append(f"{where}: the group is empty") if not text: errors.append(f"{where}: the text is empty") if len(group) > checklists.TEXT_MAX or len(text) > checklists.TEXT_MAX: errors.append(f"{where}: at most {checklists.TEXT_MAX} characters") bad = [m for m in RX_BRACES.findall(text) if m not in ALLOWED_PLACEHOLDERS] stray = RX_BRACES.sub("", text) if bad: errors.append(f"{where}: unknown placeholder {', '.join(bad)} (use {{drawing}}, {{rev}} or {{wps}})") elif "{" in stray or "}" in stray: errors.append(f"{where}: a brace without its pair") clean.append({"group": group, "key": key, "text": text}) out[name] = clean return out, errors def _checklists_out(conn: Any) -> dict[str, Any]: row = conn.execute("SELECT version FROM settings WHERE key = %s", (checklists.SETTINGS_KEY,)).fetchone() return { "value": checklists.load_templates(conn), "defaults": checklists.DEFAULT_TEMPLATES, "version": int(row[0]) if row else 0, } @router.get("/checklists") def checklists_get(p: Principal = Depends(admin_principal), d: Db = Depends(get_db)) -> dict[str, Any]: # noqa: B008 return _checklists_out(d.conn) @router.put("/checklists", dependencies=WRITE) def checklists_put( body: ChecklistsPut, p: Principal = Depends(admin_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: conn = d.conn value, errors = check_templates(body.value) if errors: raise ApiError(422, "bad_checklists", errors[0], extra={"errors": errors}) with conn.transaction(): row = conn.execute( "SELECT version FROM settings WHERE key = %s FOR UPDATE", (checklists.SETTINGS_KEY,) ).fetchone() current = int(row[0]) if row else 0 if body.version != current: raise ApiError( 409, "version_conflict", "Someone else changed the checklists. Reload and try again.", extra={"current": jsonable_encoder(_checklists_out(conn))}, ) before = checklists.load_templates(conn) conn.execute( "INSERT INTO settings (key, value, version, updated_at, updated_by) VALUES (%s, %s, 1, now(), %s)" " ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, version = settings.version + 1," " updated_at = now(), updated_by = EXCLUDED.updated_by", (checklists.SETTINGS_KEY, Jsonb(value), p.user_id), ) audit.append( conn, action="admin.checklists_updated", user_id=p.user_id, session_id=p.session_id, role_snapshot=p.role_snapshot, subject_type="settings", subject_id=checklists.SETTINGS_KEY, detail={"before": before, "after": value}, ip=p.ip, ) return _checklists_out(conn)