Compare commits
1 Commits
stl_downlo
...
staff_veri
| Author | SHA1 | Date | |
|---|---|---|---|
| f3da494f73 |
@@ -1,6 +1,6 @@
|
|||||||
"""Outgoing email -- plan.md §7 (state-transition side effects).
|
"""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)
|
send_confirmation_email(submission)
|
||||||
Guest path: token-link emailed immediately after `SubmitView` creates
|
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:
|
when present (typically pickup instructions). Template:
|
||||||
`emails/completed.*`.
|
`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
|
`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.
|
||||||
@@ -175,3 +183,62 @@ def send_completed_email(sub: Submission) -> bool:
|
|||||||
pickup-instruction the operator typed in admin reaches the user."""
|
pickup-instruction the operator typed in admin reaches the user."""
|
||||||
detail_url = f"{settings.SITE_URL}/p/{sub.slug}/"
|
detail_url = f"{settings.SITE_URL}/p/{sub.slug}/"
|
||||||
return _send("completed", sub, {"detail_url": detail_url})
|
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
|
||||||
|
|||||||
@@ -295,11 +295,14 @@ class Submission(models.Model):
|
|||||||
depend on the caller remembering to set it.
|
depend on the caller remembering to set it.
|
||||||
|
|
||||||
Additionally: when an UPDATE flips `status` to a state with a
|
Additionally: when an UPDATE flips `status` to a state with a
|
||||||
dedicated email (`rejected`, `printing`, `completed`), this method
|
dedicated email (`rejected`, `printing`, `completed`, `verifying`),
|
||||||
queues the matching `send_*_email()` via `transaction.on_commit`.
|
this method queues the matching `send_*_email()` via
|
||||||
Centralising the dispatch here means **every** save path -- admin,
|
`transaction.on_commit`. Centralising the dispatch here means
|
||||||
the validation worker, ad-hoc shell, any future view -- fires the
|
**every** save path -- admin, the validation worker, ad-hoc shell,
|
||||||
email through a single hook. Plan.md §7.3.
|
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()
|
||||||
@@ -323,9 +326,9 @@ class Submission(models.Model):
|
|||||||
# Fire on TRANSITIONS only: an UPDATE that flips status into one of
|
# Fire on TRANSITIONS only: an UPDATE that flips status into one of
|
||||||
# the email-bearing target states. Don't fire on inserts that start
|
# 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
|
# 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,
|
# in rejected/printing/completed/verifying, and even if some weird
|
||||||
# we'd rather stay silent than send "your print is ready" to a fresh
|
# path did, we'd rather stay silent than send "your print is ready"
|
||||||
# victim of a fixture/data-migration import.
|
# to a fresh victim of a fixture/data-migration import.
|
||||||
if not is_new and old_status != new_status:
|
if not is_new and old_status != new_status:
|
||||||
# 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).
|
||||||
@@ -334,6 +337,7 @@ class Submission(models.Model):
|
|||||||
send_completed_email,
|
send_completed_email,
|
||||||
send_printing_email,
|
send_printing_email,
|
||||||
send_rejection_email,
|
send_rejection_email,
|
||||||
|
send_staff_verify_email,
|
||||||
)
|
)
|
||||||
|
|
||||||
if new_status == self.Status.REJECTED:
|
if new_status == self.Status.REJECTED:
|
||||||
@@ -350,6 +354,10 @@ class Submission(models.Model):
|
|||||||
transaction.on_commit(
|
transaction.on_commit(
|
||||||
lambda sub=self: send_completed_email(sub)
|
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.
|
||||||
|
|||||||
@@ -18,11 +18,9 @@ import secrets
|
|||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.db import IntegrityError, transaction
|
from django.db import IntegrityError, transaction
|
||||||
from django.http import FileResponse, Http404
|
|
||||||
from django.urls import reverse, reverse_lazy
|
from django.urls import reverse, reverse_lazy
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from django.views.decorators.http import require_safe
|
|
||||||
from django.views.generic import CreateView
|
from django.views.generic import CreateView
|
||||||
|
|
||||||
from .emails import send_confirmation_email
|
from .emails import send_confirmation_email
|
||||||
@@ -30,48 +28,6 @@ from .forms import SubmissionForm
|
|||||||
from .models import Submission, VerifiedEmail
|
from .models import Submission, VerifiedEmail
|
||||||
|
|
||||||
|
|
||||||
@require_safe
|
|
||||||
def serve_stl(request, path: str):
|
|
||||||
"""Auth-checked passthrough to a Submission's uploaded `.stl`. URL is
|
|
||||||
`/media/<path>` so it matches `MEDIA_URL` -- which means
|
|
||||||
`{{ submission.stl_file.url }}` in templates Just Works.
|
|
||||||
|
|
||||||
`path` is the relative name Django stored (e.g. `stl/foo.stl`); we
|
|
||||||
look the row up by that string rather than reading the filesystem
|
|
||||||
directly so an attacker can't escape `MEDIA_ROOT` with `..` segments.
|
|
||||||
|
|
||||||
Access tiers match `SubmissionDetailView` (plan.md §7.4 step 7):
|
|
||||||
|
|
||||||
- staff: yes (operators need the file to drive the printer).
|
|
||||||
- authenticated owner (`submitted_by == request.user`): yes.
|
|
||||||
- everyone else: 404 -- we deliberately don't 403 so the existence
|
|
||||||
of a row isn't leaked to non-owners by status code.
|
|
||||||
|
|
||||||
Guests can't authenticate by design, so they can't pull their own STL
|
|
||||||
via this path. That's consistent with the detail page's two-tier
|
|
||||||
access; the submitter has the file on their own machine in any case.
|
|
||||||
"""
|
|
||||||
sub = Submission.objects.filter(stl_file=path).first()
|
|
||||||
if sub is None:
|
|
||||||
raise Http404
|
|
||||||
user = request.user
|
|
||||||
is_owner = (
|
|
||||||
user.is_authenticated
|
|
||||||
and sub.submitted_by_id is not None
|
|
||||||
and sub.submitted_by_id == user.id
|
|
||||||
)
|
|
||||||
if not (user.is_staff or is_owner):
|
|
||||||
raise Http404
|
|
||||||
# `FieldFile.open("rb")` is rooted in MEDIA_ROOT; FileResponse handles
|
|
||||||
# streaming, range requests, and closing the handle for us.
|
|
||||||
filename = sub.stl_file.name.rsplit("/", 1)[-1]
|
|
||||||
return FileResponse(
|
|
||||||
sub.stl_file.open("rb"),
|
|
||||||
as_attachment=True,
|
|
||||||
filename=filename,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SubmitView(CreateView):
|
class SubmitView(CreateView):
|
||||||
"""Public submit form. GET renders, POST creates a Submission."""
|
"""Public submit form. GET renders, POST creates a Submission."""
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ STATIC_URL = "static/"
|
|||||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||||
STATICFILES_DIRS = [BASE_DIR / "static"] if (BASE_DIR / "static").exists() else []
|
STATICFILES_DIRS = [BASE_DIR / "static"] if (BASE_DIR / "static").exists() else []
|
||||||
|
|
||||||
MEDIA_URL = "/media/"
|
MEDIA_URL = "media/"
|
||||||
MEDIA_ROOT = BASE_DIR / "media"
|
MEDIA_ROOT = BASE_DIR / "media"
|
||||||
|
|
||||||
STORAGES = {
|
STORAGES = {
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ Including another URLconf
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
|
||||||
from apps.submissions.views import serve_stl
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
# Our local /accounts/ routes (close-account, etc.) come BEFORE allauth's
|
# Our local /accounts/ routes (close-account, etc.) come BEFORE allauth's
|
||||||
@@ -27,10 +25,6 @@ urlpatterns = [
|
|||||||
# through to allauth.
|
# through to allauth.
|
||||||
path("accounts/", include("apps.accounts.urls")),
|
path("accounts/", include("apps.accounts.urls")),
|
||||||
path("accounts/", include("allauth.urls")),
|
path("accounts/", include("allauth.urls")),
|
||||||
# Auth-checked media serve. Lives here (not in apps.submissions.urls)
|
|
||||||
# because the prefix is MEDIA_URL, not /submit/. WhiteNoise serves
|
|
||||||
# /static/ only, so this is the sole handler for /media/.
|
|
||||||
path("media/<path:path>", serve_stl, name="serve_stl"),
|
|
||||||
path("", include("apps.dashboard.urls")),
|
path("", include("apps.dashboard.urls")),
|
||||||
path("submit/", include("apps.submissions.urls")),
|
path("submit/", include("apps.submissions.urls")),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -136,24 +136,13 @@
|
|||||||
<div class="bg-white border border-slate-200 rounded-lg p-5">
|
<div class="bg-white border border-slate-200 rounded-lg p-5">
|
||||||
<h3 class="font-semibold text-sm uppercase tracking-wide text-slate-500 mb-3">Source</h3>
|
<h3 class="font-semibold text-sm uppercase tracking-wide text-slate-500 mb-3">Source</h3>
|
||||||
{% if submission.source_type == 'upload' %}
|
{% if submission.source_type == 'upload' %}
|
||||||
{% if submission.stl_file %}
|
<div class="flex items-center gap-3 p-3 rounded-md bg-slate-50 border border-slate-200">
|
||||||
<a href="{{ submission.stl_file.url }}" class="flex items-center gap-3 p-3 rounded-md bg-slate-50 border border-slate-200 hover:border-amber-400 hover:bg-amber-50/40">
|
<svg class="w-8 h-8 text-slate-400 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg>
|
||||||
<svg class="w-8 h-8 text-slate-400 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg>
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex-1 min-w-0">
|
<p class="font-medium text-sm break-all">{% if submission.stl_file %}{{ submission.stl_file.name }}{% else %}(file not on disk){% endif %}</p>
|
||||||
<p class="font-medium text-sm break-all">{{ submission.stl_file.name }}</p>
|
<p class="text-xs text-slate-500">Raw <span class="mono">.stl</span> upload</p>
|
||||||
<p class="text-xs text-slate-500">Raw <span class="mono">.stl</span> upload · click to download</p>
|
|
||||||
</div>
|
|
||||||
<svg class="w-4 h-4 text-slate-400 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg>
|
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
<div class="flex items-center gap-3 p-3 rounded-md bg-slate-50 border border-slate-200">
|
|
||||||
<svg class="w-8 h-8 text-slate-400 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<p class="font-medium text-sm break-all">(file not on disk)</p>
|
|
||||||
<p class="text-xs text-slate-500">Raw <span class="mono">.stl</span> upload</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ submission.source_url }}" target="_blank" rel="noopener" class="flex items-center gap-3 p-3 rounded-md bg-slate-50 border border-slate-200 hover:border-amber-400 hover:bg-amber-50/40">
|
<a href="{{ submission.source_url }}" target="_blank" rel="noopener" class="flex items-center gap-3 p-3 rounded-md bg-slate-50 border border-slate-200 hover:border-amber-400 hover:bg-amber-50/40">
|
||||||
<span class="w-10 h-10 rounded-md bg-amber-100 grid place-items-center text-amber-700 font-bold text-xs flex-shrink-0">{{ submission.source_type|slice:":1"|upper }}</span>
|
<span class="w-10 h-10 rounded-md bg-amber-100 grid place-items-center text-amber-700 font-bold text-xs flex-shrink-0">{{ submission.source_type|slice:":1"|upper }}</span>
|
||||||
|
|||||||
78
templates/emails/staff_verify.body.html
Normal file
78
templates/emails/staff_verify.body.html
Normal 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 %}
|
||||||
23
templates/emails/staff_verify.body.txt
Normal file
23
templates/emails/staff_verify.body.txt
Normal 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 }}
|
||||||
1
templates/emails/staff_verify.subject.txt
Normal file
1
templates/emails/staff_verify.subject.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
hamprint: {{ submission.slug }} awaiting verification
|
||||||
Reference in New Issue
Block a user