6.1 KiB
6.1 KiB
The DisExcel Project — Context for Claude Code
Backend: FastAPI + SQLAlchemy (async) + Pydantic v2 + PostgreSQL. JWT auth (access + refresh tokens), RBAC permissions, Redis cache, RabbitMQ background workers. Python >=3.13,<4.0, Poetry for dependency management.
Auth & Permissions
- Users have
direct_permissions(list ofPermissions) andgroup(list ofPermissionsGroups, each with its ownpermissions) — many-to-many both ways. - Effective permissions =
direct_permissions ∪ (union of all groups' permissions). require_permissions(*permissions)insrc/web/protected_routesis a FastAPI dependency factory — wrapsCurrentUserService.get_current_user. Call with no args (require_permissions()) for "just authenticated, no specific permission needed".- Access tokens carry a
jticlaim. Logout writesrevoked_access_token:{jti}to Redis with TTL = remaining token lifetime —get_current_userchecks this key before anything else. secureflag on refresh_token cookie is driven byenv_settings.PROD_MODE(bool) —Falselocally/tests so cookies work over plain HTTP,Truein prod.
Redis (src/cache/)
RedisClient(redis.Redis)— module-level shared singleton, subclassesredis.Redisdirectly (inherits all commands, no manual wrapping needed).- Three uses: permissions is-cache was considered and rejected (no real DB
savings —
get_user_by_idalready eager-loads everything viaselectinin one call); rate limiting on login (RateLimit.rate_limit(ip)—INCR+EXPIREon first attempt, blocks >5/60s); access-token revoke blacklist (see above). - Rate limit is only triggered inside
except HTTPExceptionon/protected/token— i.e. only on failed logins, not successful ones (otherwise legitimate repeated logins would trip it).
RabbitMQ (src/messaging/)
RabbitMQClient— shared class, lazyconnect()(can't be async__init__), holds oneconnection+ onechannel,get_channel()ensures setup.- Topic exchange named
"email". Each message type gets its own routing key (email.welcome,email.reset) and its own durable queue (queue_welcome_email,queue_reset_email), each queue explicitly bound to the exchange with its own key. - Important: both producer and consumer must declare/bind the queues —
if only the consumer does it and the consumer has never run,
publishon a not-yet-existing queue silently loses the message.EmailProducer.setup()also declares+binds both queues defensively. message.process()async context manager = manual ack (auto-ack on success, requeue/nack on exception) — this is the right choice for email, notno_ack=True.- Email templates: Jinja2, inline CSS (email clients don't support
<style>reliably),EmailMessagewithset_content()(plain-text fallback) +add_alternative(html, subtype="html").
Daemons / workers (src/daemons/)
BaseDaemonABC (name+ asyncrun()), one subclass per consumer (WelcomeEmailDaemon,ResetEmailDaemon, more to come — e.g. reports).DAEMONSregistry dict maps string name → daemon class.run_daemon.py(project root) is the single entrypoint:python run_daemon.py <name>runs one daemon,python run_daemon.py --allreadsconfigs/daemons.json({"daemons": [...]}) and runs all enabled ones concurrently viaasyncio.gather.- Each daemon runs as its own Docker service/container (
command: ["python", "run_daemon.py", "<name>"]), same pattern as themigrationservice. daemons.jsonis read with a plain PydanticBaseModel+ manualjson.load, NOTpydantic-settingsjson_file— that requires wiringsettings_customise_sourcesmanually in this pydantic-settings version and isn't worth the complexity here.
Docker / Poetry groups
pyproject.tomluses PEP 621[project.dependencies]for shared deps (sqlalchemy, redis, aio-pika, pydantic, bcrypt, jose, aiofiles, asyncpg, psycopg2-binary, alembic, greenlet).webgroup: fastapi, uvicorn, gunicorn, python-multipart — only needed by the API server.daemongroup: worker-only deps (currently empty, grows as needed).devgroup: pytest stack, allure, httpie, requests-async.- Dockerfile has parallel builder→final stage pairs:
builder→prod(installsmain,web) andworker-builder→worker(installsmain,daemon). Same base pattern: venv builder stage copies/opt/venvinto a clean final stage, poetry itself is uninstalled after install to keep the final image lean. - Alembic runs against a separate sync engine (
asyncpgswapped out, psycopg2 used instead) — async SQLAlchemy engine can't drive Alembic directly without therun_syncbridge, and a dedicated sync engine is simpler than that bridge. DB_HOSTdiffers between contexts:psql(Docker service name) for containers talking to each other,localhostfor anything run on the host (e.g. localalembic revision --autogenerate). Compose services overrideDB_HOSTviaenvironment:; the.envfile's own default is for host-side runs.
Testing (tests/unit, tests/integrated, tests/e2e)
- Recurring root cause of "different event loop" /
MissingGreenlet-style errors: prod code uses module-level singletons (engine,redis_client) created once at import time and reused for the app's whole lifetime — this is correct for prod (one event loop, whole uptime) but breaks under pytest-asyncio's defaultfunction-scoped event loop (a new loop per test, but the singleton's connections stay bound to the first loop). Fix: test fixtures create a freshengine/RedisClientper test and monkeypatch or inject them in place of the global singleton, then dispose on teardown — not a globalsession-scoped event loop (that would mask real isolation bugs). - e2e
MySessionmust subclasshttpx.AsyncClient(notrequests_async. AsyncSession— that library silently drops cookies between requests, which broke refresh-token-cookie-dependent tests like logout). test_user_fixtureisindirect=Trueparametrized with(direct_permissions, group)tuples.