From f3da494f73245a6a57cdbf95eb7cb266875d03fe Mon Sep 17 00:00:00 2001 From: Simonas Kareiva Date: Sun, 17 May 2026 12:31:44 +0300 Subject: [PATCH] Add email notification for staff --- apps/submissions/emails.py | 71 ++++++++++++++++++++- apps/submissions/models.py | 24 ++++--- templates/emails/staff_verify.body.html | 78 +++++++++++++++++++++++ templates/emails/staff_verify.body.txt | 23 +++++++ templates/emails/staff_verify.subject.txt | 1 + 5 files changed, 187 insertions(+), 10 deletions(-) create mode 100644 templates/emails/staff_verify.body.html create mode 100644 templates/emails/staff_verify.body.txt create mode 100644 templates/emails/staff_verify.subject.txt diff --git a/apps/submissions/emails.py b/apps/submissions/emails.py index 0e436c4..e1bdd98 100644 --- a/apps/submissions/emails.py +++ b/apps/submissions/emails.py @@ -1,6 +1,6 @@ """Outgoing email -- plan.md §7 (state-transition side effects). -Five public functions, one per dedicated template: +Six public functions, one per dedicated template: send_confirmation_email(submission) Guest path: token-link emailed immediately after `SubmitView` creates @@ -32,7 +32,15 @@ Five public functions, one per dedicated template: when present (typically pickup instructions). Template: `emails/completed.*`. -All five delegate to Django's email machinery. The backend is wired in + send_staff_verify_email(submission) + Operator broadcast fired on any transition into `status = verifying`. + One message to every active staff user with a non-empty email + (recipients placed in `bcc` so individual addresses stay hidden). + Surfaces the submitter's private `notes_for_op` -- not echoed on the + public detail page (plan.md §5) -- since the audience is staff only. + Template: `emails/staff_verify.*`. + +All six delegate to Django's email machinery. The backend is wired in `hamprint/settings/base.py`: Mailtrap via `django-anymail` when `MAILTRAP_API_TOKEN` is present, console when not. Send failures are caught + logged so a flaky transport never blocks the submission flow. @@ -175,3 +183,62 @@ def send_completed_email(sub: Submission) -> bool: pickup-instruction the operator typed in admin reaches the user.""" detail_url = f"{settings.SITE_URL}/p/{sub.slug}/" return _send("completed", sub, {"detail_url": detail_url}) + + +def send_staff_verify_email(sub: Submission) -> bool: + """Broadcast a "please verify" notice to every active staff user when + a submission lands in `verifying`. Fired by the centralised + `Submission.save()` hook so the worker's `processing -> verifying` + success branch AND any future admin path that returns a row to + `verifying` both trigger it (plan.md §7.3). + + Recipients are placed in `bcc` so individual staff addresses stay + hidden from each other; `to` carries `DEFAULT_FROM_EMAIL` purely to + satisfy transports that reject envelopes with no `to`. Returns False + (without raising) when there is no active staff with a usable email + or when the transport surfaces an exception. + """ + from django.contrib.auth import get_user_model + + UserModel = get_user_model() + recipients = list( + UserModel.objects + .filter(is_staff=True, is_active=True) + .exclude(email="") + .values_list("email", flat=True) + ) + if not recipients: + logger.info( + "no active staff with email; skipping staff_verify for %s", sub.slug + ) + return False + + admin_url = ( + f"{settings.SITE_URL}/admin/submissions/submission/{sub.pk}/change/" + ) + detail_url = f"{settings.SITE_URL}/p/{sub.slug}/" + context = { + "submission": sub, + "site_url": settings.SITE_URL, + "admin_url": admin_url, + "detail_url": detail_url, + } + subject, body_text, body_html = _render("staff_verify", context) + + msg = EmailMultiAlternatives( + subject=subject, + body=body_text, + from_email=settings.DEFAULT_FROM_EMAIL, + to=[settings.DEFAULT_FROM_EMAIL], + bcc=recipients, + ) + msg.attach_alternative(body_html, "text/html") + try: + msg.send() + except Exception: + logger.exception("failed to send staff_verify email for %s", sub.slug) + return False + logger.info( + "sent staff_verify email for %s to %d staff", sub.slug, len(recipients) + ) + return True diff --git a/apps/submissions/models.py b/apps/submissions/models.py index 5d318a3..bd84589 100644 --- a/apps/submissions/models.py +++ b/apps/submissions/models.py @@ -295,11 +295,14 @@ class Submission(models.Model): depend on the caller remembering to set it. Additionally: when an UPDATE flips `status` to a state with a - dedicated email (`rejected`, `printing`, `completed`), this method - queues the matching `send_*_email()` via `transaction.on_commit`. - Centralising the dispatch here means **every** save path -- admin, - the validation worker, ad-hoc shell, any future view -- fires the - email through a single hook. Plan.md §7.3. + dedicated email (`rejected`, `printing`, `completed`, `verifying`), + this method queues the matching `send_*_email()` via + `transaction.on_commit`. Centralising the dispatch here means + **every** save path -- admin, the validation worker, ad-hoc shell, + any future view -- fires the email through a single hook. The + `verifying` branch is a staff broadcast (the user-facing + `send_verifying_email` is still emitted explicitly by the worker + on its success branch). Plan.md §7.3. """ if not self.slug: self.slug = self._generate_unique_slug() @@ -323,9 +326,9 @@ class Submission(models.Model): # Fire on TRANSITIONS only: an UPDATE that flips status into one of # the email-bearing target states. Don't fire on inserts that start # out in those states -- by plan.md §7.3 no submit-time edge lands - # in rejected/printing/completed, and even if some weird path did, - # we'd rather stay silent than send "your print is ready" to a fresh - # victim of a fixture/data-migration import. + # in rejected/printing/completed/verifying, and even if some weird + # path did, we'd rather stay silent than send "your print is ready" + # to a fresh victim of a fixture/data-migration import. if not is_new and old_status != new_status: # Local imports keep this module out of the apps/submissions # import-cycle (emails.py imports from here). @@ -334,6 +337,7 @@ class Submission(models.Model): send_completed_email, send_printing_email, send_rejection_email, + send_staff_verify_email, ) if new_status == self.Status.REJECTED: @@ -350,6 +354,10 @@ class Submission(models.Model): transaction.on_commit( lambda sub=self: send_completed_email(sub) ) + elif new_status == self.Status.VERIFYING: + transaction.on_commit( + lambda sub=self: send_staff_verify_email(sub) + ) # Refresh the snapshot so a follow-up save on the same instance # compares against the just-persisted state, not the original load. diff --git a/templates/emails/staff_verify.body.html b/templates/emails/staff_verify.body.html new file mode 100644 index 0000000..e4bb0ad --- /dev/null +++ b/templates/emails/staff_verify.body.html @@ -0,0 +1,78 @@ +{% extends "emails/_base.html" %} +{% block body %} +

Awaiting verification

+ +

+ A new print has been submitted and has just passed the automated checks. It is waiting for a staff review before it can enter the print queue. +

+ +{# Pill colour comes from `Submission.STATUS_EMAIL_COLORS[VERIFYING]` -- violet, same as the dashboard chip and the user-facing verifying email. #} + + + + +
+ + {{ submission.get_status_display }} + +
+ + + + + +
+ + + + + + + + + + + + + + + + + +
Codename{{ submission.slug }}
Source{{ submission.source_label }}
Submitter + {% if submission.submitted_by_id %} + {{ submission.submitted_by.email }} (signed in) + {% else %} + {{ submission.guest_email }} (guest, email confirmed) + {% endif %} +
Filament{% if submission.requested_filament %}{{ submission.requested_filament.display_label }}{% else %}No preference{% endif %}
+
+ +{% if submission.notes_for_op %} +{# Private user-to-operator notes. Surfaced here because the recipient is staff -- never echoed on the public detail page (plan.md §5). #} + + + + +
+

+ Notes from the submitter +

+

{{ submission.notes_for_op }}

+
+{% endif %} + + + + + +
+ + Review in admin + +
+ +

+ Public detail page: {{ detail_url }} +

+{% endblock %} diff --git a/templates/emails/staff_verify.body.txt b/templates/emails/staff_verify.body.txt new file mode 100644 index 0000000..11e281a --- /dev/null +++ b/templates/emails/staff_verify.body.txt @@ -0,0 +1,23 @@ +Hi, + +A new print has been submitted and has just passed the automated checks. +It is now waiting for a staff verification before it can enter the print +queue. + + Codename : {{ submission.slug }} + Source : {{ submission.source_label }} + Submitter: {% if submission.submitted_by_id %}{{ submission.submitted_by.email }} (signed in){% else %}{{ submission.guest_email }} (guest, email confirmed){% endif %} + Filament : {% if submission.requested_filament %}{{ submission.requested_filament.display_label }}{% else %}No preference{% endif %} +{% if submission.notes_for_op %} +Notes the submitter left for you: + + {{ submission.notes_for_op }} +{% endif %} +Review the submission in admin: + {{ admin_url }} + +Public detail page: + {{ detail_url }} + +— hamprint +{{ site_url }} diff --git a/templates/emails/staff_verify.subject.txt b/templates/emails/staff_verify.subject.txt new file mode 100644 index 0000000..fd122b6 --- /dev/null +++ b/templates/emails/staff_verify.subject.txt @@ -0,0 +1 @@ +hamprint: {{ submission.slug }} awaiting verification -- 2.49.1