"""UI settings admin API (phase 91, task 01; colors-only since phase 190). The persistence surface of the admin Theme tab (the tab itself lands in tasks 04/05): the single ``ui_settings`` row (id 1) that stores what the admin sets — since phase 190 (A1, owner-confirmed 2026-10-18) the 17 palette colors (the 9 identity colors + the 8 semantic state colors, phase 93 — B3 revised) and NOTHING else. The three brand strings (app name, input placeholder, footer text) belong to the admin Settings panel (``app_settings``, phase 168): this row keeps its string columns as the documented FALLBACK for values it baked in before that phase, but the API never writes them (see the PUT bullet). The whole router sits behind :func:`app.core.auth.require_admin` (router-wide ``dependencies`` — the :mod:`app.api.tokens` pattern): anonymous callers AND token users get 403 on every route (only the admin themes the deployment). Routes (under ``/api`` via the ``main`` registration): * ``GET /api/ui-settings`` — the EFFECTIVE values (the resolver's DB-over-env / DB-over-built-in merge, B1): a missing row reports the env strings + the built-in palette, so a fresh tab shows the live theme. Creates nothing. The three strings stay in the response READ-ONLY (old-client compat, and the resolver's order makes them the EFFECTIVE ones — the panel's value when the panel owns the field). * ``PUT /api/ui-settings`` — a FULL replacement of the row's 17 COLORS: each color must match ``^#[0-9a-fA-F]{6}$`` (lowercased on store, else 422 naming the field), and — the owner-locked normalization — a color equal to its built-in is stored as NULL, so "save the defaults" leaves the row empty and the served HTML stays byte-identical (the no-op injection contract, task 02). The 3 string fields of the body are ACCEPTED AND IGNORED (phase 190 A1 — the request model keeps them so an old cached client still saves its palette; the handler drops them BEFORE validating or persisting, so a Theme-tab save can never re-bake a brand string and shadow the Settings panel again — the ``TODO.md`` L3 incident). The stored string columns are left exactly as they were, never cleared: with no panel row a legacy baked value still applies (the A1 fallback), and "reset" for a string is the panel's ``DELETE /api/admin/settings/{key}``. Upserts the id-1 row (SELECT → update-or-insert); a concurrent PUT is single-admin — last writer wins. Returns the new effective values. """ from __future__ import annotations import re from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select from sqlalchemy.orm import Session from app.config import Settings, get_settings from app.core import theming from app.core.auth import require_admin from app.db import get_db from app.models import UiSettings from app.schemas import UiSettingsIn, UiSettingsOut router = APIRouter( prefix="/ui-settings", tags=["ui-settings"], dependencies=[Depends(require_admin)], # phase 91: the Theme tab is admin-only ) #: The ``#rrggbb`` shape the color pickers produce (case-insensitive; #: lowercased on store so the stored/tagged hex is canonical). _HEX_COLOR = re.compile(r"^#[0-9a-fA-F]{6}$") def _validate_colors(payload: UiSettingsIn) -> dict[str, str | None]: """Validate + normalize the 17 palette colors (the 9 identity + the 8 semantic state — all ``COLOR_FIELDS``-driven, phase 93): strict ``#rrggbb`` (else 422 naming the field), lowercased on store, and a value equal to its BUILT-IN is stored as ``None`` — the owner-locked normalization that keeps "save the defaults" byte-identical (the row stays empty, the no-op injection contract).""" values: dict[str, str | None] = {} for field in theming.COLOR_FIELDS: raw = getattr(payload, field) if raw is None: values[field] = None continue if _HEX_COLOR.fullmatch(raw) is None: raise HTTPException( status_code=422, detail=f"{field} must be a #rrggbb hex color" ) value = raw.lower() values[field] = None if value == theming.BUILTIN_COLORS[field] else value return values @router.get("", response_model=UiSettingsOut) def get_ui_settings( settings: Settings = Depends(get_settings), # noqa: B008 db: Session = Depends(get_db), # noqa: B008 ) -> UiSettingsOut: """The effective UI settings — DB-over-env / DB-over-built-in (B1). Reads only: a missing row means "defaults" (the env strings + the built-in palette), so a fresh deployment's tab shows the live theme with an empty row, and nothing is ever upserted by a read. The 3 strings report the EFFECTIVE values through the phase-190 order (panel row → this row → env), so an old client's form pre-populates with what the deployment actually shows, never with this row's stale bake. """ return UiSettingsOut(**theming.effective_settings(db, settings)) @router.put("", response_model=UiSettingsOut) def update_ui_settings( payload: UiSettingsIn, settings: Settings = Depends(get_settings), # noqa: B008 db: Session = Depends(get_db), # noqa: B008 ) -> UiSettingsOut: """Replace the row's 17 COLORS with the body's (validated and normalized — see the module docstring), then report the new effective values. The body's 3 brand strings are NEVER read (phase 190, A1: the admin Settings panel is their single writable owner). Dropping them here rather than rejecting them keeps an old cached client's Save working — and takes away the mechanism of the ``TODO.md`` L3 incident: the row's string columns keep exactly what they hold (never written, so a legacy baked value survives as the documented fallback, and no save can re-bake the then-effective name and shadow every later panel change). Upsert on the id-1 row (SELECT → update-or-insert; the Python-side ``default=1`` supplies the PK on the insert). Concurrency is single-admin (one owner, one tab) — the last writer wins and no lock is taken: a lost race just means the other admin's PUT is the effective one. """ colors = _validate_colors(payload) row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first() if row is None: row = UiSettings(id=1) db.add(row) for field in theming.COLOR_FIELDS: setattr(row, field, colors[field]) db.commit() return UiSettingsOut(**theming.effective_settings(db, settings))