20 KiB
20 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.- CORS:
CORSMiddlewareinmain.pywithallow_origins=[env_settings. FRONT_URL]andallow_credentials=True(needed for the httpOnlyrefresh_tokencookie — with credentials,["*"]is rejected by browsers).FRONT_URLis a requiredEnvfield with no default, so the backend fails to start without it inconfigs/.env(e.g.http://localhost:5173). It is added last, after the logging middlewares, so it wraps them and CORS headers also appear on error responses (401/429).
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 (RateLimit.rate_limit(ip, action, limit=5, window=60)—INCR+EXPIRE ... NXon first attempt, keyrate_limit:{action}:{ip}, so each action has its own counter); access-token revoke blacklist (see above). - Two rate-limited actions:
"login"(5/60s) and"reset"(3/600s). The login limit is only triggered insideexcept HTTPExceptionon/protected/token— i.e. only on failed logins, not successful ones (otherwise legitimate repeated logins would trip it)./protected/resetcounts every call.
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. - Reset route:
POST /protected/reset?email=...inauth_routes.py—emailis a plainstrparam, so FastAPI reads it from the query string, not the JSON body. The route rate-limits (3/600s per IP), callsCrudService.reset_password(email)(generates a 16-char temp password viaHashService.generate_temp_password()— which isasync— hashes and saves it throughupdate_user), then publishes{email, temp_password}toemail.reset. A 404 (unknown email) is swallowed so the response is always{"detail": "New password was sent"}— no user enumeration. Known rough edge: the DB password is changed before the email is sent, so a message that ends up in the DLQ leaves the user with a changed password and no email; and anyone who knows an email can reset that account's password. A token-link flow would avoid both.
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.claudegroup:graphifyy(the/graphifyClaude Code skill) — dev tooling only, never referenced by any Dockerfile stage, install withpoetry install --with claudewhen needed locally.- 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.
Codebase knowledge graph (graphify)
/graphify(Claude Code skill,graphifyypackage in theclaudePoetry group) builds a navigable knowledge graph of this repo intographify-out/(graph.html,GRAPH_REPORT.md,graph.json) — gitignored, regenerable, never commit it.- Its own interpreter/venv detection defaults to
uv tool/pipx-style installs; this project has neither —graphifyyis installed straight into the project's own.venvvia Poetry, so point graphify at.venv/bin/python3directly rather than letting it search foruv. - The sandbox's blanket
**/.env/**/.env.*read-deny blocks graphify from readingconfigs/.env.exampletoo (not just the real.env) — it shows up asskipped_sensitivein detection. This is a sandbox-level block, not graphify's own sensitive-file heuristic; a real.envwould be excluded by graphify anyway, but.env.example(no real secrets) would otherwise be safe to include if the sandbox allowed reading it. - A
GRAPH HEALTH WARNING(dangling-endpoint edges, collapsed edges) on a fresh build most likely means the AST extractor's node-ID format changed since a previous partial run — re-run withgraphify extract --forceif it persists across rebuilds; a one-off warning on first build isn't necessarily a problem.
Frontend (frontend/)
- React 19 + TypeScript + Vite +
react-router-dom7, package manager is yarn (yarn.lock). Run withyarn dev(Vite, defaulthttp://localhost:5173). The user writes/edits frontend code themselves — explain rather than edit unless asked (see memory). - Layout:
src/features/auth/components/(AuthComponent,ResetComponent, each with its own.module.css),src/pages/(LoginPageat/,ForgotPasswordPageat/forgot-password— thin wrappers),src/layouts/AuthLayout(shared split-screen shell with the marketing block; takeschildrenfor the form on the right),src/api/(token.ts,LoginAPI/Auth.ts,ResetAPI/Reset.ts),src/types/UserTypes.ts(mirrors the PydanticUserOut/UserCreate).index.css= global reset/font,App.css=#rootframe. - Env: Vite reads
.env*fromfrontend/configs/(envDir: "./configs"invite.config.ts), not fromfrontend/. OnlyVITE_-prefixed variables reach the browser code, viaimport.meta.env. Backend address isVITE_BACKEND_URL, exported asAPI_URLfromsrc/api/token.ts. Restart the dev server after editing.env. There is no Viteserver.proxy(removed on purpose) — the browser calls the backend directly, so backend CORS (see Auth) must allow the frontend origin. - Access token lives in a module variable in
src/api/token.ts(setAccessToken/getAccessToken), not inlocalStorage; it is lost on page reload (a/protected/refreshcall on startup is not implemented yet).authFetch(path, init)addsAuthorization: Bearerandcredentials: "include". Login (POST /protected/token) must be sent as form-data (URLSearchParamswithusername= email andpassword), because the route usesOAuth2PasswordRequestForm, and returns{access_token, token_type}; the refresh token arrives as an httpOnly cookie. - Forms are controlled inputs (
useState) with hand-written validation: per-fielderrorsstate, checked on blur and on submit;<form noValidate>so browser bubbles don't preempt it. Invalid field gets.inputError, text goes in an absolutely positioned.errorTextunder the field. Backend errors are logged withconsole.errorand shown as a short generic message. - CSS Modules gotchas: only class selectors are hashed. Bare element
selectors (
form {},input {}) and#idselectors stay global and leak between components (one module'sform { gap }restyled the other form). Scope them under the module's own class (.wrapper_1 form {}) — the hashed ancestor makes the rule local. Classes with hyphens are accessed asstyles["email-icon"]. Class names shared between two modules are independent copies (duplicate the rules, don't rely on sharing). - React 19 types:
FormEventis deprecated; useSubmitEvent<HTMLFormElement>.<p>must not contain<div>(hydration warning). - Login currently redirects to
http://localhost:8000/docsafter success (placeholder — no post-login page yet);ResetComponentcopies that placeholder redirect too.
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.