Make the software-request notification deliverable, async, and observable - #6
Conversation
settings/prod.py has selected the real SMTP backend since the request form landed, but nothing anywhere configured a host — so production silently targeted unauthenticated localhost:25 as webmaster@localhost, a combination no relay accepts and no container can serve. Every submit saved its row and then 500d on the send. Typed fields on Settings for host, port, TLS/SSL mode, credentials, From address and a socket timeout (Django's backend has none, and the send is about to move onto the single prefork Celery worker). TLS+SSL together is refused at boot rather than at Django's send-time check, which is post-response once the send is a task. ProductionSettings requires email_host and default_from_email — the same refuse-to-boot-on-a-placeholder shape as ILIFU_ADMIN_EMAILS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fwHvQmMcVNe86kqX9jS2V
A nullable timestamp, not a state machine: NULL means "support has not been told", whether the send is still pending or was given up on — retries are Celery's business, not the schema's — and the admin queue marker this feeds only asks that binary question. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fwHvQmMcVNe86kqX9jS2V
A slow or unreachable relay used to hold a gunicorn thread for the full SMTP timeout on every submit, and a failed send 500d a request that was in fact saved — total failure, to the one person who could not tell the difference. The send now runs in a Celery task with three sixty-second retries; the submit succeeds the moment the row exists. The failure contract the old TODO asked to be re-chosen: exhaustion (and a dead broker — the enqueue is guarded, unlike the ingest API's, because this caller is a human, not a retrying collector) logs ERROR and leaves support_notified_at NULL, which the admin queue will render as a "support not notified" marker. The retry tests flip CELERY_TASK_EAGER_PROPAGATES off via override_settings: with propagation on, trace re-raises Retry before Task.apply's eager re-execution branch can run, so a transient failure is indistinguishable from a permanent one through the view — and it must be a Django override, not an app.conf write, because celery's config chain reads django.conf:settings live and shadows conf edits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fwHvQmMcVNe86kqX9jS2V
The always-success submit contract moved notification failure out of the requester's sight, so it has to land somewhere support already looks: a "support not notified" marker on the queue row while support_notified_at is NULL. --warn rather than --err, because a just-submitted row is legitimately un-notified for the seconds before the worker runs — "needs a look", not "broken". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fwHvQmMcVNe86kqX9jS2V
There was a problem hiding this comment.
Pull request overview
This PR makes software-request email notifications non-blocking and production-safe by moving SMTP delivery off the request cycle into a Celery task, adding an explicit SMTP settings contract, and exposing delivery failures in the admin queue.
Changes:
- Add typed SMTP configuration to
Settings(with production-requiredEMAIL_HOST+DEFAULT_FROM_EMAIL) and export these into Django settings. - Introduce
SoftwareRequest.support_notified_atplus a Celery task (notify_support_of_request) with retries and error logging to send/stamp asynchronously. - Surface “support not notified” in the admin request queue and update tests/docs accordingly.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Documents new production-required mail relay settings. |
| ANSIBLE_DEPLOY.md | Adds deploy-time guidance for required SMTP variables. |
| .env.example | Documents SMTP env vars and their defaults/intent. |
| catalog/src/ilifu_catalog/settings/base.py | Adds validated SMTP settings fields + exports into Django settings. |
| catalog/src/ilifu_catalog/settings/prod.py | Requires email_host / default_from_email in prod and hardcodes SMTP backend. |
| catalog/src/ilifu_catalog/requests_app/models.py | Adds support_notified_at timestamp to track notification delivery. |
| catalog/src/ilifu_catalog/requests_app/migrations/0002_softwarerequest_support_notified_at.py | Migration adding support_notified_at. |
| catalog/src/ilifu_catalog/requests_app/views.py | Enqueues notification task after saving the request; guards/logs broker failures. |
| catalog/src/ilifu_catalog/requests_app/tasks.py | New Celery task to send mail with retries, logging, and stamp update. |
| catalog/src/ilifu_catalog/software/views_admin.py | Adds support_notified flag to queue row view-model. |
| catalog/src/ilifu_catalog/templates/software/partials/admin/_queue_card.html | Shows “support not notified” marker when the stamp is still NULL. |
| catalog/tests/test_settings.py | Tests SMTP settings export, default timeout, TLS/SSL mutual exclusion, and prod requirements. |
| catalog/tests/test_request_form.py | Updates request-form behavior tests for async notification + retry paths. |
| catalog/tests/test_admin_screen.py | Tests the admin queue marker behavior for un-notified requests. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Code reviewVerdict: ready to merge with fixes. No critical issues. The core mechanics — retry accounting, Verified strengths
Important
Minor
Recommendations
Fixes for all of the above are being pushed to this branch. 🤖 Generated with Claude Code |
Review of #6: the request form's enqueue guard only helps if the publish returns. A refused broker fails fast, but a hung one held the socket for as long as the OS allowed, times kombu's default three publish retries — the same unbounded-wait shape the async send removed from SMTP, back in the request path. The broker connection now carries the same two-second socket bounds as the cache aliases, and the publish retries once, quickly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fwHvQmMcVNe86kqX9jS2V
Review of #6: migration 0002 left `support_notified_at` NULL on every pre-existing request — rows that *were* notified, synchronously, by the code this PR replaced. Left alone, each would wear the admin queue's "support not notified" marker forever: a permanent false alarm on the one screen where the marker is meant to prompt action, teaching admins to ignore it from day one. Stamp them with `created_at` — an approximation that looks like one — and leave rows with a real send record untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fwHvQmMcVNe86kqX9jS2V
Review of #6: in the 300px sidebar the status badge and username are flex: none, leaving the package name as the queue row's only shrinkable element — so the worded "support not notified" badge ellipsized the name on exactly the rows needing attention. The marker is now the design's `!` glyph, --warn as before, with the wording kept in its title/aria-label. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fwHvQmMcVNe86kqX9jS2V
Review of #6, three contracts tightened at the task: - A redelivered message (worker restart mid-task, a retry whose first delivery completed) no longer duplicates the email or re-stamps the row: the task bails on an already-stamped row, and the stamp itself filters on isnull so the first send's timestamp survives a race. - The module docstring claimed the task must never raise because eager mode would propagate into the request — the retry path *does* propagate eagerly, and it is the view's guarded enqueue that absorbs it. The docstring now states the real reason, and a NOTE at the `.delay()` site pins the autocommit dependency `on_commit` would otherwise hide. - The send-fails test asserted any ERROR record and so passed on the guard's log line while believing it tested the give-up branch; it now names the message it means, and the retry tests' function-local imports and generator annotation are cleaned up with it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fwHvQmMcVNe86kqX9jS2V
Review of #6: the notification task added a fourth copy of the `str(settings.SUPPORT_EMAIL) # type: ignore[misc]` accessor. The ignore and its django-stubs rationale now live once in `ilifu_catalog.support` — a leaf module every site can import, which the per-screen helpers could not be (`views_catalog` imports `detail_panels`, and `requests_app.views` imports the task that would need it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fwHvQmMcVNe86kqX9jS2V
- settings/test.py passes the eight email fields explicitly, restoring its "every value explicit" contract — an ambient EMAIL_USE_TLS=1 EMAIL_USE_SSL=1 would otherwise have failed the whole suite at import. - compose.prod.yaml's --threads comment no longer cites the synchronous send_mail this PR deleted; CLAUDE.md's Celery section now counts all three tasks. - ANSIBLE_DEPLOY.md documents the implicit-SSL two-variable trap: setting only EMAIL_USE_SSL=1 now takes the site down at boot, not just email. - tasks.py joins the CI 100%-coverage gate (matched by basename, so both the ingest apply and the support notification) — it has the same silent-failure profile as the rest of the list and is at 100% today. - README's "Open" list records that a permanently failed notification has no resend control beyond a shell. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fwHvQmMcVNe86kqX9jS2V
|
All review findings addressed, one per commit:
Verification: full suite in the containerised Postgres/Redis setup (with 🤖 Generated with Claude Code |
Why
The request form saved its row and then called
send_mailsynchronously withfail_silently=False— while nothing anywhere configured an SMTP host. In production that meant Django's fallbacks (unauthenticatedlocalhost:25aswebmaster@localhost) inside a container with no MTA: every submit saved the row and then showed the requester a 500. The synchronous send was also a recorded TODO at the site, waiting on a decision about where failure should surface once it moved off the request cycle.What
Settings(EMAIL_HOST, port, TLS/SSL mode, credentials,DEFAULT_FROM_EMAIL,EMAIL_TIMEOUT— Django's backend has no socket timeout of its own). Defaults suit smtp-relay.gmail.com in IP-allowlist mode (587 + STARTTLS, no auth).ProductionSettingsrequiresEMAIL_HOSTandDEFAULT_FROM_EMAIL: prod hardcodes the SMTP backend, so omitting them can only produce a deployment whose notifications die silently — the same refuse-to-boot shape asILIFU_ADMIN_EMAILS. TLS+SSL together is refused at boot rather than at Django's send-time check.SoftwareRequest.support_notified_at— nullable timestamp; NULL means "support has not been told", pending or given up. Stamped by queryset.update()so the task can't clobber a concurrent triage.requests_app/tasks.py::notify_support_of_request, three retries at 60s. The submit succeeds the moment the row exists; send failure, retry exhaustion and a dead broker (the enqueue is guarded — this caller is a human, not a retrying collector) all log ERROR and leave the stamp NULL. Email content unchanged, including the Keycloak-lookup line.support not notified(--warn) on queue rows whose stamp is NULL: the failure now surfaces where support already looks. It shows transiently on fresh rows until the worker runs — deliberate, noted in the template.Notes for review
.apply()underCELERY_TASK_EAGER_PROPAGATES=False(override_settings— celery's config chain readsdjango.conf:settingslive and shadowsapp.confwrites). With propagation on, trace re-raisesRetrybeforeTask.apply's eager re-execution branch runs, so a transient failure is indistinguishable from a permanent one through the view.MaxRetriesExceededError:Task.retry(exc=...)re-raises the original exception on exhaustion, so that catch is a branch that never runs.EMAIL_HOSTandDEFAULT_FROM_EMAILare set (values expected at deploy time; see.env.exampleand ANSIBLE_DEPLOY.md).Verification
Full suite in the containerised Postgres/Redis setup: 1241 passed, 5 skipped, 100% coverage.
pre-commit run --all-filesclean (including the djlint lint pass on the template change).🤖 Generated with Claude Code
https://claude.ai/code/session_015fwHvQmMcVNe86kqX9jS2V