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
+39
View File
@@ -32,6 +32,12 @@ pre-migration deployment is a normal state); any other DB failure is
one WARNING per process naming the exception class ("settings
overrides unavailable — env values in effect"). Both "once per process"
guards are module-level flags, reset by :func:`reset_state_for_tests`.
Phase 190 (task 02) adds the one question another resolver asks this
table: :func:`applied_override_keys` — "which keys does the Settings
panel own right now?" (the brand-string ownership order in
:func:`app.core.theming.effective_settings`). It reads on the CALLER's
session, so answering it costs no second engine and never a stale view.
"""
from __future__ import annotations
@@ -45,6 +51,7 @@ from pydantic import ValidationError
from sqlalchemy import create_engine, text
from sqlalchemy.engine import Engine
from sqlalchemy.exc import ProgrammingError, SQLAlchemyError
from sqlalchemy.orm import Session
from sqlalchemy.pool import NullPool
from app.config import Settings
@@ -125,6 +132,38 @@ def load_overrides() -> dict[str, str]:
return {}
def applied_override_keys(session: Session) -> frozenset[str]:
"""The ``app_settings`` keys IN EFFECT right now — phase 190 task 02.
:func:`app.core.theming.effective_settings` needs exactly this to
answer "does the admin's Settings panel own this brand string?" (A1:
the panel row beats the Theme-tab row, and only a REAL row counts —
comparing values would mis-read a panel row that happens to equal
the env value, the exact shadow the phase-190 incident is).
Read on the CALLER's session — one ``SELECT`` on a connection the
caller already holds, transactionally consistent with the
``ui_settings`` read beside it (a cross-request memo of the key set
would go stale the moment a row is written outside the panel and
could re-shadow a just-saved value). The resolver calls this ONCE
per resolution and tests the three string fields for membership, so
a resolution costs no second query per field.
The kill switch (``BOR_SETTINGS_DB``, D6) short-circuits before any
read — with the overlay off no row is in effect, and the Theme-tab
row keeps its phase-91 fallback. A non-overridable key (LOCKED A3)
and an orphan row (a field a later release removed) are never
"in effect": :func:`merge` drops them, so they must not win here
either.
"""
if not enabled():
return frozenset()
keys = session.execute(text("SELECT key FROM app_settings")).scalars().all()
return frozenset(
key for key in keys if key in Settings.model_fields and CATALOG[key].overridable
)
def _coerce(field_name: str, raw: str) -> Any:
"""Coerce one env-native string row value for the candidate set.
+49 -11
View File
@@ -83,12 +83,28 @@ diverge (``tests/unit/test_theming.py`` pins the mirror: every
pairs).
Effective-value resolution (:func:`effective_settings`) — the DB-over-
env / DB-over-built-in merge (B1, owner-locked 2026-09-09): the single
``ui_settings`` row (id 1, task 01) wins column-by-column when set; a
NULL/empty string column falls back to the ``BOR_`` env value, a NULL
color column to the built-in. ONE resolver is used by BOTH
``GET /api/ui-settings`` (the tab) and ``GET /api/config`` (the brand
layer), so the tab and the running UI can never disagree.
env / DB-over-built-in merge (B1, owner-locked 2026-09-09) resolved
column-by-column:
* **the three brand strings** (``app_name`` / ``input_placeholder`` /
``footer_text``) — **Settings-panel row → Theme-tab row → env**
(phase 190, A1 owner-confirmed 2026-10-18). The panel (the
``app_settings`` rows behind ``/api/admin/settings``, phase 168) is
the SINGLE writable owner of the strings, so its row wins whenever it
exists; the Theme-tab row (this module's ``ui_settings``) is the
documented fallback for the rows it baked before this phase (no data
migration — a legacy row keeps rendering what it holds until the
panel overrides it); the ``BOR_`` env value is the last resort.
Presence, never value comparison: an ``app_settings`` row that
happens to equal the env value still owns the field (that equality is
exactly how the phase-190 shadow looked from the owner's side).
* **the 17 colors** — unchanged (B1): the ``ui_settings`` row when not
``NULL``, else the built-in palette. The ownership flip is
strings-only.
ONE resolver is used by BOTH ``GET /api/ui-settings`` (the tab) and
``GET /api/config`` (the brand layer), so the tab and the running UI
can never disagree.
"""
from __future__ import annotations
@@ -99,6 +115,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import Settings, get_settings
from app.core import settings_overlay
from app.models import UiSettings
#: The 17 built-in palette colors, keyed by BARE variable name (no
@@ -136,8 +153,12 @@ BUILTIN_COLORS: dict[str, str] = {
#: API, and the ``theme_style_tag`` renderer (task 02).
COLOR_FIELDS: tuple[str, ...] = tuple(BUILTIN_COLORS)
#: The 3 display strings the ``ui_settings`` row carries — env fallback
#: (B1: unlike the colors, the env vars stay the strings' default).
#: The 3 brand strings the ``ui_settings`` row carries. Since phase 190
#: (A1) the Settings panel (``app_settings``, phase 168) is their single
#: writable OWNER and wins over this row; the row is the documented
#: fallback for the values it baked in before that phase, with the env
#: value last (B1: unlike the colors, the env vars stay the default).
#: ``PUT /api/ui-settings`` no longer writes them.
STRING_FIELDS: tuple[str, ...] = ("app_name", "input_placeholder", "footer_text")
@@ -150,12 +171,22 @@ def effective_settings(
defaults, column by column:
* **strings** (``app_name`` / ``input_placeholder`` / ``footer_text``)
— the DB value when it is a non-empty string, else the env value
(``settings.app_name`` etc. — B1: the env vars stay the fallback);
— the three-way order of phase 190 (A1), per field:
**Settings-panel row** → **Theme-tab row** → **env**. The panel
row is ``getattr(settings, field)`` (the ``Settings`` the route
passes already resolves through the phase-168 overlay, so a panel
row IS its value) and it wins only when the overlay reports the
key as an actual override
(:func:`app.core.settings_overlay.applied_override_keys`, read
ONCE here — presence, never a value compare). Otherwise the
``ui_settings`` value when it is a non-empty string (the phase-91
fallback a legacy baked-in row still lands in), else the env
value (B1: the env vars stay the last resort).
* **colors** (the 17 :data:`COLOR_FIELDS` — 9 identity + 8
semantic state) — the DB value when not ``None``, else
:data:`BUILTIN_COLORS` (B1: no env fallback for colors — the
built-in palette IS the default).
built-in palette IS the default; the ownership flip is
strings-only).
A missing row (``GET`` creates nothing) means "defaults" — the env
strings + the built-in palette. The ``settings`` parameter names the
@@ -167,8 +198,15 @@ def effective_settings(
if settings is None:
settings = get_settings()
row = session.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
#: The keys the admin's Settings panel owns right now (phase 190
#: A1) — one SELECT on this same session, consulted per string
#: field below with a membership test (no query per field).
panel_keys = settings_overlay.applied_override_keys(session)
effective: dict[str, str] = {}
for field in STRING_FIELDS:
if field in panel_keys:
effective[field] = getattr(settings, field)
continue
value = getattr(row, field, None) if row is not None else None
effective[field] = value if isinstance(value, str) and value else getattr(settings, field)
for key in COLOR_FIELDS: