Files
skeleton-api/CLAUDE.md
T

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 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 AttachmentPoints (canonical ordering enforced in app/crud/fit.py so a pair only ever has one row), labeled possible / exact / none, with threaded FitNotes. 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 AugmentedParts whose backing ImportedParts 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 two AttachmentPoints, and up to two Fits 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

  • 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.
  • 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 with web_require(...) in app/web/admin.py. Superusers bypass all checks (User.is_superuser / User.has_permission). Add new permission 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 httpOnly cookie (settings.cookie_name). app/deps.py reads both. Web auth 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 table. services/auth.rotate_refresh_token revokes-then-reissues. 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.
  • 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

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.