Files
brain-of-reese/app/api/ui_settings.py
T
ducoterra c6ffa050a6
Build and Push Containers / build-and-push-app (push) Successful in 2m9s
Build and Push Containers / build-and-push-db (push) Successful in 15s
phase: 190_app_name_settings_fix
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`.
2026-10-18 02:59:19 -04:00

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))