All gates re-verified green. Report: - **Verified (all 4 tasks already complete; this was the final pass):** `effective_settings` resolves the 3 brand strings panel-row → tab-row → env (`applied_override_keys`, presence-not-value); `PUT /api/ui-settings` ignores string body fields; Theme-tab form is colors-only (17 pickers, no `#theme-app-name`/`#theme-placeholder`/`#theme-footer`); the ONE shared `applyBrandFromConfig` re-applies title/`.brand-text`/footer/placeholder after a panel save-reset and a tab save-reset; boot path byte-identical (single `/api/config`). - **Completion criteria:** - strings order panel→tab→env, both branches — ✅ `tests/unit/test_theming.py::test_effective_app_name_prefers_settings_panel_row_over_ui_settings_row`, `..._fall_back_to_ui_settings_row_without_panel_row`, `..._per_field_panel_then_tab_then_env`, `..._ignore_panel_rows_when_the_overlay_is_off` - PUT ignores string fields + no 3 inputs in the tab — ✅ `tests/integration/test_ui_settings_api.py::test_put_ignores_string_fields_in_body` (+2) and the E2E `test_theme_tab_dom_is_colors_only` - live apply without reload + incident regression — ✅ `tests/e2e/test_app_name_settings_fix.py` tests 1–4 - byte-identical boot, no double-apply — ✅ `tests/unit/test_brand_live.py` markers + E2E "exactly one /api/config" - suite green, coverage >90%, ruff+pyright clean — ✅ - no behavior change in prior phases — ✅ (palette, phase-168 panel, env fallback suites all green) - atomic commit — **left to the harness** (pipeline rule overrides per-phase committing; no git write run) - **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **4630 passed, TOTAL 6941 16 99%**, exit 0 · `uv run pytest tests/e2e/test_app_name_settings_fix.py -v --no-cov` → **6 passed** in isolation · regression E2E (isolated, one process each, all exit 0): `test_smoke` `test_configurable_brand` `test_admin_theme_tab` `test_theme_save_and_coverage` `test_theme_semantic_completion` `test_themed_favicon` `test_dark_tech_theme` `test_admin_settings_panel` `test_admin_settings_layout` `test_settings_mobile_value_width` · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors. - **Documentation:** README — fixed "saving one repaints *every* open page" (only the open document repaints; other pages on next load) and a broken paragraph wrap in *Customizing the look*; AGENTS.md rule 16 — `test_chunks_hnsw_recall.py` now described accurately (index paths forced with `enable_seqscan`/`indexonlyscan`/`bitmapscan` off, unforced plan asserted only on a pristine relation); `tests/unit/test_theme_frontend.py` — two stale section headers ("20 form fields"/"20 inputs" → 17 color). No AGENTS.md settings-overlay/resolution-order clause exists, so per the phase none was added. Full env-var cross-check (`BOR_*` in `app/config.py` vs README) → 0 gaps; 0 missing file references. - **Notable:** the working tree also carries this phase's flake hardening of the phase-188 `tests/integration/test_chunks_hnsw_recall.py` (verified legit and green). Live infra untouched; shared DB left clean (0 `app_settings`, 0 `ui_settings`). **Flagged, not changed:** `settings_overlay.applied_override_keys` lets a `ProgrammingError` (missing `app_settings`) propagate from public `GET /api/config`, where `load_overrides` deliberately fails soft — only reachable on an un-migrated (0027) DB, outside this phase's pinned A1 semantics, so left for the owner to rule on rather than silently deviating. - **Next pending phase:** `191_source_name_labels_tags`.
142 lines
6.4 KiB
Python
142 lines
6.4 KiB
Python
"""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))
|