phase: 190_app_name_settings_fix
Build and Push Containers / build-and-push-app (push) Successful in 2m9s
Build and Push Containers / build-and-push-db (push) Successful in 15s

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`.
This commit is contained in:
2026-10-18 02:59:19 -04:00
parent 47456a44df
commit c6ffa050a6
59 changed files with 4104 additions and 556 deletions
+8 -6
View File
@@ -3,12 +3,14 @@ layer, the phase-59 docs-push flag (the "Save as doc" gating), the
phase-122 image flag (UI affordance gating), and the phase-62 UI
customization strings (composer placeholder, footer line).
Phase 91 (task 01): the three UI strings are now the EFFECTIVE values —
the ``ui_settings`` row (admin Theme tab) over the env values (B1: DB
wins when set, env is the fallback), resolved by the SAME
:func:`app.core.theming.effective_settings` resolver the
``/api/ui-settings`` API uses, so the brand layer and the tab can never
disagree. The route opens a short-lived session (the sync-endpoint
Phase 91 (task 01): the three UI strings are now the EFFECTIVE values,
resolved by the SAME :func:`app.core.theming.effective_settings` resolver
the ``/api/ui-settings`` API uses, so the brand layer and the tab can
never disagree. Phase 190 (task 02, A1) fixed WHO owns them: the admin
Settings panel's ``app_settings`` row (phase 168) first, then the
legacy ``ui_settings`` (Theme-tab) row, then the env value — so a name
set in the panel is what this endpoint serves, whatever an old Theme-tab
save baked into the other row. The route opens a short-lived session (the sync-endpoint
house pattern — the route is sync, matching the middleware world).
Phase 91 (task 03): the retired CSS-file theming's ``theme`` key is
+39 -38
View File
@@ -1,10 +1,14 @@
"""UI settings admin API (phase 91, task 01).
"""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 — the app name, input placeholder, footer text, and the 17
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). The whole router sits behind
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).
@@ -14,16 +18,25 @@ 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.
* ``PUT /api/ui-settings`` — a FULL replacement of the row: each
string is trimmed (empty → NULL, >300 → 422 naming the field), each
color must match ``^#[0-9a-fA-F]{6}$`` (lowercased on store, else 422
naming the field), and — the owner-locked normalization — a color
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). Upserts the id-1 row (SELECT →
update-or-insert); a concurrent PUT is single-admin — last writer
wins. Returns the new effective values.
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
@@ -49,28 +62,6 @@ router = APIRouter(
#: 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}$")
#: The strings' column length (mirrors ``ui_settings`` VARCHAR(300)).
_MAX_STRING_LEN = 300
def _validate_strings(payload: UiSettingsIn) -> dict[str, str | None]:
"""Trim the 3 display strings: empty after the trim → ``None``
(the clear operation), >300 chars after the trim → 422 naming the
field (the house fixed-detail style — the detail never varies by
value beyond naming the field)."""
values: dict[str, str | None] = {}
for field in theming.STRING_FIELDS:
raw = getattr(payload, field)
if raw is None:
values[field] = None
continue
value = raw.strip()
if len(value) > _MAX_STRING_LEN:
raise HTTPException(
status_code=422, detail=f"{field} is too long (max 300)"
)
values[field] = value or None
return values
def _validate_colors(payload: UiSettingsIn) -> dict[str, str | None]:
@@ -105,7 +96,11 @@ def get_ui_settings(
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.
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))
@@ -116,24 +111,30 @@ def update_ui_settings(
settings: Settings = Depends(get_settings), # noqa: B008
db: Session = Depends(get_db), # noqa: B008
) -> UiSettingsOut:
"""Replace the single row with the body's 20 values (validated and
"""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.
"""
strings = _validate_strings(payload)
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.STRING_FIELDS:
setattr(row, field, strings[field])
for field in theming.COLOR_FIELDS:
setattr(row, field, colors[field])
db.commit()