Instruction file imported from shammy642/led_manager (
.github/instructions/overview.instructions.md). Copyright stays with the author.
Architecture overview
Server-rendered FastAPI app: Jinja2 templates + HTMX partial swaps, not a JSON API. Non-HTML surfaces are the monitor WebSockets (/ws/monitor/*) and the Settings SQLite export download.
Layers (top → bottom)
- Routes (app/webapp_routes/) — thin. Parse forms, coordinate services/CRUD, render templates. Never contain SQL or database-transfer implementation details.
- CRUD (app/crud/) — one module per entity (
receiver_crud.py,device_crud.py,player_crud.py). Functions takesession: Sessionfirst, return models, raise domain errors. - Models (app/models/) — SQLModel tables with Pydantic validators. Own all field validation/normalization.
- Services (app/services/) — external-system integrations (dnsmasq, arp, ping, update) plus the SQLite-only database transfer boundary. Services never import routes or CRUD.
database_transfer.pydeliberately imports the SQLModel types only to validate imported rows before installation. - Utils (app/utils/) — shared helpers (exceptions, formatting, form parsing, config writers). No imports from crud/routes/services.
Normal dependency direction is downward: routes → crud → models and routes → services → utils. The database transfer service is a lifecycle exception: it receives the database path and engine callbacks, and imports models for snapshot validation; it must remain independent of routes, CRUD, monitoring, and dnsmasq. CRUD modules may import each other for FK checks (e.g. receiver_crud calls device_crud.get_device; use a function-local import to avoid cycles, as in device_crud._validate_player_id).
Sync vs async
- Route handlers and CRUD are sync (SQLite via
check_same_thread=False). - Async only around monitoring:
MonitorHub,PingManager,PingProbe, WebSocket routes, and the monitor start/stop button routes. MonitorHubis a module-level singleton viaget_monitor_hub(); tests reset it withreset_monitor_hub_for_tests().
Directory ownership
| Path | Belongs here |
|---|---|
| app/models/ | SQLModel tables, plus tiny shared types (sort.py StrEnums, form_error.py) |
| app/crud/ | DB read/write functions, one file per entity: <entity>_crud.py |
| app/webapp_routes/ | FastAPI routers; two files per entity, with single-router page exceptions such as Settings |
| app/services/ | Integrations with the OS/network (subprocess, WebSocket hubs) and SQLite transfer lifecycle |
| app/utils/ | Pure helpers shared across layers |
| app/templates/ | Jinja2: pages/ full pages, partials/ row/status fragments, buttons/ button fragments |
| app/static/ | CSS and vendored JS (htmx.min.js); mounted at /static |
Where new code goes
| Task | Location & pattern | Example |
|---|---|---|
| New entity | model in app/models/<entity>.py, CRUD in app/crud/<entity>_crud.py, routers in app/webapp_routes/<entity>_routes.py + <entity>_button_routes.py, templates pages/<entity>s.html + partials/<entity>_row.html, register both routers in app/app.py |
receiver stack |
| New page | app/webapp_routes/<name>_routes.py with prefix /<name>, template pages/<name>.html, add active_page branch in app/templates/index.html |
update_routes.py |
| Settings page | app/webapp_routes/settings_routes.py with normal export and HTMX import endpoints; no button-router split |
settings_routes.py |
| New HTMX action | POST endpoint in the entity's _button_routes.py returning a partial |
edit_receiver_button |
| New external integration | class in app/services/<name>.py with from_env() + injectable command runner |
ArpScanner |
| Database transfer | app/services/database_transfer.py; inject database path, maintenance context, engine disposer, and upload limit |
DatabaseTransfer |
| New shared helper | app/utils/<topic>.py, pure function |
format_validation_errors |
| New domain error | error family in app/utils/exceptions.py carrying .messages: dict[str, str] |
DeviceConflictError |
| DB schema change | edit the model; no migrations exist — tables are created by SQLModel.metadata.create_all() in the lifespan |
app/app.py |
App wiring
app/app.py creates the FastAPI(lifespan=...) app (lifespan calls create_db_and_tables()), mounts /static, includes every router including settings_router, and redirects / → read_monitor. New routers must be imported and included there.