Files
fitguide/CLAUDE.md
T
2026-07-19 12:02:41 -03:00

4.0 KiB

CLAUDE.md

Guidance for working in this repo. Read alongside README.md (user-facing setup).

What this is

A FastAPI skeleton serving both a JSON API (/api/*) and server-rendered HTML via Jinja2 + HTMX (/, /admin/*). Async SQLAlchemy 2.0 ORM, JWT auth with refresh-token revocation, and RBAC (users → roles → permissions).

Commands

uv sync --extra dev                    # install (incl. dev tools)
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 ruff check . [--fix]            # lint
uv run alembic upgrade head            # apply migrations
uv run alembic revision --autogenerate -m "msg"   # new migration after model changes
docker compose up --build              # app + Postgres, auto-seeded

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.

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) 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.
  • 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.
  • Credential verification goes through services/auth.authenticate(db, username, password) — both login routes call it, not crud.user.authenticate directly. When settings.ldap_enabled, it binds against LDAP first (services/ldap.py, sync ldap3 offloaded via run_in_threadpool), provisions/refreshes the local row via crud.user.upsert_ldap_user (unusable local password, auth_provider="ldap", LDAP_DEFAULT_ROLES), then falls back to local DB auth. LDAP config lives in settings.ldap_*. Tests fake the blocking bind by patching services.ldap._authenticate_sync.
  • 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.

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; production should use Alembic instead. 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), make_user. 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.