**Phase 168 — Admin Settings panel — final verification: PASS** 1. **Implemented/verified:** `/settings.html` (admin-only) with the 75-field catalogue (`app/core/settings_catalog.py`), DB-over-env overrides in Postgres (`app_settings`, migration 0028 at head, `settings_overlay.py` resolver, `app/api/admin_settings.py` — 4 admin-only routes), live-apply on save with no restart + restart-required badges, Reset/Clear all, write-only secrets, `.env`-never-written, `BOR_SETTINGS_DB=0` kill switch. 2. **Phase E2E: 12/12** — run with `E2E_APP_PORT=8200` (ports 8123/8124 are held by an unrelated "undercooked" project; no process killed, no code change — the documented env override). 3. **Regression set: all 7 suites green in isolation** (theme tab, API tokens, configurable brand, global tuning, admin auth, cache busting, wide desktop column — 38 tests). 4. **Full suite: unit+integration exit 0 (4066 passed), app/ coverage 99%** (gate >90%); **ruff clean, pyright 0 errors / 0 warnings.** 5. **Log contract (rule 10) verified live:** per-turn + per-response byte-budget lines present; settings write lines carry key + action, **never the value**. 6. **Byte-budget audit: all endpoints under the 262,144 B budget**; settings catalogue 23,513 B (gzip 3,326). 7. **Defect fixed (one):** criterion 5 had no integration pin — added `test_bad_row_inserted_directly_never_bricks_boot` to `tests/integration/test_admin_settings_api.py` (malformed row inserted directly → fresh `create_app()` boots, row skipped with logged reason, rest of table applies, `/api/health` 200) — 12/12 green. 8. **Docs pass:** `README.md` and `AGENTS.md` reviewed in full — both consistent with the as-built feature (Settings page table, Settings section, Configuration-reference note, `.env.example` block); **no changes required**. 9. **Cleanup:** audit app killed (by PID); `app_settings` left empty (0 rows); all changes left in the working tree (no commit, per protocol). 10. **Completion criteria:** all 7 checkboxes verified green (criterion 7's commit is the harness's). 11. **Next pending phase:** `170_edit_bubble_sizing`.
82 lines
3.4 KiB
Python
82 lines
3.4 KiB
Python
"""app_settings: admin DB-side overrides for any BOR_* setting (phase 168, task 01)
|
|
|
|
Revision ID: 0028
|
|
Revises: 0027
|
|
Create Date: 2026-10-09
|
|
|
|
``TODO.md`` L3: "I want an admin panel that can edit anything in .env
|
|
and save it to the database. The database value should override the
|
|
env var. The change should take effect on clicking 'save' on that
|
|
page." This revision carries the storage half of that (phase 168 D1)
|
|
— the resolver (D2) and the live-apply hook (D3) are task 02/03.
|
|
|
|
* ``app_settings`` — one row per admin setting override, the whole
|
|
table a narrow key/value pair:
|
|
|
|
* ``key`` — VARCHAR(100) NOT NULL, PRIMARY KEY. The BARE
|
|
``Settings`` field name (``relevance_threshold``) — never the
|
|
``BOR_…`` form: the prefix is a presentation concern
|
|
(``"BOR_" + key.upper()``), so a stored prefix could double up.
|
|
The PK is the uniqueness guard: two rows for one setting would
|
|
make "which one wins" undefined, and Postgres enforces it for
|
|
free (the ``ui_settings`` single-row shape is not an option here
|
|
— there are 75 settings, and a wide row would need per-field DDL
|
|
for every new one, defeating "edit ANYTHING in .env").
|
|
* ``value`` — TEXT NOT NULL. The value in the SAME textual form
|
|
the env var would carry (``"0.55"``, ``"true"``, ``"md,txt"``,
|
|
a JSON array for the one ``list[str]`` field). Storing the env-
|
|
native string — not a typed column — keeps coercion and
|
|
validation in exactly one place (the pydantic validators the
|
|
overlay runs the full candidate set through, phase 168 D1/A5),
|
|
and makes "anything in .env" work for all 75 settings with zero
|
|
per-field DDL. NOT NULL on purpose: "clear the override" is a
|
|
DELETE of the row (the panel's per-row Reset / Clear all), never
|
|
an empty value, so the resolver never has to distinguish
|
|
"no override" from "override to empty".
|
|
* ``updated_at`` — TIMESTAMPTZ NOT NULL, ``now()`` server default
|
|
(the ``folder_summaries`` / ``kb_overview.updated_at`` house
|
|
style): when the admin last saved the override.
|
|
|
|
No index beyond the PK: the whole table is read by primary key order
|
|
in ONE ``SELECT`` per settings resolution (the ``ui_settings``
|
|
"display fact, never a query surface" note, 0027).
|
|
|
|
One additive, fully reversible migration (A13).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sqlalchemy as sa
|
|
|
|
from alembic import op
|
|
|
|
revision = "0028"
|
|
down_revision = "0027"
|
|
branch_labels = None
|
|
depends_on = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
# No index beyond the PK (a display fact, never a query surface —
|
|
# the whole table is one SELECT per resolution) and no server
|
|
# default on ``value`` (NOT NULL — the reset is a DELETE).
|
|
op.create_table(
|
|
"app_settings",
|
|
sa.Column("key", sa.String(length=100), primary_key=True),
|
|
sa.Column("value", sa.Text(), nullable=False),
|
|
sa.Column(
|
|
"updated_at",
|
|
sa.DateTime(timezone=True),
|
|
server_default=sa.func.now(),
|
|
nullable=False,
|
|
),
|
|
)
|
|
|
|
|
|
def downgrade() -> None:
|
|
# The table is the only 0028 artefact — dropping it leaves 0027's
|
|
# schema byte-identical (A13, fully reversible). No data is lost
|
|
# by the drop in any meaningful sense: the values are re-enterable
|
|
# from the panel (they are an override of the env, not a source of
|
|
# truth — the env file stays the base, LOCKED A1).
|
|
op.drop_table("app_settings")
|