phase: 168_admin_settings_panel
**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`.
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user