diff --git a/Architecture.md b/Architecture.md index fd6d954..6b6f555 100644 --- a/Architecture.md +++ b/Architecture.md @@ -2,25 +2,30 @@ Small FastAPI app rendering Jinja2 templates with HTMX-driven partial updates. SQLite is the only storage. SQLAlchemy 2.x is the ORM; Alembic -manages schema. `uv` manages Python deps. +manages schema. `uv` manages Python deps. Structured JSON logging to +stdout is the runtime-observability contract; see +[Operations](Operations). ## Modules ``` src/quartermaster/ - main.py FastAPI app factory - routes.py Budget configuration HTTP handlers - routes_month.py Monthly view HTTP handlers - service.py Budget queries, totals, target, zero-amount, group views - month_service.py Month snapshot, deviation, per-month CRUD, lifecycle, group views - groups.py Group enum, labels, section->group mapping, default open - models.py SQLAlchemy models, Section enum, MonthState enum - db.py Engine, session, PRAGMA foreign_keys=ON - config.py DB URL resolution - templates/ Jinja2 templates (base, index, month, partials) + main.py FastAPI app factory + routes.py Budget configuration HTTP handlers + routes_month.py Monthly view HTTP handlers + routes_health.py GET /healthz (unauthenticated, separate router) + service.py Budget queries, totals, target, zero-amount, group views + month_service.py Month snapshot, deviation, per-month CRUD, lifecycle, group views + groups.py Group enum, labels, section->group mapping, default open + models.py SQLAlchemy models, Section enum, MonthState enum + db.py Engine, session, PRAGMA foreign_keys=ON + config.py DB URL resolution + logging_config.py Loads LOG_CONFIG from logconfig.json; AccessLogFilter + logconfig.json dictConfig source-of-truth (tests + uvicorn both consume) + templates/ Jinja2 templates (base, index, month, partials) static/ - app.css Full stylesheet (Barlow Condensed + ledger layout) - brand/ Logo assets (full, mark-wide, shield-wide) + app.css Full stylesheet (Barlow Condensed + ledger layout) + brand/ Logo assets (full, mark-wide, shield-wide) ``` ## Sections and groups @@ -50,7 +55,7 @@ entry id, section, name, amount, notes (nullable, up to 1024 chars), created_at, updated_at -debt_target (singleton, id = 1) +debt_target (singleton, id = 1) id, debt_minimum_id FK entry(id) ON DELETE SET NULL, updated_at ``` @@ -79,7 +84,7 @@ posting description NULL, payee NULL, created_at, updated_at -month_debt_target (singleton per month) +month_debt_target (singleton per month) month_id PK FK month(id) ON DELETE CASCADE, month_entry_id FK month_entry(id) ON DELETE SET NULL, updated_at @@ -192,28 +197,28 @@ Colour tone: ### Budget (`src/quartermaster/routes.py`) ``` -GET / index page -POST /sections/{section}/entries add entry -DELETE /entries/{entry_id} remove entry -POST /entries/{entry_id}/notes update entry notes -POST /debt-target set / clear debt target +GET / index page +POST /sections/{section}/entries add entry +DELETE /entries/{entry_id} remove entry +POST /entries/{entry_id}/save update entry (name, amount, notes) +POST /debt-target set / clear debt target ``` ### Month (`src/quartermaster/routes_month.py`) ``` -GET /month/{year_month} view page or create-flow -POST /month/{year_month}/create snapshot the budget (lands in Planning) -POST /month/{year_month}/activate Planning -> Active -POST /month/{year_month}/close Active -> Closed (requires applied zero = 0) -POST /month/{year_month}/reopen Closed -> Active -POST /month/{year_month}/sections/{section}/entries add month entry -POST /month/{year_month}/entries/{entry_id} update (name / planned / notes) -DELETE /month/{year_month}/entries/{entry_id} remove month entry -POST /month/{year_month}/entries/{entry_id}/postings add a transaction -POST /month/{year_month}/postings/{posting_id} update a transaction -DELETE /month/{year_month}/postings/{posting_id} delete a transaction -POST /month/{year_month}/target set / clear per-month target +GET /month/{year_month} view page or create-flow +POST /month/{year_month}/create snapshot the budget (lands in Planning) +POST /month/{year_month}/activate Planning -> Active +POST /month/{year_month}/close Active -> Closed (requires applied zero = 0) +POST /month/{year_month}/reopen Closed -> Active +POST /month/{year_month}/sections/{section}/entries add month entry +POST /month/{year_month}/entries/{entry_id} update (name / planned / notes) +DELETE /month/{year_month}/entries/{entry_id} remove month entry +POST /month/{year_month}/entries/{entry_id}/postings add a transaction +POST /month/{year_month}/postings/{posting_id} update a transaction +DELETE /month/{year_month}/postings/{posting_id} delete a transaction +POST /month/{year_month}/target set / clear per-month target ``` `update_month_entry` no longer accepts an `applied` field. Applied @@ -229,6 +234,18 @@ All mutation routes return the section partial plus OOB swaps for: OOB is used so section-level changes keep the page-level summary widgets accurate without a reload. +### Health (`src/quartermaster/routes_health.py`) + +``` +GET /healthz liveness + DB reachability (unauthenticated) +``` + +Separate router, zero app-level dependencies. Success: 200 +`{"status":"ok"}`. Failure: 503 +`{"status":"error","detail":""}` — class name +only, no message or traceback leaked. A failed probe emits a +structured warning log with `event=healthz_failed`, `error_class=`. + ## HTMX conventions * Month entry rows carry inline inputs for `name` and `planned` plus a @@ -252,6 +269,23 @@ accurate without a reload. and the server returns 204 + `HX-Redirect: /month/{year_month}` so the page re-renders cleanly in the new state. +## Observability + +See [Operations](Operations) for the Logs and Health sections. Brief +summary: + +* `src/quartermaster/logconfig.json` is the single source of truth for + the `logging.config.dictConfig`. Loaded into `LOG_CONFIG` in + `logging_config.py` at import time; same file read by uvicorn CLI + via `--log-config`. +* Formatter: `pythonjsonlogger.json.JsonFormatter` with `rename_fields` + mapping `asctime/levelname/name` → `timestamp/level/logger`. +* `AccessLogFilter` in `logging_config.py` enriches uvicorn access + records with `event="http_request"`, `method`, `path`, `status`, + `client_ip`. +* Five seed app events at the main mutation sites plus one + `healthz_failed` event at the probe. + ## Visual identity * **Typography**: Barlow Condensed (300-800 + italic) imported from @@ -276,7 +310,7 @@ accurate without a reload. 5-column summary grid (caret / name / planned / applied / actions). The 2px progress bar rides the summary's bottom border. * Budget entry rows use `table.entries` with a 4-column grid. - * `.target-section` with a burgundy left bar and `↳` margin glyph + * `.target-section` with a burgundy left bar and ↳ margin glyph * `.state-badge` tracked-caps label with bullet separators ## Testing @@ -286,4 +320,11 @@ accurate without a reload. share the same in-memory engine across threads via `StaticPool`. * Backup script tests shell out and assert on filenames and sqlite round-trips. -* Full suite runs in under 4 seconds. +* Logging tests instantiate the JSON formatter from `LOG_CONFIG` + directly, push a record through a `StringIO` handler, assert on the + parsed output. `AccessLogFilter` is tested with a synthetic uvicorn + `LogRecord`. Seed events are tested via `caplog`. The dictConfig + smoke test saves / restores logger state in a try/finally so it + cannot leak `propagate=False` to subsequent tests. +* `/healthz` is tested with and without a failing session. +* Full suite (148 tests) runs in under 6 seconds.