Updating CLAUDE.md
This commit is contained in:
1 parent
aef60d77be
commit
e24b8e5ea6
1 file changed
+94
-13
@@ -1,26 +1,41 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
Guidance for working in this repo. Read alongside `README.md` (user-facing setup).
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
Guidance for working in this repo. Read alongside `README.md` (user-facing setup
|
||||||
|
for the original FastAPI skeleton this app was built from).
|
||||||
|
|
||||||
## What this is
|
## What this is
|
||||||
|
|
||||||
A FastAPI skeleton serving **both** a JSON API (`/api/*`) and server-rendered
|
**Fitguide**: a parts-compatibility guide for Armament scopes and accessories,
|
||||||
HTML via **Jinja2 + HTMX** (`/`, `/admin/*`). Async SQLAlchemy 2.0 ORM, JWT auth
|
built on a FastAPI skeleton that serves **both** a JSON API (`/api/*`) and
|
||||||
with refresh-token revocation, and RBAC (users → roles → permissions).
|
server-rendered HTML via **Jinja2 + HTMX** (`/`, `/scopes`, `/admin/*`). Async
|
||||||
|
SQLAlchemy 2.0 ORM, JWT auth with refresh-token revocation (plus optional LDAP
|
||||||
|
direct-bind login), and RBAC (users → roles → permissions). The domain layer on
|
||||||
|
top of the skeleton tracks parts, their attachment points, and which parts fit
|
||||||
|
together — see "Domain model" below.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync --extra dev # install (incl. dev tools)
|
uv sync --extra dev # install (incl. dev tools)
|
||||||
|
uv run alembic upgrade head # apply migrations
|
||||||
uv run python -m scripts.seed # seed perms/roles/superuser (idempotent)
|
uv run python -m scripts.seed # seed perms/roles/superuser (idempotent)
|
||||||
uv run uvicorn app.main:app --reload # dev server on :8000
|
uv run uvicorn app.main:app --reload # dev server on :8000
|
||||||
uv run pytest # tests (in-memory SQLite)
|
uv run pytest # tests (in-memory SQLite)
|
||||||
|
uv run pytest tests/test_foo.py::test_bar # single test
|
||||||
uv run ruff check . [--fix] # lint
|
uv run ruff check . [--fix] # lint
|
||||||
uv run alembic upgrade head # apply migrations
|
uv run black . # format (ruff does not format here)
|
||||||
uv run alembic revision --autogenerate -m "msg" # new migration after model changes
|
uv run alembic revision --autogenerate -m "msg" # new migration after model changes
|
||||||
docker compose up --build # app + Postgres, auto-seeded
|
docker compose up --build # app + Postgres, auto-seeded
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`Makefile` wraps the common ones (`make sync|seed|dev|test|fmt|migrate`).
|
||||||
|
`make clean` deletes the local `app.db`, re-migrates, and reseeds — the quick
|
||||||
|
way to get a clean dev database. `make seed` runs `alembic upgrade head` before
|
||||||
|
seeding, i.e. day-to-day dev relies on migrations, not just
|
||||||
|
`AUTO_CREATE_TABLES` (see "Database").
|
||||||
|
|
||||||
## Architecture / layering
|
## Architecture / layering
|
||||||
|
|
||||||
Request → router (`app/api/` JSON or `app/web/` HTML) → `app/crud/` (data access)
|
Request → router (`app/api/` JSON or `app/web/` HTML) → `app/crud/` (data access)
|
||||||
@@ -28,40 +43,106 @@ Request → router (`app/api/` JSON or `app/web/` HTML) → `app/crud/` (data ac
|
|||||||
token logic in `app/services/auth.py`. Keep route handlers thin; put DB logic in
|
token logic in `app/services/auth.py`. Keep route handlers thin; put DB logic in
|
||||||
`crud/`, not in routers.
|
`crud/`, not in routers.
|
||||||
|
|
||||||
|
`app/api/` is one router module per resource, all mounted under `/api` by
|
||||||
|
`app/api/router.py`. `app/web/` is organized differently: `pages.py` (dashboard/
|
||||||
|
login), `scopes.py` (the public-facing `/scopes` fit guide), and **`admin.py`**
|
||||||
|
— a single ~3000-line router covering every admin screen (users, roles, part
|
||||||
|
taxonomy tables, imported parts, augmented parts + detail + nested attachment
|
||||||
|
points/fits, audit log, etc.), organized into `# --- Section ---` banner
|
||||||
|
comments per resource rather than split into files. When adding an admin screen,
|
||||||
|
follow the existing banner-section convention there rather than starting a new
|
||||||
|
web router module.
|
||||||
|
|
||||||
|
## Domain model
|
||||||
|
|
||||||
|
The business domain, independent of the auth/RBAC skeleton:
|
||||||
|
|
||||||
|
- **`AugmentedPart`** (`augmented_parts`) is the generic "part" entity — a
|
||||||
|
scope, flip cover, ARD, polarizer, adapter ring, etc. — distinguished by
|
||||||
|
`PartType`/`SubPartType`, with `PartColour`, `Manufacturer`, and an optional
|
||||||
|
`ScopeModel`. "Scope" is just one `PartType` value (see `SCOPE_TYPE_NAME` in
|
||||||
|
`app/services/scope_guide.py`), not a separate table.
|
||||||
|
- **`ScopeInfo`** is a 1:1 extension of `AugmentedPart` (magnification,
|
||||||
|
objective thread, model suffix) populated only for scope-type parts.
|
||||||
|
- **`AttachmentPoint`** belongs to one `AugmentedPart`, is named via
|
||||||
|
`AttachmentPointName` (e.g. "Ocular", "Objective"), and connects via
|
||||||
|
*exactly one* of `IsoThread` / `UncThread` / `ClampConnection` /
|
||||||
|
`StaticConnection` — that "exactly one" invariant is enforced in the app
|
||||||
|
layer (CRUD/API validation), not by a DB constraint.
|
||||||
|
- **`Fit`** (`app/models/fit.py`) pairs two `AttachmentPoint`s (canonical
|
||||||
|
ordering enforced in `app/crud/fit.py` so a pair only ever has one row),
|
||||||
|
labeled `possible` / `exact` / `none`, with threaded `FitNote`s.
|
||||||
|
`app/services/fit_matching.py` computes `possible` fits automatically
|
||||||
|
(matching ISO/UNC thread specs exactly, or a static connection's dimension
|
||||||
|
falling inside a clamp connection's range); `exact`/`none` are user-asserted
|
||||||
|
and never touched by the algorithm.
|
||||||
|
- **`ImportedPart`** (`imported_parts`, PK is the string `InventoryID` — not an
|
||||||
|
int id) holds raw rows from an external inventory feed, fetched via
|
||||||
|
`app/services/odata.py` (OData v4, paginated). `app/services/
|
||||||
|
augmented_part_sync.py` reconciles these into `AugmentedPart` +
|
||||||
|
`AttachmentPoint` rows: infers `PartType` by substring match on the
|
||||||
|
description, parses `TBXThreadAttribute` against ISO/UNC regexes (falling
|
||||||
|
back to an OD-derived `StaticConnection`), and prunes `AugmentedPart`s whose
|
||||||
|
backing `ImportedPart`s have disappeared.
|
||||||
|
- **`app/services/scope_import.py`** is a separate, manually-triggered bulk
|
||||||
|
importer: reads an uploaded `.xlsx` of third-party scopes, upserting by
|
||||||
|
`product_number` (→ `AugmentedPart.sku`) and creating `ScopeModel`,
|
||||||
|
`ScopeInfo`, up to three `AttachmentPoint`s, and up to two `Fit`s per row
|
||||||
|
(see the module docstring for the full column mapping).
|
||||||
|
- **`app/services/scope_guide.py`** assembles the `/scopes` page: one row per
|
||||||
|
Scope-type `AugmentedPart`, one column per other `PartType`, each cell
|
||||||
|
listing parts of that type with a recorded `Fit`.
|
||||||
|
|
||||||
## Conventions that matter
|
## Conventions that matter
|
||||||
|
|
||||||
- **Everything async.** Engine, sessions, CRUD, routes, tests. Use `await`; never
|
- **Everything async.** Engine, sessions, CRUD, routes, tests. Use `await`; never
|
||||||
add a sync DB call. `get_db` (in `app/core/database.py`) yields the request session.
|
add a sync DB call. `get_db` (in `app/core/database.py`) yields the request session.
|
||||||
- **Relationships use `lazy="selectin"`** (`User.roles`, `Role.permissions`) so
|
- **Relationships use `lazy="selectin"`** (`User.roles`, `Role.permissions`,
|
||||||
attributes are safe to read after the session closes — required under async.
|
`AttachmentPoint.iso_thread`, etc.) so attributes are safe to read after the
|
||||||
Preserve this when adding relationships that templates/schemas read.
|
session closes — required under async. Preserve this when adding relationships
|
||||||
|
that templates/schemas read.
|
||||||
- **Config** is `app/core/config.py` (`pydantic-settings`). Add new settings there;
|
- **Config** is `app/core/config.py` (`pydantic-settings`). Add new settings there;
|
||||||
they're auto-read from env vars (uppercased field name) or `.env`. Env wins.
|
they're auto-read from env vars (uppercased field name) or `.env`. Env wins.
|
||||||
- **RBAC**: guard API routes with `Depends(require_permission("resource:action"))`;
|
- **RBAC**: guard API routes with `Depends(require_permission("resource:action"))`;
|
||||||
guard web routes with `web_require(...)` in `app/web/admin.py`. Superusers bypass
|
guard web routes with `web_require(...)` in `app/web/admin.py`. Superusers bypass
|
||||||
all checks (`User.is_superuser` / `User.has_permission`). Add new permission
|
all checks (`User.is_superuser` / `User.has_permission`). Add new permission
|
||||||
strings to `scripts/seed.py`'s `PERMISSIONS`/`ROLES`.
|
strings to `scripts/seed.py`'s `PERMISSIONS`/`ROLES`.
|
||||||
|
- **HTMX UI-level write gating**: on top of route-level RBAC, pages that mix
|
||||||
|
read-only and read-write viewers (e.g. `augmented_part_detail.html`'s Fits and
|
||||||
|
Attachment Points sections) compute a `can_write_<thing>` bool from the
|
||||||
|
current user's permissions and wrap add/edit/delete controls in
|
||||||
|
`{% if can_write_<thing> %}` in the template, so read-only users see data but
|
||||||
|
not controls they'd get a 403 on. The route-level `web_require(...)` check
|
||||||
|
remains the real security boundary; the template flag is only for UX.
|
||||||
- **Auth, two transports**: API uses `Authorization: Bearer`; the HTMX UI uses an
|
- **Auth, two transports**: API uses `Authorization: Bearer`; the HTMX UI uses an
|
||||||
httpOnly cookie (`settings.cookie_name`). `app/deps.py` reads both. Web auth
|
httpOnly cookie (`settings.cookie_name`). `app/deps.py` reads both. Web auth
|
||||||
failures raise `NotAuthenticated`, handled in `main.py` as a redirect to `/login`.
|
failures raise `NotAuthenticated`, handled in `main.py` as a redirect to `/login`
|
||||||
|
(preserving the original path as `?next=`).
|
||||||
- **Refresh tokens** are stored server-side (hash + `jti` + `revoked`) in the
|
- **Refresh tokens** are stored server-side (hash + `jti` + `revoked`) in the
|
||||||
`refresh_tokens` table. `services/auth.rotate_refresh_token` revokes-then-reissues.
|
`refresh_tokens` table. `services/auth.rotate_refresh_token` revokes-then-reissues.
|
||||||
Note: JWT payload `exp` is a tz-aware `datetime` (PyJWT only epoch-encodes inside
|
Note: JWT payload `exp` is a tz-aware `datetime` (PyJWT only epoch-encodes inside
|
||||||
the token) — pass it straight to the model, don't `fromtimestamp` it.
|
the token) — pass it straight to the model, don't `fromtimestamp` it.
|
||||||
|
- **LDAP login** (`app/services/ldap.py`) is optional (`settings.ldap_enabled`),
|
||||||
|
direct-bind, and just-in-time provisions a local `User` on first successful
|
||||||
|
login. `ldap3` is sync, so its calls run via `run_in_threadpool`.
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
|
|
||||||
One `DATABASE_URL` drives SQLite (`sqlite+aiosqlite://`, the default) or Postgres
|
One `DATABASE_URL` drives SQLite (`sqlite+aiosqlite://`, the default) or Postgres
|
||||||
(`postgresql+asyncpg://`). `AUTO_CREATE_TABLES=true` calls `Base.metadata.create_all`
|
(`postgresql+asyncpg://`). `AUTO_CREATE_TABLES=true` calls `Base.metadata.create_all`
|
||||||
on startup for dev convenience; production should use Alembic instead. `init_db()`
|
on startup for dev convenience, but the day-to-day `make seed`/`make clean` flow
|
||||||
imports `app.models` inside the function to avoid a circular import.
|
runs `alembic upgrade head` first — treat Alembic migrations as the source of
|
||||||
|
truth and keep them in sync with model changes. `init_db()` imports `app.models`
|
||||||
|
inside the function to avoid a circular import.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
`tests/conftest.py` builds an in-memory SQLite engine with `StaticPool`, creates
|
`tests/conftest.py` builds an in-memory SQLite engine with `StaticPool`, creates
|
||||||
tables, and overrides `get_db`. Fixtures: `client` (httpx ASGI), `seeded`
|
tables, and overrides `get_db`. Fixtures: `client` (httpx ASGI), `seeded`
|
||||||
(perms+roles), `make_user`. `asyncio_mode = "auto"` — write `async def test_*`
|
(perms+roles from `scripts.seed.PERMISSIONS`/`ROLES`), `make_user`, and
|
||||||
without decorators.
|
`db_sessionmaker` (used directly in tests to seed domain rows — models, fits,
|
||||||
|
attachment points — beyond what `seeded`/`make_user` cover). `asyncio_mode =
|
||||||
|
"auto"` — write `async def test_*` without decorators.
|
||||||
|
|
||||||
## When extending
|
## When extending
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user