14 KiB
14 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.connect()retriesconnect_robust()up to 5 times with exponential backoff (asyncio.sleep(2**attempt)) before raising — needed because when running the app locally (outside Docker Compose) right afterdocker compose up rabbitmq, the broker's AMQP listener isn't immediately ready and resets the connection mid-handshake (ConnectionResetError). Compose's owndepends_on: condition: service_healthyonly helps when the app itself also runs as a Compose service — it does nothing when the app is started on the host.- Topology is centralized, not declared ad-hoc.
src/models/rabbitmq_models/email.pydefines the whole topology as pydantic data (Topology/ExchangeSpec/QueueSpec, exported asemail_topology) — topic exchange"email", one durable quorum queue per message type (queue_welcome_email/email.welcome,queue_reset_email/email.reset), each with a matching dead-letter queue.src/messaging/topology_setup.py::apply_topology(channel, topology)is the only place that actually callsdeclare_exchange/declare_queue/bindagainst RabbitMQ. It's called once at startup — inmain.py'slifespan(web) and indaemon_run.py'smain()(daemon entrypoint) — before any producer/consumer touches the exchange/queues.EmailProducer/WelcomeEmailConsumer/ResetEmailConsumerno longer declare or bind anything themselves — they onlyget_exchange("email")/get_queue("queue_..."), assumingapply_topologyalready ran. Changing a queue's arguments inemail_topologyrequires manually deleting the existing queue in RabbitMQ first —declare_queueon an already-existing queue with different arguments fails withPRECONDITION_FAILED, it doesn't update in place. - Dead-lettering: each original queue's
x-dead-letter-exchangepoints atemail.dlx, adirectexchange (notfanout— fanout would broadcast every dead-lettered message to all DLQs, mixing up welcome/reset failures). Each DLQ is bound toemail.dlxwith the same routing key as the original queue (email.welcome/email.reset); RabbitMQ preserves a message's original routing key when dead-lettering, so no explicitx-dead-letter-routing-keyoverride is needed — thedirectexchange alone routes each dead letter back to the correct DLQ. message.process(ignore_processed=True)+ manualawait message.nack(...)inprocess_message, not the plain auto-ack/nack thatmessage.process()does by default — needed because business-logic exceptions are caught inside the block (to log them and decide retry vs. dead-letter) rather than left to propagate, soprocess()itself must be told not to also try to ack/nack on exit. Classification: transient errors (SMTP/network:SMTPServerDisconnected,SMTPConnectError,TimeoutError,ConnectionRefusedError) →nack(requeue=True); everything else (bad message body,SMTPAuthenticationError, bugs) →nack(requeue=False)→ DLQ. NoteSMTPAuthenticationErroris a known rough edge here — it's a systemic config problem (bad credentials), not a per-message one, but is currently DLQ'd per-message like any other permanent error rather than halting the daemon with an alert.- Email templates: Jinja2 (
src/service/email/jinja_env.py— one sharedEnvironment/FileSystemLoadersingleton, templates insrc/service/email/templates/), inline CSS (email clients don't support<style>reliably),EmailMessagewithset_content()(plain-text fallback) +add_alternative(html, subtype="html"). - Reset-password flow mirrors welcome:
ResetEmailSender(src/service/email/email_reset.py) renderstemplates/reset.html({{ temp_password }}, no longer hardcoded) the same wayDaemonEmailSenderdoeswelcome.html.ResetEmailConsumer.process_messagereads bothemailandtemp_passwordfrom the message body and uses the same transient/permanent classification asWelcomeEmailConsumer.EmailProducer.send_reset_email(email, temp_password)takes the password as a second argument now. Still missing: nothing in the app actually callssend_reset_emailyet — there's no password-reset route that generates atemp_passwordand publishes it. Don't assume the reset-password feature is reachable end-to-end until that route exists.
Logging (src/logging/)
- All log output (HTTP endpoints, SQL, daemons) funnels through one
module-level
asyncio.Queue(log_queueinsrc/logging/logger.py) and a singleLogWriter.log_writer()background task that drains it and appends to./logs/{type}_log_{month}_{year}.txt— this avoids the original bug of firing an unsupervisedasyncio.create_taskper log line (unordered writes, lost logs if the process died before the task ran).log_writer()must be started explicitly wherever the process entrypoint is — it'sasyncio.create_task(writer.log_writer())inmain.py'slifespanfor the web process, and the same call indaemon_run.py'smain()for the daemon process.log_queueis process-local (plain in-memoryasyncio.Queue, not shared across processes), so every new entrypoint needs its own writer task or its logs silently queue forever and are never written (unbounded memory growth, not a crash). logging.Handlersubclasses (LoggerDBforsqlalchemy.engine,LoggerDaemonfor the"daemon"logger) just push(type, formatted_msg)ontolog_queuefromemit()— do not give them a custom__init__that doesn't callsuper().__init__(); skipping it meansself.level/self.filters/etc. never get set and any log call raisesAttributeError: 'LoggerX' object has no attribute 'level'.- Two
ContextVars tag log lines with a correlation id without threading it through every function call:request_id_ctx(set once per HTTP request inLoggingMiddleware.dispatch) andmessage_id_ctx(meant to be set once per RabbitMQ message inprocess_message). Only visible within the same async call chain — aContextVarset in an HTTP request is"-"(the default) inside daemon code, and vice versa; they don't cross the process boundary either. src/logging/logger.pyvssrc/logging/http_logger.pysplit matters for Docker.logger.pyhas zerofastapi/starletteimports — intentional, because thedaemonPoetry group (and therefore the daemon Docker image) never installsfastapi.http_logger.pyholdsLoggingMiddleware/ProcessingTimeMiddleware(the only things that actually need FastAPI/Starlette) and is imported only frommain.py.src/logging/__init__.pyimports only fromlogger.py. Never importsrc.logging.http_loggerfrom anything that runs in the daemon process (consumers.py,email_welcome.py,daemon_run.py, etc.) — it would drag infastapi, which raisesModuleNotFoundErrorin the daemon container.- Plain
logger.exception(...)must only be called from inside anexceptblock — it pulls the active exception viasys.exc_info()to attach a traceback. Called outsideexcept(e.g. for a routine "message received" log line), it still runs but appends a literalNoneType: Noneinstead of a traceback, since there's no active exception to format.
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.daemon_run.py(project root) is the single entrypoint:python daemon_run.py <name>runs one daemon,python daemon_run.py --allreadsconfigs/daemons.json({"daemons": [...]}) and runs all enabled ones concurrently viaasyncio.gather.main()also applies the RabbitMQ topology (apply_topology) and starts the log writer task before dispatching to either mode, inside atry/finallyso both the single-daemon and--allpaths cancel the writer task on the way out.- Each daemon runs as its own Docker service/container (
command: ["python", "daemon_run.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 —aiosmtpd,jinja2(email sending and templating). Must not gain afastapi/starlettedependency; see thesrc/logging/logger.pyvshttp_logger.pysplit above for why that boundary is enforced deliberately.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.tests/unit/test_consumers.pycoversRabbitMQClient/WelcomeEmailConsumer/ResetEmailConsumerentirely with mocks — no real broker involved. Pattern:monkeypatch.setattr(rabbitmq_client_module.aio_pika, "connect_robust", ...)patches the module attribute thatconnect()looks up at call time (not therabbitmq_clientsingleton's method — that's a bound method, it has no attribute of its own to patch).aio_pika.connect_robust/asyncio.sleepmust both be mocked when testing the retry loop, or the test really sleeps2**attemptseconds between attempts.message.process(...)is an async context manager, not a plain awaitable — mocking it needs aMagicMockwith__aenter__/__aexit__set toAsyncMocks (seemake_fake_message()in that file), not justAsyncMock(). When asserting on what a mocked async method returned, compare againstmock.return_value(or a variable captured before assigning it), never against the mock itself —some_mock is some_mock.return_valueis never true, they're two different objects.