3 Commits

11 changed files with 366 additions and 23 deletions

View File

@@ -1,6 +1,6 @@
"""Outgoing email -- plan.md §7 (state-transition side effects). """Outgoing email -- plan.md §7 (state-transition side effects).
Three public functions, one per dedicated template: Six public functions, one per dedicated template:
send_confirmation_email(submission) send_confirmation_email(submission)
Guest path: token-link emailed immediately after `SubmitView` creates Guest path: token-link emailed immediately after `SubmitView` creates
@@ -20,7 +20,27 @@ Three public functions, one per dedicated template:
rejection reason in the email as on the public detail page. rejection reason in the email as on the public detail page.
Template: `emails/rejected.*`. Template: `emails/rejected.*`.
All three delegate to Django's email machinery. The backend is wired in send_printing_email(submission)
Fired on any transition into `status = printing` (operator clicks
"Start printing" in admin). Excited tone: "your print is on the
bed right now". Template: `emails/printing.*`.
send_completed_email(submission)
Fired on any transition into `status = completed` (operator clicks
"Mark completed"). Pickup-ready announcement; renders
`submission.operator_notes` as a "note from the operator" callout
when present (typically pickup instructions). Template:
`emails/completed.*`.
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 `hamprint/settings/base.py`: Mailtrap via `django-anymail` when
`MAILTRAP_API_TOKEN` is present, console when not. Send failures are caught `MAILTRAP_API_TOKEN` is present, console when not. Send failures are caught
+ logged so a flaky transport never blocks the submission flow. + logged so a flaky transport never blocks the submission flow.
@@ -146,3 +166,79 @@ def send_verifying_email(sub: Submission) -> bool:
will be the queued / printing one.""" will be the queued / printing one."""
detail_url = f"{settings.SITE_URL}/p/{sub.slug}/" detail_url = f"{settings.SITE_URL}/p/{sub.slug}/"
return _send("verifying", sub, {"detail_url": detail_url}) return _send("verifying", sub, {"detail_url": detail_url})
def send_printing_email(sub: Submission) -> bool:
"""Notify the submitter that the print has just started (plan.md §7.3
`queued -> printing` transition). Excited tone -- the operator just
clicked "Start printing" in admin and the first layer is going down."""
detail_url = f"{settings.SITE_URL}/p/{sub.slug}/"
return _send("printing", sub, {"detail_url": detail_url})
def send_completed_email(sub: Submission) -> bool:
"""Notify the submitter that the print finished successfully and is
ready for pickup (plan.md §7.3 `printing -> completed` transition).
`submission.operator_notes` is rendered when present so any
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

View File

@@ -294,11 +294,15 @@ class Submission(models.Model):
currently owns the row, so the per-email cap and trust list don't currently owns the row, so the per-email cap and trust list don't
depend on the caller remembering to set it. depend on the caller remembering to set it.
Additionally: when an UPDATE flips `status` to `rejected` from any Additionally: when an UPDATE flips `status` to a state with a
other state, this method queues `send_rejection_email()` via dedicated email (`rejected`, `printing`, `completed`, `verifying`),
`transaction.on_commit`. Centralising the email here means **every** this method queues the matching `send_*_email()` via
save path -- admin, the validation worker, ad-hoc shell, any future `transaction.on_commit`. Centralising the dispatch here means
view -- fires the email through a single hook. Plan.md §7.3. **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: if not self.slug:
self.slug = self._generate_unique_slug() self.slug = self._generate_unique_slug()
@@ -319,27 +323,42 @@ class Submission(models.Model):
super().save(*args, **kwargs) super().save(*args, **kwargs)
# Fire on TRANSITIONS only: an UPDATE that flips status to rejected. # Fire on TRANSITIONS only: an UPDATE that flips status into one of
# Don't fire on inserts that start out as rejected -- those should # the email-bearing target states. Don't fire on inserts that start
# be impossible by design (plan.md §7.3 doesn't define a (none) -> # out in those states -- by plan.md §7.3 no submit-time edge lands
# rejected edge), and even if some weird path creates one we'd # in rejected/printing/completed/verifying, and even if some weird
# rather stay silent than spam a fresh victim. # path did, we'd rather stay silent than send "your print is ready"
if ( # to a fresh victim of a fixture/data-migration import.
not is_new if not is_new and old_status != new_status:
and old_status != new_status
and new_status == self.Status.REJECTED
):
# Local imports keep this module out of the apps/submissions # Local imports keep this module out of the apps/submissions
# import-cycle (emails.py imports from here). # import-cycle (emails.py imports from here).
from django.db import transaction from django.db import transaction
from .emails import send_rejection_email from .emails import (
send_completed_email,
transaction.on_commit( send_printing_email,
lambda sub=self, prev=old_status: send_rejection_email( send_rejection_email,
sub, previous_status=prev send_staff_verify_email,
)
) )
if new_status == self.Status.REJECTED:
transaction.on_commit(
lambda sub=self, prev=old_status: send_rejection_email(
sub, previous_status=prev
)
)
elif new_status == self.Status.PRINTING:
transaction.on_commit(
lambda sub=self: send_printing_email(sub)
)
elif new_status == self.Status.COMPLETED:
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 # Refresh the snapshot so a follow-up save on the same instance
# compares against the just-persisted state, not the original load. # compares against the just-persisted state, not the original load.
self._original_status = new_status self._original_status = new_status

View File

@@ -0,0 +1,51 @@
{% extends "emails/_base.html" %}
{% block body %}
<h1 style="margin:0 0 12px 0; font-size:22px; font-weight:700; color:#065f46; letter-spacing:-0.01em;">Your print is ready</h1>
<p style="margin:0 0 16px 0; color:#334155; font-size:15px; line-height:1.55;">
Done! <strong style="font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; color:#92400e; font-weight:700;">{{ submission.slug }}</strong> came off the printer successfully and is waiting for you at hamlab.lt.
</p>
{# Pill colour comes from `Submission.STATUS_EMAIL_COLORS[COMPLETED]` -- the emerald palette used everywhere else for the "success" terminal state. #}
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 20px 0;">
<tr>
<td style="padding:6px 14px; background-color:{{ submission.status_email_style.bg }}; border-radius:9999px;">
<span style="color:{{ submission.status_email_style.fg }}; font-weight:600; font-size:14px; letter-spacing:0.01em;">
{{ submission.get_status_display }}
</span>
</td>
</tr>
</table>
{% if submission.operator_notes %}
{# Pickup-instructions callout -- only rendered when the operator left a note (e.g. "in the green bin by the lasers"). #}
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:0 0 24px 0;">
<tr>
<td style="padding:16px; background-color:#ecfdf5; border-left:3px solid #10b981; border-radius:0 4px 4px 0;">
<p style="margin:0 0 6px 0; font-size:11px; font-weight:600; color:#065f46; text-transform:uppercase; letter-spacing:0.06em;">
Note from the operator
</p>
<p style="margin:0; color:#334155; font-size:14px; line-height:1.55; white-space:pre-line;">{{ submission.operator_notes }}</p>
</td>
</tr>
</table>
{% endif %}
<p style="margin:0 0 24px 0; color:#334155; font-size:15px; line-height:1.55;">
Come grab it whenever the lab is open. Thanks for printing with us!
</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="background-color:#10b981; border-radius:6px;">
<a href="{{ detail_url }}" style="display:inline-block; padding:12px 24px; color:#ffffff; font-weight:600; font-size:15px; text-decoration:none; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
View pickup details
</a>
</td>
</tr>
</table>
<p style="margin:18px 0 0 0; font-size:13px; color:#64748b;">
Direct link: <span style="color:#475569; word-break:break-all; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;">{{ detail_url }}</span>
</p>
{% endblock %}

View File

@@ -0,0 +1,19 @@
Hi,
Your hamprint is done! "{{ submission.slug }}" came off the printer
successfully and is waiting for you at hamlab.lt.
Codename : {{ submission.slug }}
Status : Completed
{% if submission.operator_notes %}
A note from the operator:
{{ submission.operator_notes }}
{% endif %}
Come grab it whenever the lab is open. Thanks for printing with us!
Pickup details and a photo (if the operator left one) are here:
{{ detail_url }}
— hamprint
{{ site_url }}

View File

@@ -0,0 +1 @@
hamprint: {{ submission.slug }} is ready for pickup

View File

@@ -0,0 +1,37 @@
{% extends "emails/_base.html" %}
{% block body %}
<h1 style="margin:0 0 12px 0; font-size:22px; font-weight:700; color:#9a3412; letter-spacing:-0.01em;">Your print is starting</h1>
<p style="margin:0 0 16px 0; color:#334155; font-size:15px; line-height:1.55;">
Great news &mdash; <strong style="font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; color:#92400e; font-weight:700;">{{ submission.slug }}</strong> is on the printer right now. The operator has started the job and the first layer is going down.
</p>
{# Pill colour comes from `Submission.STATUS_EMAIL_COLORS[PRINTING]` -- warm orange to mirror the live-printing chip on the dashboard. #}
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 20px 0;">
<tr>
<td style="padding:6px 14px; background-color:{{ submission.status_email_style.bg }}; border-radius:9999px;">
<span style="color:{{ submission.status_email_style.fg }}; font-weight:600; font-size:14px; letter-spacing:0.01em;">
{{ submission.get_status_display }}
</span>
</td>
</tr>
</table>
<p style="margin:0 0 24px 0; color:#334155; font-size:15px; line-height:1.55;">
We'll email you again the moment it finishes so you know when to come pick it up. Nothing for you to do right now &mdash; feel free to track progress at the link below.
</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="background-color:#f59e0b; border-radius:6px;">
<a href="{{ detail_url }}" style="display:inline-block; padding:12px 24px; color:#ffffff; font-weight:600; font-size:15px; text-decoration:none; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
Follow the print
</a>
</td>
</tr>
</table>
<p style="margin:18px 0 0 0; font-size:13px; color:#64748b;">
Direct link: <span style="color:#475569; word-break:break-all; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;">{{ detail_url }}</span>
</p>
{% endblock %}

View File

@@ -0,0 +1,17 @@
Hi,
Great news -- your hamprint submission "{{ submission.slug }}" is on the
printer right now. The operator has started the job and the first layer
is going down.
Codename : {{ submission.slug }}
Status : Printing
We'll send one more email when it finishes, so you know when to come
pick it up. No action needed in the meantime.
You can also follow along here:
{{ detail_url }}
— hamprint
{{ site_url }}

View File

@@ -0,0 +1 @@
hamprint: {{ submission.slug }} is on the printer

View File

@@ -0,0 +1,78 @@
{% extends "emails/_base.html" %}
{% block body %}
<h1 style="margin:0 0 12px 0; font-size:22px; font-weight:700; color:#5b21b6; letter-spacing:-0.01em;">Awaiting verification</h1>
<p style="margin:0 0 16px 0; color:#334155; font-size:15px; line-height:1.55;">
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.
</p>
{# Pill colour comes from `Submission.STATUS_EMAIL_COLORS[VERIFYING]` -- violet, same as the dashboard chip and the user-facing verifying email. #}
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 20px 0;">
<tr>
<td style="padding:6px 14px; background-color:{{ submission.status_email_style.bg }}; border-radius:9999px;">
<span style="color:{{ submission.status_email_style.fg }}; font-weight:600; font-size:14px; letter-spacing:0.01em;">
{{ submission.get_status_display }}
</span>
</td>
</tr>
</table>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:0 0 20px 0; border:1px solid #e2e8f0; border-radius:6px;">
<tr>
<td style="padding:14px 16px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="font-size:14px; color:#334155;">
<tr>
<td style="padding:4px 0; color:#64748b; width:110px;">Codename</td>
<td style="padding:4px 0; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; color:#92400e; font-weight:600;">{{ submission.slug }}</td>
</tr>
<tr>
<td style="padding:4px 0; color:#64748b;">Source</td>
<td style="padding:4px 0;">{{ submission.source_label }}</td>
</tr>
<tr>
<td style="padding:4px 0; color:#64748b;">Submitter</td>
<td style="padding:4px 0;">
{% if submission.submitted_by_id %}
{{ submission.submitted_by.email }} <span style="color:#64748b;">(signed in)</span>
{% else %}
{{ submission.guest_email }} <span style="color:#64748b;">(guest, email confirmed)</span>
{% endif %}
</td>
</tr>
<tr>
<td style="padding:4px 0; color:#64748b;">Filament</td>
<td style="padding:4px 0;">{% if submission.requested_filament %}{{ submission.requested_filament.display_label }}{% else %}<span style="color:#64748b;">No preference</span>{% endif %}</td>
</tr>
</table>
</td>
</tr>
</table>
{% 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). #}
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:0 0 24px 0;">
<tr>
<td style="padding:16px; background-color:#f5f3ff; border-left:3px solid #8b5cf6; border-radius:0 4px 4px 0;">
<p style="margin:0 0 6px 0; font-size:11px; font-weight:600; color:#5b21b6; text-transform:uppercase; letter-spacing:0.06em;">
Notes from the submitter
</p>
<p style="margin:0; color:#334155; font-size:14px; line-height:1.55; white-space:pre-line;">{{ submission.notes_for_op }}</p>
</td>
</tr>
</table>
{% endif %}
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="background-color:#7c3aed; border-radius:6px;">
<a href="{{ admin_url }}" style="display:inline-block; padding:12px 24px; color:#ffffff; font-weight:600; font-size:15px; text-decoration:none; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
Review in admin
</a>
</td>
</tr>
</table>
<p style="margin:18px 0 0 0; font-size:13px; color:#64748b;">
Public detail page: <a href="{{ detail_url }}" style="color:#475569; word-break:break-all; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;">{{ detail_url }}</a>
</p>
{% endblock %}

View File

@@ -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 }}

View File

@@ -0,0 +1 @@
hamprint: {{ submission.slug }} awaiting verification