8.6 KiB
CLAUDE.md
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
Fitguide: a parts-compatibility guide for Armament scopes and accessories,
built on a FastAPI skeleton that serves both a JSON API (/api/*) and
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
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 uvicorn app.main:app --reload # dev server on :8000
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 black . # format (ruff does not format here)
uv run alembic revision --autogenerate -m "msg" # new migration after model changes
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
Request → router (app/api/ JSON or app/web/ HTML) → app/crud/ (data access)
→ app/models/. Schemas in app/schemas/, cross-cutting DI in app/deps.py,
token logic in app/services/auth.py. Keep route handlers thin; put DB logic in
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 byPartType/SubPartType, withPartColour,Manufacturer, and an optionalScopeModel. "Scope" is just onePartTypevalue (seeSCOPE_TYPE_NAMEinapp/services/scope_guide.py), not a separate table.ScopeInfois a 1:1 extension ofAugmentedPart(magnification, objective thread, model suffix) populated only for scope-type parts.AttachmentPointbelongs to oneAugmentedPart, is named viaAttachmentPointName(e.g. "Ocular", "Objective"), and connects via exactly one ofIsoThread/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 twoAttachmentPoints (canonical ordering enforced inapp/crud/fit.pyso a pair only ever has one row), labeledpossible/exact/none, with threadedFitNotes.app/services/fit_matching.pycomputespossiblefits automatically (matching ISO/UNC thread specs exactly, or a static connection's dimension falling inside a clamp connection's range);exact/noneare user-asserted and never touched by the algorithm.ImportedPart(imported_parts, PK is the stringInventoryID— not an int id) holds raw rows from an external inventory feed, fetched viaapp/services/odata.py(OData v4, paginated).app/services/ augmented_part_sync.pyreconciles these intoAugmentedPart+AttachmentPointrows: infersPartTypeby substring match on the description, parsesTBXThreadAttributeagainst ISO/UNC regexes (falling back to an OD-derivedStaticConnection), and prunesAugmentedParts whose backingImportedParts have disappeared.app/services/scope_import.pyis a separate, manually-triggered bulk importer: reads an uploaded.xlsxof third-party scopes, upserting byproduct_number(→AugmentedPart.sku) and creatingScopeModel,ScopeInfo, up to twoAttachmentPoints, and up to twoFits per row (see the module docstring for the full column mapping).app/services/scope_guide.pyassembles the/scopespage: one row per Scope-typeAugmentedPart, one column per otherPartType, each cell listing parts of that type with a recordedFit.
Conventions that matter
- Everything async. Engine, sessions, CRUD, routes, tests. Use
await; never add a sync DB call.get_db(inapp/core/database.py) yields the request session. - Relationships use
lazy="selectin"(User.roles,Role.permissions,AttachmentPoint.iso_thread, etc.) so attributes are safe to read after the 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; they're auto-read from env vars (uppercased field name) or.env. Env wins. - RBAC: guard API routes with
Depends(require_permission("resource:action")); guard web routes withweb_require(...)inapp/web/admin.py. Superusers bypass all checks (User.is_superuser/User.has_permission). Add new permission strings toscripts/seed.py'sPERMISSIONS/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 acan_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-levelweb_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 httpOnly cookie (settings.cookie_name).app/deps.pyreads both. Web auth failures raiseNotAuthenticated, handled inmain.pyas a redirect to/login(preserving the original path as?next=). - Refresh tokens are stored server-side (hash +
jti+revoked) in therefresh_tokenstable.services/auth.rotate_refresh_tokenrevokes-then-reissues. Note: JWT payloadexpis a tz-awaredatetime(PyJWT only epoch-encodes inside the token) — pass it straight to the model, don'tfromtimestampit. - LDAP login (
app/services/ldap.py) is optional (settings.ldap_enabled), direct-bind, and just-in-time provisions a localUseron first successful login.ldap3is sync, so its calls run viarun_in_threadpool.
Database
One DATABASE_URL drives SQLite (sqlite+aiosqlite://, the default) or Postgres
(postgresql+asyncpg://). AUTO_CREATE_TABLES=true calls Base.metadata.create_all
on startup for dev convenience, but the day-to-day make seed/make clean flow
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/conftest.py builds an in-memory SQLite engine with StaticPool, creates
tables, and overrides get_db. Fixtures: client (httpx ASGI), seeded
(perms+roles from scripts.seed.PERMISSIONS/ROLES), make_user, and
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
Add a feature → model in models/ (+ register in models/__init__.py), schema in
schemas/, CRUD in crud/, routes in api/ and/or web/, then a migration. If it
needs access control, add the permission to the seed and guard the routes.