Add proper email notifications
This commit is contained in:
50
.dockerignore
Normal file
50
.dockerignore
Normal file
@@ -0,0 +1,50 @@
|
||||
# Keep the image lean and reproducible: don't ship anything we'd regenerate
|
||||
# inside the container anyway, and never ship host-only / secret state.
|
||||
|
||||
# Host venv -- container has its own Python install.
|
||||
.venv/
|
||||
|
||||
# VCS metadata.
|
||||
.git/
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Local databases / dev artefacts.
|
||||
*.sqlite3
|
||||
db.sqlite3*
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.swp
|
||||
*.log
|
||||
*.tmp
|
||||
|
||||
# Secrets / env files: container reads .env via --env-file or compose env_file:.
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Tailwind binary cache: re-downloaded by `manage.py tailwind build` during
|
||||
# image build, then removed in the same layer. Host copy might be a different
|
||||
# arch / version, so always skip it.
|
||||
.django_tailwind_cli/
|
||||
|
||||
# Output of `collectstatic` -- the Containerfile runs this fresh at build time.
|
||||
staticfiles/
|
||||
|
||||
# Documentation, design assets, and prototype HTML that operators don't need
|
||||
# at runtime.
|
||||
demo/
|
||||
plan.md
|
||||
plan.pdf
|
||||
plan.html
|
||||
CLAUDE.md
|
||||
CONTRIBUTING.md
|
||||
README.md
|
||||
.claude/
|
||||
|
||||
# IDE / cache directories.
|
||||
.vscode/
|
||||
.idea/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
node_modules/
|
||||
24
.env.example
24
.env.example
@@ -9,6 +9,11 @@ SECRET_KEY=replace-me-with-a-long-random-string
|
||||
DEBUG=true
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
|
||||
# Public base URL used in outgoing emails (confirmation link, detail-page
|
||||
# link). Defaults to http://localhost:8000 if unset. In production set to
|
||||
# e.g. https://print.hamlab.lt -- no trailing slash.
|
||||
SITE_URL=http://localhost:8000
|
||||
|
||||
# --- PostgreSQL ---
|
||||
# Used by the `db` container and interpolated into DATABASE_URL inside
|
||||
# compose.yaml; the `web` container reads DATABASE_URL via dj-database-url.
|
||||
@@ -16,15 +21,20 @@ POSTGRES_DB=hamprint
|
||||
POSTGRES_USER=hamprint
|
||||
POSTGRES_PASSWORD=changeme
|
||||
|
||||
# --- Mailjet (transactional email) ---
|
||||
# Only used when running with `hamprint.settings.prod`; `hamprint.settings.dev`
|
||||
# overrides EMAIL_BACKEND to the console backend so emails are printed to
|
||||
# the `web` container logs.
|
||||
# Get keys at https://app.mailjet.com/account/apikeys
|
||||
MAILJET_API_KEY=
|
||||
MAILJET_API_SECRET=
|
||||
# --- Mailtrap (transactional email) ---
|
||||
# When MAILTRAP_API_TOKEN is set, Django sends through Mailtrap's HTTPS API
|
||||
# via django-anymail. When blank, settings/base.py falls back to the console
|
||||
# email backend -- handy for local development without burning real quota or
|
||||
# spamming real addresses.
|
||||
# Get a token at https://mailtrap.io/api-tokens
|
||||
MAILTRAP_API_TOKEN=
|
||||
DEFAULT_FROM_EMAIL=hamprint <noreply@hamlab.lt>
|
||||
|
||||
# Optional: set to a Mailtrap testing-inbox ID to capture outgoing mail in a
|
||||
# sandbox inbox instead of actually delivering it. Find the ID in the URL
|
||||
# of the inbox at https://mailtrap.io/inboxes. Leave blank for real sending.
|
||||
MAILTRAP_TEST_INBOX_ID=
|
||||
|
||||
# --- Google OAuth (django-allauth Google provider) ---
|
||||
# Configure at https://console.cloud.google.com/apis/credentials and add a
|
||||
# SocialApp via Django admin (/admin/socialaccount/socialapp/add/).
|
||||
|
||||
62
Containerfile
Normal file
62
Containerfile
Normal file
@@ -0,0 +1,62 @@
|
||||
# hamprint -- single-container image (plan.md §10).
|
||||
#
|
||||
# Runs Gunicorn plus the two periodic jobs (process_submissions every 30 s,
|
||||
# cleanup_stale every 5 min) in the same process group. No sidecars.
|
||||
#
|
||||
# Build: podman build -t hamprint:latest .
|
||||
# Run: podman run --rm -p 8000:8000 --env-file .env hamprint:latest
|
||||
#
|
||||
# In production the host typically mounts a volume at /app/media so uploaded
|
||||
# STLs survive container restarts; the database connection comes from
|
||||
# DATABASE_URL (Postgres in prod, SQLite if you really want).
|
||||
|
||||
FROM docker.io/library/python:3.14-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
DJANGO_SETTINGS_MODULE=hamprint.settings.prod
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# System packages python:3.14-slim doesn't ship:
|
||||
# tini -- PID 1 for clean signal forwarding to gunicorn + the loops
|
||||
# libgomp1 -- numpy/numpy-stl runtime on some kernels
|
||||
# curl -- handy for healthchecks if compose.yaml grows one
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends tini curl libgomp1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Python deps as their own layer so app-code edits don't invalidate the wheel
|
||||
# cache. psycopg[binary] is added explicitly because requirements.txt is
|
||||
# kept Postgres-driver-agnostic for the host-venv (SQLite) path.
|
||||
COPY requirements.txt .
|
||||
RUN pip install -r requirements.txt 'psycopg[binary]'
|
||||
|
||||
# Application code (.dockerignore excludes .venv, .git, db.sqlite3, demo/, etc).
|
||||
COPY . .
|
||||
|
||||
# Build Tailwind CSS, gather everything under STATIC_ROOT for WhiteNoise, then
|
||||
# drop the ~120 MB Tailwind CLI download so it doesn't ride along. The source
|
||||
# .css lives at assets/tailwind.source.css (per TAILWIND_CLI_SRC_CSS in
|
||||
# settings/base.py); only the CLI binary cache is purged.
|
||||
RUN python manage.py tailwind build --force \
|
||||
&& python manage.py collectstatic --noinput \
|
||||
&& rm -rf /app/.django_tailwind_cli
|
||||
|
||||
# Default writable dirs. Mount a volume at /app/media in prod for persistence.
|
||||
RUN mkdir -p /app/media /app/staticfiles
|
||||
|
||||
# Drop privileges. uid 1000 maps cleanly to the typical host user in rootless
|
||||
# podman, so a bind-mounted media volume stays writable without extra fuss.
|
||||
RUN useradd -m -u 1000 app \
|
||||
&& chown -R app:app /app
|
||||
USER app
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# tini reaps zombies + forwards SIGTERM to all children, so when the orchestrator
|
||||
# stops the container both Gunicorn AND the two background loops get the signal.
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
CMD ["bash", "entrypoint.sh"]
|
||||
14
apps/accounts/urls.py
Normal file
14
apps/accounts/urls.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Local additions under `/accounts/`. Mounted in `hamprint/urls.py` BEFORE
|
||||
`include("allauth.urls")` so any path defined here wins; everything we
|
||||
don't claim falls through to allauth.
|
||||
"""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = "accounts"
|
||||
|
||||
urlpatterns = [
|
||||
path("close/", views.close_account, name="close"),
|
||||
]
|
||||
@@ -1,3 +1,68 @@
|
||||
from django.shortcuts import render
|
||||
"""Local account views -- the ones allauth doesn't ship.
|
||||
|
||||
# Create your views here.
|
||||
Today: just `close_account` ("permanently delete this user"). Logout is
|
||||
allauth's; we override its template, not its view.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import logout
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db import transaction
|
||||
from django.http import HttpResponseForbidden
|
||||
from django.shortcuts import redirect, render
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.submissions.models import Submission
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def close_account(request):
|
||||
"""Permanently delete the signed-in user and all of their submissions.
|
||||
|
||||
GET -> render a "are you sure?" confirmation page, showing how many of
|
||||
their submissions will go with the user.
|
||||
POST -> delete the rows, log the user out, redirect to the dashboard.
|
||||
|
||||
Refuses staff users (`is_staff=True`): they'd be locking themselves
|
||||
(and possibly the only operator) out of /admin/ with no recourse, so
|
||||
that path requires another operator to remove the row via
|
||||
/admin/auth/user/ instead.
|
||||
|
||||
Why delete the submissions too: `Submission.submitted_by` uses
|
||||
`on_delete=SET_NULL`, but the `CheckConstraint` on the model requires
|
||||
that EITHER `submitted_by` OR `guest_email` is non-null. OAuth-created
|
||||
submissions have `guest_email=NULL`, so SET_NULL would violate the
|
||||
constraint at delete time. Simpler + matches user expectation of
|
||||
"delete my account": wipe the rows wholesale. The `post_delete` signal
|
||||
in `apps/submissions/signals.py` unlinks the uploaded STLs at the
|
||||
same time.
|
||||
"""
|
||||
if request.user.is_staff:
|
||||
return HttpResponseForbidden(
|
||||
"Staff users cannot close their own account from here. "
|
||||
"Ask another operator to remove the row via /admin/auth/user/."
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
user = request.user
|
||||
username = user.get_username()
|
||||
with transaction.atomic():
|
||||
Submission.objects.filter(submitted_by=user).delete()
|
||||
logout(request)
|
||||
user.delete()
|
||||
messages.info(
|
||||
request,
|
||||
f"Account {username} and all of your prints have been "
|
||||
f"permanently deleted.",
|
||||
)
|
||||
return redirect("dashboard:index")
|
||||
|
||||
submission_count = Submission.objects.filter(submitted_by=request.user).count()
|
||||
return render(
|
||||
request,
|
||||
"account/close.html",
|
||||
{"submission_count": submission_count},
|
||||
)
|
||||
|
||||
@@ -7,9 +7,9 @@ app_name = "dashboard"
|
||||
urlpatterns = [
|
||||
path("", views.IndexView.as_view(), name="index"),
|
||||
path("my-prints/", views.MyPrintsView.as_view(), name="my_prints"),
|
||||
path("p/<slug:slug>/confirm/<str:token>/", views.ConfirmEmailView.as_view(), name="confirm"),
|
||||
# Routes to be added as features land (see plan.md Section 7):
|
||||
# path("p/<slug:slug>/", views.SubmissionDetailView.as_view(), name="detail"),
|
||||
# path("p/<slug:slug>/status/", views.SubmissionStatusFragment.as_view(), name="status"),
|
||||
# path("p/<slug:slug>/confirm/<str:token>/", views.ConfirmEmailView.as_view(), name="confirm"),
|
||||
# path("p/<slug:slug>/resend/", views.ResendConfirmationView.as_view(), name="resend"),
|
||||
]
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import secrets
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.db.models import Count, Q
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.utils.html import format_html
|
||||
from django.views import View
|
||||
from django.views.generic import ListView
|
||||
|
||||
from apps.submissions.models import Submission
|
||||
@@ -54,6 +60,76 @@ class IndexView(ListView):
|
||||
return ctx
|
||||
|
||||
|
||||
class ConfirmEmailView(View):
|
||||
"""Email-confirmation landing page (plan.md §7.4 step 8).
|
||||
|
||||
The URL `/p/<slug>/confirm/<token>/` is what the welcome email's button
|
||||
points at. Hitting it does plan.md §7.3's `identifying -> processing`
|
||||
transition (`email_confirmed = True`, `confirmation_token` cleared),
|
||||
then bounces the user back to the dashboard with a green notice. The
|
||||
`processing` worker (plan.md §7.5) picks the row up within ~30 s and
|
||||
moves it to `verifying`, where it becomes visible on the public list.
|
||||
|
||||
Idempotent: hitting the link twice (or after expiry / approval / rejection)
|
||||
doesn't crash -- it just surfaces a neutral "already past the
|
||||
confirmation step" notice and redirects. Constant-time string compare
|
||||
on the token, so a stranger guessing tokens can't sniff prefix matches
|
||||
via response-time differences.
|
||||
"""
|
||||
|
||||
def get(self, request, slug, token):
|
||||
# 404 if the row doesn't exist -- including the case where
|
||||
# cleanup_stale has already nuked an unconfirmed submission.
|
||||
sub = get_object_or_404(Submission, slug=slug)
|
||||
|
||||
if sub.status != Submission.Status.IDENTIFYING:
|
||||
# Already past the confirmation step -- treat as a no-op rather
|
||||
# than an error so refreshing / re-clicking is harmless.
|
||||
messages.info(
|
||||
request,
|
||||
format_html(
|
||||
"Submission <strong class=\"font-mono\">{slug}</strong> is "
|
||||
"already past the confirmation step (current state: "
|
||||
"<em>{status}</em>). Nothing more to do.",
|
||||
slug=sub.slug,
|
||||
status=sub.get_status_display(),
|
||||
),
|
||||
)
|
||||
return redirect("dashboard:index")
|
||||
|
||||
# Constant-time compare on the token to make timing-side-channel
|
||||
# attacks against the 32-byte secret impractical.
|
||||
stored = sub.confirmation_token or ""
|
||||
if not stored or not secrets.compare_digest(stored, token):
|
||||
messages.error(
|
||||
request,
|
||||
format_html(
|
||||
"Couldn't confirm submission <strong class=\"font-mono\">{slug}</strong> — "
|
||||
"the link is invalid or has expired. If you submitted "
|
||||
"this print, please <a href=\"{submit_url}\" class=\"underline font-medium\">"
|
||||
"submit again</a>.",
|
||||
slug=sub.slug,
|
||||
submit_url="/submit/",
|
||||
),
|
||||
)
|
||||
return redirect("dashboard:index")
|
||||
|
||||
sub.status = Submission.Status.PROCESSING
|
||||
sub.email_confirmed = True
|
||||
sub.confirmation_token = ""
|
||||
sub.save()
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
format_html(
|
||||
"Submission <strong class=\"font-mono\">{slug}</strong> confirmed! "
|
||||
"It'll appear on the dashboard shortly, after validation.",
|
||||
slug=sub.slug,
|
||||
),
|
||||
)
|
||||
return redirect("dashboard:index")
|
||||
|
||||
|
||||
class MyPrintsView(LoginRequiredMixin, ListView):
|
||||
"""Private listing -- every submission the signed-in user has ever made.
|
||||
|
||||
|
||||
129
apps/submissions/emails.py
Normal file
129
apps/submissions/emails.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Outgoing email -- plan.md §7 (state-transition side effects).
|
||||
|
||||
Two public functions:
|
||||
|
||||
send_confirmation_email(submission)
|
||||
Guest path: token-link emailed immediately after `SubmitView` creates
|
||||
an `identifying` row. The user must click within 24 h (plan.md §7.6)
|
||||
or the row is cleaned up.
|
||||
|
||||
send_status_update_email(submission, *, previous_status=None)
|
||||
Generic notifier for any state transition the user should know about
|
||||
(queued, rejected, completed, failed). Callers pick when to fire it
|
||||
-- typically operator admin actions and the validation worker.
|
||||
|
||||
Both 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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
from .models import Submission
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _recipient(sub: Submission) -> str | None:
|
||||
"""Resolve the email address for a submission.
|
||||
|
||||
OAuth users -> `User.email`; guests -> `guest_email`. Returns None if
|
||||
neither is set, which shouldn't happen thanks to the model's
|
||||
`CheckConstraint` but we keep the belt-and-braces check anyway.
|
||||
"""
|
||||
if sub.submitted_by_id and sub.submitted_by.email:
|
||||
return sub.submitted_by.email
|
||||
return sub.guest_email or None
|
||||
|
||||
|
||||
def _render(name: str, context: dict) -> tuple[str, str, str]:
|
||||
"""Load `templates/emails/{name}.subject.txt` + `.body.txt` + `.body.html`
|
||||
and render each with the same context.
|
||||
|
||||
The subject is stripped of trailing whitespace so a multi-line subject
|
||||
template (which can happen when an author hits Enter at the end) doesn't
|
||||
accidentally include a newline -- which would make the transport API
|
||||
reject the message as malformed.
|
||||
"""
|
||||
subject = render_to_string(f"emails/{name}.subject.txt", context).strip()
|
||||
body_text = render_to_string(f"emails/{name}.body.txt", context)
|
||||
body_html = render_to_string(f"emails/{name}.body.html", context)
|
||||
return subject, body_text, body_html
|
||||
|
||||
|
||||
def _send(name: str, sub: Submission, extra_context: dict) -> bool:
|
||||
"""Common send path. Builds a `multipart/alternative` message with both
|
||||
the plain-text and HTML bodies attached so clients that don't render
|
||||
HTML (or where the user has opted out) still see a readable email.
|
||||
Returns True if the message was handed off to the transport backend
|
||||
successfully, False on any failure (we never propagate)."""
|
||||
to = _recipient(sub)
|
||||
if not to:
|
||||
logger.warning("no recipient for submission %s; skipping %s email", sub.slug, name)
|
||||
return False
|
||||
context = {
|
||||
"submission": sub,
|
||||
"site_url": settings.SITE_URL,
|
||||
**extra_context,
|
||||
}
|
||||
subject, body_text, body_html = _render(name, context)
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=body_text, # text/plain root part
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
to=[to],
|
||||
)
|
||||
msg.attach_alternative(body_html, "text/html") # rich version
|
||||
try:
|
||||
msg.send()
|
||||
except Exception: # broad on purpose: transport-level surprises shouldn't crash callers
|
||||
logger.exception("failed to send %s email for %s", name, sub.slug)
|
||||
return False
|
||||
logger.info("sent %s email for %s to %s", name, sub.slug, to)
|
||||
return True
|
||||
|
||||
|
||||
def send_confirmation_email(sub: Submission) -> bool:
|
||||
"""Send the email-confirmation link to a guest submitter.
|
||||
|
||||
The URL is built by string concatenation rather than `reverse()` because
|
||||
`dashboard:confirm` is currently scaffolded as a commented stub in
|
||||
`apps/dashboard/urls.py` (plan.md §7). Once that route is wired,
|
||||
switching to `reverse()` is a one-line change.
|
||||
"""
|
||||
confirm_url = (
|
||||
f"{settings.SITE_URL}/p/{sub.slug}/confirm/{sub.confirmation_token}/"
|
||||
)
|
||||
return _send("confirmation", sub, {"confirm_url": confirm_url})
|
||||
|
||||
|
||||
def send_status_update_email(
|
||||
sub: Submission, *, previous_status: str | None = None
|
||||
) -> bool:
|
||||
"""Notify the submitter that their submission moved to a new state.
|
||||
|
||||
Pass `previous_status` so the email can render "was X, now Y" when
|
||||
useful; omit it for first-time-ever notifications.
|
||||
"""
|
||||
detail_url = f"{settings.SITE_URL}/p/{sub.slug}/"
|
||||
return _send(
|
||||
"status_update",
|
||||
sub,
|
||||
{"detail_url": detail_url, "previous_status": previous_status},
|
||||
)
|
||||
|
||||
|
||||
def send_verifying_email(sub: Submission) -> bool:
|
||||
"""Notify the submitter that auto-validation passed (plan.md §7.3
|
||||
`processing -> verifying` transition). The print is now queued for
|
||||
a manual operator review; if the operator approves, the next email
|
||||
will be the queued / printing one."""
|
||||
detail_url = f"{settings.SITE_URL}/p/{sub.slug}/"
|
||||
return _send("verifying", sub, {"detail_url": detail_url})
|
||||
80
apps/submissions/management/commands/process_submissions.py
Normal file
80
apps/submissions/management/commands/process_submissions.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""`python manage.py process_submissions` -- implements plan.md §7.5.
|
||||
|
||||
Drains one batch of submissions stuck in `processing`: validates each row's
|
||||
STL (uploads) or URL (printables / makerworld / thingiverse) and transitions
|
||||
to `verifying` on success or `rejected` on failure. Runs on a 30-second
|
||||
loop from the `web` container's entrypoint (plan.md §10) -- one invocation
|
||||
of this command per tick.
|
||||
|
||||
Concurrency: `select_for_update(skip_locked=True)` keeps replicas / stray
|
||||
cron ticks / a re-entrant restart from grabbing the same row. On SQLite the
|
||||
locks are no-ops (dev only); Postgres in prod gets the non-blocking lock
|
||||
semantics the design assumes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.submissions.emails import send_status_update_email, send_verifying_email
|
||||
from apps.submissions.models import Submission
|
||||
from apps.submissions.validation import (
|
||||
ValidationError,
|
||||
validate_external_url,
|
||||
validate_stl_file,
|
||||
)
|
||||
|
||||
BATCH = 50
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = (
|
||||
"Drain submissions stuck in `processing` -- one batch per invocation. "
|
||||
"Designed to be called on a loop from the web container entrypoint; "
|
||||
"safe to also call ad-hoc for debugging."
|
||||
)
|
||||
|
||||
def add_arguments(self, parser) -> None:
|
||||
parser.add_argument(
|
||||
"--batch",
|
||||
type=int,
|
||||
default=BATCH,
|
||||
help=f"Max rows to process per invocation (default: {BATCH}).",
|
||||
)
|
||||
|
||||
def handle(self, *args, batch: int, **opts) -> None:
|
||||
with transaction.atomic():
|
||||
# `list(...)` materialises the slice inside the transaction so
|
||||
# subsequent `.save()` calls don't invalidate the queryset.
|
||||
queue = list(
|
||||
Submission.objects
|
||||
.select_for_update(skip_locked=True)
|
||||
.filter(status=Submission.Status.PROCESSING)
|
||||
.order_by("updated_at")[:batch]
|
||||
)
|
||||
|
||||
if not queue:
|
||||
return
|
||||
|
||||
for sub in queue:
|
||||
try:
|
||||
if sub.source_type == Submission.SourceType.UPLOAD:
|
||||
validate_stl_file(sub.stl_file.path)
|
||||
else:
|
||||
validate_external_url(sub.source_url, sub.source_type)
|
||||
except ValidationError as exc:
|
||||
sub.status = Submission.Status.REJECTED
|
||||
sub.operator_notes = f"Automatic rejection: {exc}"
|
||||
sub.closed_at = timezone.now()
|
||||
# closed_by stays NULL -- the validator did the rejecting,
|
||||
# not an operator (plan.md §5 / §7.3).
|
||||
sub.save()
|
||||
send_status_update_email(sub, previous_status="processing")
|
||||
self.stdout.write(f"rejected {sub.slug}: {exc}")
|
||||
else:
|
||||
sub.status = Submission.Status.VERIFYING
|
||||
sub.save()
|
||||
send_verifying_email(sub)
|
||||
self.stdout.write(f"verifying {sub.slug}")
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import namesgenerator
|
||||
from django.conf import settings
|
||||
from django.core.validators import FileExtensionValidator
|
||||
from django.db import models
|
||||
@@ -178,6 +179,29 @@ class Submission(models.Model):
|
||||
# dashboard index so post-submit redirects always land somewhere real.
|
||||
return reverse("dashboard:index")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Auto-generate `slug` on first save so any creation path -- admin,
|
||||
`SubmitView`, fixtures, `objects.create()` -- gets a Docker-style
|
||||
codename without callers having to remember to set one."""
|
||||
if not self.slug:
|
||||
self.slug = self._generate_unique_slug()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def _generate_unique_slug(cls, max_attempts: int = 16) -> str:
|
||||
"""`namesgenerator.get_random_name` + collision retries in Python
|
||||
(rather than a DB-side loop) so the rare collision path stays
|
||||
observable. If the surrounding transaction still loses a race with
|
||||
a concurrent insert, the DB's `unique=True` constraint fires an
|
||||
`IntegrityError` and the caller can retry."""
|
||||
for _ in range(max_attempts):
|
||||
candidate = namesgenerator.get_random_name()
|
||||
if not cls.objects.filter(slug=candidate).exists():
|
||||
return candidate
|
||||
# Fall through: extremely unlikely with the size of namesgenerator's
|
||||
# adjective/surname space.
|
||||
return namesgenerator.get_random_name()
|
||||
|
||||
# --- presentation helpers (consumed by dashboard templates) -------------
|
||||
|
||||
SOURCE_LABEL = {
|
||||
@@ -201,6 +225,20 @@ class Submission(models.Model):
|
||||
Status.FAILED: "bg-red-100 text-red-800",
|
||||
}
|
||||
|
||||
# Same palette as STATUS_BADGE_CLASS but expressed as hex pairs because
|
||||
# email clients strip <style> blocks and don't load external CSS, so the
|
||||
# HTML email templates use inline `background-color` / `color`.
|
||||
STATUS_EMAIL_COLORS = {
|
||||
Status.IDENTIFYING: {"bg": "#fef3c7", "fg": "#92400e"}, # amber
|
||||
Status.PROCESSING: {"bg": "#f1f5f9", "fg": "#334155"}, # slate
|
||||
Status.VERIFYING: {"bg": "#ede9fe", "fg": "#5b21b6"}, # violet
|
||||
Status.QUEUED: {"bg": "#dbeafe", "fg": "#1e40af"}, # blue
|
||||
Status.PRINTING: {"bg": "#ffedd5", "fg": "#9a3412"}, # orange
|
||||
Status.COMPLETED: {"bg": "#d1fae5", "fg": "#065f46"}, # emerald
|
||||
Status.REJECTED: {"bg": "#fee2e2", "fg": "#991b1b"}, # red
|
||||
Status.FAILED: {"bg": "#fee2e2", "fg": "#991b1b"}, # red
|
||||
}
|
||||
|
||||
@property
|
||||
def source_label(self) -> str:
|
||||
return self.SOURCE_LABEL.get(self.source_type, self.source_type)
|
||||
@@ -208,3 +246,9 @@ class Submission(models.Model):
|
||||
@property
|
||||
def status_badge_class(self) -> str:
|
||||
return self.STATUS_BADGE_CLASS.get(self.status, "bg-slate-100 text-slate-700")
|
||||
|
||||
@property
|
||||
def status_email_style(self) -> dict:
|
||||
return self.STATUS_EMAIL_COLORS.get(
|
||||
self.status, {"bg": "#f1f5f9", "fg": "#334155"}
|
||||
)
|
||||
|
||||
190
apps/submissions/validation.py
Normal file
190
apps/submissions/validation.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""STL + URL validation for the `processing` worker (plan.md §7.5.2 / §7.5.3).
|
||||
|
||||
Two public callables:
|
||||
|
||||
validate_stl_file(path)
|
||||
Magic-byte probe + numpy-stl mesh load + bounding-box vs build plate +
|
||||
triangle-count sanity. Raises `ValidationError` with the user-visible
|
||||
reason on any rejection.
|
||||
|
||||
validate_external_url(url, source_type)
|
||||
Host allow-list re-check + HEAD reachability (fallback to GET with a
|
||||
Range header on 405). Raises `ValidationError` likewise.
|
||||
|
||||
`numpy-stl` is imported lazily so this module can be imported on a host venv
|
||||
that hasn't run `pip install -r requirements.txt` yet -- the failure only
|
||||
fires when an STL is actually validated.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
from .models import Submission
|
||||
|
||||
|
||||
class ValidationError(Exception):
|
||||
"""Raised when a Submission fails its validation pass. The message
|
||||
becomes the operator-visible (and user-visible) rejection reason."""
|
||||
|
||||
|
||||
# Host allow-list per source_type. Mirrors `apps.submissions.forms`
|
||||
# deliberately rather than DRYing through a shared import: the
|
||||
# `processing` worker shouldn't be coupled to the form layer.
|
||||
_URL_HOSTS: dict[str, set[str]] = {
|
||||
Submission.SourceType.PRINTABLES: {"printables.com", "www.printables.com"},
|
||||
Submission.SourceType.MAKERWORLD: {"makerworld.com", "www.makerworld.com"},
|
||||
Submission.SourceType.THINGIVERSE: {"thingiverse.com", "www.thingiverse.com"},
|
||||
}
|
||||
|
||||
_MIN_AXIS_MM = 5.0
|
||||
_MIN_TRIANGLES = 4
|
||||
_MAX_TRIANGLES = 5_000_000
|
||||
|
||||
# Be a polite citizen so anti-bot defenses on the model platforms don't 403 us.
|
||||
_HTTP_HEADERS = {
|
||||
"User-Agent": "hamprint-validator/1.0 (+https://hamlab.lt)",
|
||||
}
|
||||
|
||||
|
||||
def _build_volume() -> tuple[float, float, float]:
|
||||
"""Read `PRINTER_BUILD_VOLUME_MM` (env-driven, default 235,235,250)."""
|
||||
raw = getattr(settings, "PRINTER_BUILD_VOLUME_MM", "235,235,250")
|
||||
parts = [float(x) for x in raw.split(",")]
|
||||
if len(parts) != 3:
|
||||
raise RuntimeError(
|
||||
f"PRINTER_BUILD_VOLUME_MM must be 'x,y,z'; got: {raw!r}"
|
||||
)
|
||||
return parts[0], parts[1], parts[2]
|
||||
|
||||
|
||||
def _fmt(value: float) -> str:
|
||||
"""Render `235.0` as `235`, `235.5` as `235.5`. Just for clean error text."""
|
||||
return str(int(value)) if value.is_integer() else f"{value:g}"
|
||||
|
||||
|
||||
# ---- STL ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def validate_stl_file(path: str) -> None:
|
||||
"""Four-pass check; raises `ValidationError` on first failure."""
|
||||
p = Path(path)
|
||||
if not p.is_file():
|
||||
raise ValidationError("STL file is missing on disk")
|
||||
|
||||
size = p.stat().st_size
|
||||
|
||||
# 1. Magic-byte / format probe.
|
||||
with p.open("rb") as f:
|
||||
head_80 = f.read(80)
|
||||
if head_80.startswith(b"solid "):
|
||||
# Could be ASCII -- but some binary STLs also start with "solid ".
|
||||
# Confirm by looking for an ASCII facet marker in the first 4 KB.
|
||||
with p.open("rb") as f:
|
||||
first_4k = f.read(4096)
|
||||
if b"facet normal" not in first_4k:
|
||||
raise ValidationError(
|
||||
"file is not a valid STL: header / size mismatch"
|
||||
)
|
||||
# ASCII STL accepted; the mesh load below catches anything subtler.
|
||||
else:
|
||||
# Binary STL: 80-byte header + uint32 triangle count + 50 B per tri.
|
||||
with p.open("rb") as f:
|
||||
f.seek(80)
|
||||
tri_bytes = f.read(4)
|
||||
if len(tri_bytes) != 4:
|
||||
raise ValidationError(
|
||||
"file is not a valid STL: header / size mismatch"
|
||||
)
|
||||
triangle_count = struct.unpack("<I", tri_bytes)[0]
|
||||
if size != 84 + 50 * triangle_count:
|
||||
raise ValidationError(
|
||||
"file is not a valid STL: header / size mismatch"
|
||||
)
|
||||
|
||||
# 2. Mesh load. Imported lazily so the module imports without numpy-stl.
|
||||
try:
|
||||
from stl import mesh # type: ignore[import-not-found]
|
||||
except ImportError as exc: # pragma: no cover -- only hit on host venv
|
||||
raise ValidationError(
|
||||
"numpy-stl is not installed in this environment"
|
||||
) from exc
|
||||
try:
|
||||
m = mesh.Mesh.from_file(str(p))
|
||||
except Exception: # numpy-stl raises a variety of types; broad on purpose
|
||||
raise ValidationError("STL could not be parsed")
|
||||
|
||||
# 3. Bounding-box check. mesh.vectors has shape (N, 3, 3).
|
||||
vertices = m.vectors.reshape(-1, 3)
|
||||
mins = vertices.min(axis=0)
|
||||
maxs = vertices.max(axis=0)
|
||||
extents = [float(maxs[i] - mins[i]) for i in range(3)]
|
||||
bv_x, bv_y, bv_z = _build_volume()
|
||||
|
||||
if extents[0] > bv_x or extents[1] > bv_y or extents[2] > bv_z:
|
||||
raise ValidationError(
|
||||
f"part is {_fmt(extents[0])}x{_fmt(extents[1])}x{_fmt(extents[2])} mm; "
|
||||
f"doesn't fit on our {_fmt(bv_x)}x{_fmt(bv_y)}x{_fmt(bv_z)} build plate"
|
||||
)
|
||||
if max(extents) < _MIN_AXIS_MM:
|
||||
raise ValidationError(
|
||||
f"part is too small to print reliably "
|
||||
f"(under {_fmt(_MIN_AXIS_MM)} mm on every axis)"
|
||||
)
|
||||
|
||||
# 4. Triangle-count sanity.
|
||||
tri_count = int(len(m.vectors))
|
||||
if tri_count < _MIN_TRIANGLES:
|
||||
raise ValidationError(
|
||||
f"mesh is degenerate (fewer than {_MIN_TRIANGLES} triangles)"
|
||||
)
|
||||
if tri_count > _MAX_TRIANGLES:
|
||||
raise ValidationError(f"mesh is too dense ({tri_count:,} triangles)")
|
||||
|
||||
|
||||
# ---- URL ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def validate_external_url(source_url: str, source_type: str) -> None:
|
||||
"""Host check + HEAD reachability; raises `ValidationError` on rejection."""
|
||||
if not source_url:
|
||||
raise ValidationError("URL is empty")
|
||||
|
||||
# 1. Host re-check. We already did this at form time but the row may have
|
||||
# been edited via the admin since.
|
||||
host = (urlparse(source_url).hostname or "").lower()
|
||||
allowed = _URL_HOSTS.get(source_type, set())
|
||||
if host not in allowed:
|
||||
raise ValidationError("URL host doesn't match source type")
|
||||
|
||||
# 2. HEAD reachability.
|
||||
try:
|
||||
r = requests.head(
|
||||
source_url,
|
||||
timeout=5,
|
||||
allow_redirects=True,
|
||||
headers=_HTTP_HEADERS,
|
||||
)
|
||||
# Some CDNs refuse HEAD with 405; fall back to a tiny ranged GET.
|
||||
if r.status_code == 405:
|
||||
r = requests.get(
|
||||
source_url,
|
||||
timeout=5,
|
||||
allow_redirects=True,
|
||||
headers={**_HTTP_HEADERS, "Range": "bytes=0-1023"},
|
||||
)
|
||||
except requests.exceptions.RequestException as exc:
|
||||
raise ValidationError(
|
||||
f"URL unreachable ({exc.__class__.__name__})"
|
||||
) from exc
|
||||
|
||||
if r.status_code >= 400:
|
||||
raise ValidationError(
|
||||
f"URL returned HTTP {r.status_code}; the model may have been "
|
||||
f"removed or set to private"
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Submit form view (plan.md §7.4 + §7).
|
||||
"""Submit form view (plan.md §7.4).
|
||||
|
||||
The view persists a `Submission` and, depending on whether the request is
|
||||
authenticated, sets the initial state machine state per plan.md §7.3:
|
||||
@@ -6,40 +6,28 @@ authenticated, sets the initial state machine state per plan.md §7.3:
|
||||
OAuth user -> processing (email already verified)
|
||||
guest with email -> identifying (waiting for confirmation link click)
|
||||
|
||||
Slug generation, email sending, and validation cron are out of scope here and
|
||||
deferred to plan.md §7.5 (`processor` sidecar) and §7.5 confirmation flow.
|
||||
For the guest path, the confirmation email is sent **synchronously** after
|
||||
the DB commit so we know the Mailtrap API outcome before redirecting. The
|
||||
dashboard then shows a green "check your inbox" notice on success or a red
|
||||
"couldn't send email -- try Google sign-in" notice on failure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
import namesgenerator
|
||||
from django.contrib import messages
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.urls import reverse_lazy
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.utils.html import format_html
|
||||
from django.views.generic import CreateView
|
||||
|
||||
from .emails import send_confirmation_email
|
||||
from .forms import SubmissionForm
|
||||
from .models import Submission
|
||||
|
||||
|
||||
def _generate_unique_slug(max_attempts: int = 16) -> str:
|
||||
"""`namesgenerator.get_random_name` with collision retries.
|
||||
|
||||
The slug column is `unique=True`; we retry instead of looping in SQL so
|
||||
the rare collision path stays observable.
|
||||
"""
|
||||
for _ in range(max_attempts):
|
||||
candidate = namesgenerator.get_random_name()
|
||||
if not Submission.objects.filter(slug=candidate).exists():
|
||||
return candidate
|
||||
# Fall through: extremely unlikely, but caller will see IntegrityError
|
||||
# if the next save() still collides.
|
||||
return namesgenerator.get_random_name()
|
||||
|
||||
|
||||
class SubmitView(CreateView):
|
||||
"""Public submit form. GET renders, POST creates a Submission."""
|
||||
|
||||
@@ -52,10 +40,12 @@ class SubmitView(CreateView):
|
||||
kwargs["user"] = self.request.user
|
||||
return kwargs
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
submission: Submission = form.save(commit=False)
|
||||
submission.slug = _generate_unique_slug()
|
||||
# The slug is auto-generated in `Submission.save()` if blank, so we
|
||||
# don't need to set one here. The `except IntegrityError` below
|
||||
# handles the rare race where the just-generated slug collides with
|
||||
# a concurrent insert.
|
||||
|
||||
if self.request.user.is_authenticated:
|
||||
submission.submitted_by = self.request.user
|
||||
@@ -70,28 +60,65 @@ class SubmitView(CreateView):
|
||||
submission.status = Submission.Status.IDENTIFYING
|
||||
submission.confirmation_token = secrets.token_urlsafe(32)
|
||||
submission.confirmation_sent_at = timezone.now()
|
||||
# TODO(plan.md §7.4 step 6): send the confirmation email here.
|
||||
|
||||
# Persist inside a tight atomic block so the row is committed BEFORE
|
||||
# we hit the email transport. That way a slow / failing Mailtrap API
|
||||
# call can never roll back a saved submission, and we get to look at
|
||||
# the result and surface it as a user-visible notice.
|
||||
with transaction.atomic():
|
||||
try:
|
||||
submission.save()
|
||||
except IntegrityError:
|
||||
# Extremely rare slug collision on the unique index; one more try.
|
||||
submission.slug = _generate_unique_slug()
|
||||
# Extremely rare slug collision on the unique index; clearing
|
||||
# `slug` makes `Submission.save()` regenerate it on retry.
|
||||
submission.slug = ""
|
||||
submission.save()
|
||||
|
||||
self.object = submission
|
||||
|
||||
if submission.status == Submission.Status.IDENTIFYING:
|
||||
messages.info(
|
||||
self.request,
|
||||
f"Submission {submission.slug} created. Check your email "
|
||||
f"({submission.guest_email}) for a confirmation link -- you "
|
||||
f"have 24 hours.",
|
||||
)
|
||||
self._notify_guest(submission)
|
||||
else:
|
||||
messages.success(
|
||||
self.request,
|
||||
f"Submission {submission.slug} accepted. We'll start "
|
||||
f"validating it shortly.",
|
||||
format_html(
|
||||
"Submission <strong class=\"font-mono\">{}</strong> accepted. "
|
||||
"We'll start validating it shortly.",
|
||||
submission.slug,
|
||||
),
|
||||
)
|
||||
|
||||
self.object = submission
|
||||
return super().form_valid(form)
|
||||
|
||||
# ---- guest-path notice ---------------------------------------------------
|
||||
|
||||
def _notify_guest(self, submission: Submission) -> None:
|
||||
"""Send the confirmation email and surface success / failure to the
|
||||
user via the messages framework. Green on 2xx from Mailtrap, red on
|
||||
any transport failure (with a "sign in with Google instead" hint)."""
|
||||
sent = send_confirmation_email(submission)
|
||||
if sent:
|
||||
messages.success(
|
||||
self.request,
|
||||
format_html(
|
||||
"Submission <strong class=\"font-mono\">{slug}</strong> created. "
|
||||
"We've sent a confirmation link to <strong>{email}</strong> — "
|
||||
"click it within 24 hours to add your print to the queue.",
|
||||
slug=submission.slug,
|
||||
email=submission.guest_email,
|
||||
),
|
||||
)
|
||||
else:
|
||||
messages.error(
|
||||
self.request,
|
||||
format_html(
|
||||
"Submission <strong class=\"font-mono\">{slug}</strong> was saved, "
|
||||
"but we couldn't send the confirmation email to <strong>{email}</strong>. "
|
||||
"Try <a href=\"{login_url}\" class=\"underline font-medium\">"
|
||||
"signing in with Google</a> instead — it skips email "
|
||||
"confirmation entirely.",
|
||||
slug=submission.slug,
|
||||
email=submission.guest_email,
|
||||
login_url=reverse("account_login"),
|
||||
),
|
||||
)
|
||||
|
||||
55
compose.dev.yaml
Normal file
55
compose.dev.yaml
Normal file
@@ -0,0 +1,55 @@
|
||||
# Podman / Docker Compose stack for local hamprint development.
|
||||
#
|
||||
# Bring it up: podman-compose up -d (or: docker compose up -d)
|
||||
# Logs: podman-compose logs -f web
|
||||
# Tear down: podman-compose down (keeps the pgdata volume)
|
||||
# podman-compose down -v (drops the pgdata volume too)
|
||||
#
|
||||
# The `web` service uses a stock python:3.14-slim image and bind-mounts this
|
||||
# repo at /app. On every start it installs from requirements.txt plus
|
||||
# psycopg[binary] (the pip-cache volume makes subsequent starts fast) and
|
||||
# runs Django's dev server. There is intentionally no Containerfile yet --
|
||||
# swap `image:` for `build: .` once Section 10's image lands.
|
||||
|
||||
services:
|
||||
db:
|
||||
image: docker.io/library/postgres:16
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
env_file: .env
|
||||
|
||||
web:
|
||||
image: docker.io/library/python:3.14-slim
|
||||
working_dir: /app
|
||||
command: >
|
||||
bash -c "
|
||||
pip install --quiet --no-input -r requirements.txt 'psycopg[binary]' &&
|
||||
python manage.py migrate --noinput &&
|
||||
python manage.py runserver 0.0.0.0:8000
|
||||
"
|
||||
env_file: .env
|
||||
environment:
|
||||
DJANGO_SETTINGS_MODULE: hamprint.settings.dev
|
||||
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- .:/app:Z
|
||||
- pipcache:/root/.cache/pip
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
pipcache:
|
||||
57
compose.yaml
57
compose.yaml
@@ -1,20 +1,39 @@
|
||||
# Podman / Docker Compose stack for local hamprint development.
|
||||
# Production compose stack for hamprint.
|
||||
#
|
||||
# Bring it up: podman-compose up -d (or: docker compose up -d)
|
||||
# What changed from the previous bind-mount / pip-at-runtime version:
|
||||
# - `web` is now BUILT from the Containerfile in this repo. Everything
|
||||
# (Python deps, the Tailwind CLI binary, the built CSS, collectstatic
|
||||
# output) bakes into the image; nothing is installed at container start.
|
||||
# - No host source bind-mount: the container ships its own /app. Code
|
||||
# changes require a `podman-compose up -d --build web`.
|
||||
# - `DJANGO_SETTINGS_MODULE=hamprint.settings.prod` (DEBUG off, secure
|
||||
# cookies, HSTS). DEBUG=True traffic should run from the host venv,
|
||||
# not from this stack.
|
||||
# - Uploaded STLs persist in a named `media` volume so they survive
|
||||
# `podman-compose down` / image rebuilds. Drop with `down -v`.
|
||||
#
|
||||
# Bring it up: podman-compose up -d --build
|
||||
# Rebuild only web: podman-compose up -d --build web
|
||||
# Logs: podman-compose logs -f web
|
||||
# Tear down: podman-compose down (keeps the pgdata volume)
|
||||
# podman-compose down -v (drops the pgdata volume too)
|
||||
# Tear down: podman-compose down # keeps pgdata + media
|
||||
# podman-compose down -v # nukes both volumes too
|
||||
#
|
||||
# The `web` service uses a stock python:3.14-slim image and bind-mounts this
|
||||
# repo at /app. On every start it installs from requirements.txt plus
|
||||
# psycopg[binary] (the pip-cache volume makes subsequent starts fast) and
|
||||
# runs Django's dev server. There is intentionally no Containerfile yet --
|
||||
# swap `image:` for `build: .` once Section 10's image lands.
|
||||
# `.env` keys you'll want set (see `.env.example` for the full list):
|
||||
# SECRET_KEY - long random string
|
||||
# ALLOWED_HOSTS - e.g. "print.hamlab.lt,localhost"
|
||||
# SITE_URL - e.g. "https://print.hamlab.lt" (for emails)
|
||||
# POSTGRES_DB / _USER / _PASSWORD
|
||||
# MAILTRAP_API_TOKEN (+ MAILTRAP_TEST_INBOX_ID for sandbox)
|
||||
# GOOGLE_CLIENT_ID / _SECRET (optional; only if Google sign-in is wanted)
|
||||
#
|
||||
# TLS termination is the upstream proxy's job -- the `web` container speaks
|
||||
# plain HTTP on its mapped host port (default 8000).
|
||||
|
||||
services:
|
||||
db:
|
||||
image: docker.io/library/postgres:16
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
@@ -26,30 +45,24 @@ services:
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
env_file: .env
|
||||
|
||||
web:
|
||||
image: docker.io/library/python:3.14-slim
|
||||
working_dir: /app
|
||||
command: >
|
||||
bash -c "
|
||||
pip install --quiet --no-input -r requirements.txt 'psycopg[binary]' &&
|
||||
python manage.py migrate --noinput &&
|
||||
python manage.py runserver 0.0.0.0:8000
|
||||
"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Containerfile
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
DJANGO_SETTINGS_MODULE: hamprint.settings.dev
|
||||
DJANGO_SETTINGS_MODULE: hamprint.settings.prod
|
||||
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- .:/app:Z
|
||||
- pipcache:/root/.cache/pip
|
||||
- media:/app/media
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
pipcache:
|
||||
media:
|
||||
|
||||
38
entrypoint.sh
Normal file
38
entrypoint.sh
Normal file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# hamprint container entrypoint.
|
||||
#
|
||||
# Runs everything inside the single `web` container per plan.md §10:
|
||||
# 1. apply DB migrations (idempotent; runs on every start)
|
||||
# 2. background `process_submissions` (plan.md §7.5, 30 s loop)
|
||||
# 3. background `cleanup_stale` (plan.md §7.6, 5 min loop)
|
||||
# 4. exec Gunicorn so it becomes the foreground process for signal handling
|
||||
#
|
||||
# tini (set as ENTRYPOINT in the Containerfile) reaps zombies and forwards
|
||||
# SIGTERM to all three child processes on container stop.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
python manage.py migrate --noinput
|
||||
|
||||
# Validation worker. `|| true` keeps the loop alive across a transient
|
||||
# failure (e.g. a flaky external HEAD request); the next tick retries.
|
||||
(
|
||||
while true; do
|
||||
python manage.py process_submissions || true
|
||||
sleep 30
|
||||
done
|
||||
) &
|
||||
|
||||
# Stale-row reaper. Same pattern, different cadence.
|
||||
(
|
||||
while true; do
|
||||
python manage.py cleanup_stale || true
|
||||
sleep 300
|
||||
done
|
||||
) &
|
||||
|
||||
exec gunicorn hamprint.wsgi:application \
|
||||
--bind 0.0.0.0:8000 \
|
||||
--workers "${GUNICORN_WORKERS:-3}" \
|
||||
--access-logfile - \
|
||||
--error-logfile -
|
||||
@@ -119,17 +119,38 @@ STORAGES = {
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
# Email --- Mailjet via django-anymail. The env var name MAILJET_API_SECRET is
|
||||
# mapped onto anymail's MAILJET_SECRET_KEY so our two env vars stay symmetrical.
|
||||
EMAIL_BACKEND = "anymail.backends.mailjet.EmailBackend"
|
||||
ANYMAIL = {
|
||||
"MAILJET_API_KEY": env("MAILJET_API_KEY", default=""),
|
||||
"MAILJET_SECRET_KEY": env("MAILJET_API_SECRET", default=""),
|
||||
}
|
||||
# Email --- Mailtrap HTTPS API via django-anymail, with a console fallback so
|
||||
# a dev without credentials still sees emails in the runserver log instead of
|
||||
# crashing on send.
|
||||
#
|
||||
# If MAILTRAP_TEST_INBOX_ID is set, anymail routes to Mailtrap's sandbox
|
||||
# (testing inbox) instead of doing real delivery -- handy for staging.
|
||||
_mailtrap_token = env("MAILTRAP_API_TOKEN", default="")
|
||||
_mailtrap_test_inbox = env("MAILTRAP_TEST_INBOX_ID", default="")
|
||||
|
||||
if _mailtrap_token:
|
||||
EMAIL_BACKEND = "anymail.backends.mailtrap.EmailBackend"
|
||||
ANYMAIL = {
|
||||
"MAILTRAP_API_TOKEN": _mailtrap_token,
|
||||
}
|
||||
if _mailtrap_test_inbox:
|
||||
ANYMAIL["MAILTRAP_TEST_INBOX_ID"] = _mailtrap_test_inbox
|
||||
ANYMAIL["MAILTRAP_TESTING"] = True
|
||||
else:
|
||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
DEFAULT_FROM_EMAIL = env(
|
||||
"DEFAULT_FROM_EMAIL", default="hamprint <noreply@hamlab.lt>"
|
||||
)
|
||||
|
||||
# Public base URL used in outgoing emails (confirmation + status links).
|
||||
# Override in `.env` per environment; defaults to localhost for dev.
|
||||
SITE_URL = env("SITE_URL", default="http://localhost:8000").rstrip("/")
|
||||
|
||||
# Printer build volume in mm, "x,y,z". Consumed by the STL bounding-box
|
||||
# pass in `apps.submissions.validation`. Default fits a Bambu A1 / Prusa
|
||||
# MK4 class machine. Override per-printer in `.env`.
|
||||
PRINTER_BUILD_VOLUME_MM = env("PRINTER_BUILD_VOLUME_MM", default="235,235,250")
|
||||
|
||||
CRISPY_ALLOWED_TEMPLATE_PACKS = "tailwind"
|
||||
CRISPY_TEMPLATE_PACK = "tailwind"
|
||||
|
||||
|
||||
@@ -5,5 +5,6 @@ from .base import * # noqa: F401, F403
|
||||
DEBUG = True
|
||||
ALLOWED_HOSTS = ["localhost", "127.0.0.1", "0.0.0.0"]
|
||||
|
||||
# Print emails to the console in dev so we don't burn Mailjet quota.
|
||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
# Email backend is auto-detected in base.py: Mailtrap if MAILTRAP_API_TOKEN
|
||||
# is set in .env, otherwise the console backend. To force console output
|
||||
# even with a token set, clear MAILTRAP_API_TOKEN.
|
||||
|
||||
@@ -20,6 +20,10 @@ from django.urls import include, path
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
# Our local /accounts/ routes (close-account, etc.) come BEFORE allauth's
|
||||
# include so they win on URL match. Everything we don't claim falls
|
||||
# through to allauth.
|
||||
path("accounts/", include("apps.accounts.urls")),
|
||||
path("accounts/", include("allauth.urls")),
|
||||
path("", include("apps.dashboard.urls")),
|
||||
path("submit/", include("apps.submissions.urls")),
|
||||
|
||||
@@ -19,6 +19,7 @@ idna==3.14
|
||||
markdown-it-py==4.2.0
|
||||
mdurl==0.1.2
|
||||
namesgenerator==0.3
|
||||
numpy-stl>=3.0
|
||||
packaging==26.2
|
||||
pycparser==3.0
|
||||
Pygments==2.20.0
|
||||
|
||||
File diff suppressed because one or more lines are too long
44
templates/account/close.html
Normal file
44
templates/account/close.html
Normal file
@@ -0,0 +1,44 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Close your hamprint account{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-md mx-auto py-8">
|
||||
<div class="bg-white border border-red-200 rounded-lg p-8 shadow-sm">
|
||||
<h1 class="text-2xl font-bold tracking-tight text-red-700">Close your account</h1>
|
||||
<p class="text-slate-700 mt-2 text-sm">
|
||||
Signed in as
|
||||
<span class="font-medium">{{ user.email|default:user.get_username }}</span>.
|
||||
This action permanently deletes your account.
|
||||
</p>
|
||||
|
||||
<div class="mt-4 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-900">
|
||||
<p class="font-semibold">There is no undo.</p>
|
||||
<ul class="mt-2 list-disc pl-5 space-y-1">
|
||||
<li>Your user row is deleted from the database.</li>
|
||||
<li>
|
||||
{% if submission_count == 0 %}
|
||||
You don't have any prints in the system right now -- nothing else to remove.
|
||||
{% elif submission_count == 1 %}
|
||||
Your <strong>1</strong> print is deleted along with your account, including any uploaded STL.
|
||||
{% else %}
|
||||
All <strong>{{ submission_count }}</strong> of your prints are deleted along with your account, including any uploaded STLs.
|
||||
{% endif %}
|
||||
</li>
|
||||
<li>Any active queue items are cancelled. Operators won't be able to contact you afterwards.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<form method="post" class="mt-6">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="w-full px-4 py-3 rounded-md bg-red-600 text-white hover:bg-red-700 font-medium">
|
||||
Yes, delete my account permanently
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<a href="{% url 'dashboard:my_prints' %}" class="text-sm text-slate-600 hover:underline">← Cancel, take me back</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
27
templates/account/logout.html
Normal file
27
templates/account/logout.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Sign out — hamprint{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-md mx-auto py-8">
|
||||
<div class="bg-white border border-slate-200 rounded-lg p-8 shadow-sm">
|
||||
<h1 class="text-2xl font-bold tracking-tight">Sign out?</h1>
|
||||
<p class="text-slate-600 mt-2 text-sm">
|
||||
You're signed in as
|
||||
<span class="font-medium">{{ user.email|default:user.get_username }}</span>.
|
||||
Your prints stay on the dashboard either way -- you can come back any time.
|
||||
</p>
|
||||
|
||||
<form method="post" action="{% url 'account_logout' %}" class="mt-6">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="w-full px-4 py-3 rounded-md bg-amber-500 text-white hover:bg-amber-600 font-medium">
|
||||
Sign me out
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<a href="{% url 'dashboard:my_prints' %}" class="text-sm text-slate-600 hover:underline">← Back to my prints</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -44,6 +44,26 @@
|
||||
</nav>
|
||||
|
||||
<main class="mx-auto max-w-6xl px-4 py-8">
|
||||
{% if messages %}
|
||||
{% comment %}
|
||||
One styled card per Django messages.add_message() call. The text is
|
||||
rendered via {{ message|safe }} so that callers that built their
|
||||
string with django.utils.html.format_html() can include <a>/<strong>
|
||||
markup (variables are auto-escaped by format_html).
|
||||
{% endcomment %}
|
||||
<div class="mb-6 space-y-2">
|
||||
{% for message in messages %}
|
||||
<div class="px-4 py-3 rounded-md border text-sm
|
||||
{% if 'success' in message.tags %}border-emerald-200 bg-emerald-50 text-emerald-900
|
||||
{% elif 'error' in message.tags %}border-red-200 bg-red-50 text-red-900
|
||||
{% elif 'warning' in message.tags %}border-amber-200 bg-amber-50 text-amber-900
|
||||
{% else %}border-slate-200 bg-slate-50 text-slate-700{% endif %}">
|
||||
{{ message|safe }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
|
||||
@@ -80,6 +80,11 @@
|
||||
|
||||
<div class="mt-6 flex items-center justify-between text-sm">
|
||||
<a href="{% url 'submissions:create' %}" class="text-amber-700 hover:underline font-medium">+ Submit another print</a>
|
||||
<div class="flex items-center gap-4">
|
||||
{% if not user.is_staff %}
|
||||
<a href="{% url 'accounts:close' %}" class="text-red-600 hover:underline">Close account</a>
|
||||
{% endif %}
|
||||
<a href="{% url 'account_logout' %}" class="text-slate-500 hover:underline">Sign out</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
70
templates/emails/_base.html
Normal file
70
templates/emails/_base.html
Normal file
@@ -0,0 +1,70 @@
|
||||
{# Base email shell. Child templates fill {% block body %}{% endblock %}. #}
|
||||
{# Inline styles only -- most clients strip <style> tags or block CDNs. #}
|
||||
{# Table-based layout -- Outlook/Windows Mail still rely on it for spacing. #}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta name="supported-color-schemes" content="light">
|
||||
<title>hamprint</title>
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background-color:#f8fafc; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif; color:#0f172a; -webkit-font-smoothing:antialiased;">
|
||||
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f8fafc;">
|
||||
<tr>
|
||||
<td align="center" style="padding:32px 16px;">
|
||||
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="560" style="max-width:560px; width:100%; background-color:#ffffff; border:1px solid #e2e8f0; border-radius:8px; box-shadow:0 1px 2px rgba(15,23,42,0.04);">
|
||||
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="padding:20px 28px; border-bottom:1px solid #e2e8f0;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td style="padding-right:10px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="32" height="32" style="background-color:#f59e0b; border-radius:6px;">
|
||||
<tr>
|
||||
<td align="center" valign="middle" height="32" style="color:#ffffff; font-weight:700; font-size:18px; line-height:1; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">h</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
<td style="vertical-align:middle;">
|
||||
<span style="font-weight:700; font-size:18px; color:#0f172a; letter-spacing:-0.01em;">hamprint</span>
|
||||
<span style="font-size:12px; color:#64748b; margin-left:4px;">· hamlab.lt</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="padding:28px;">
|
||||
{% block body %}{% endblock %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="padding:16px 28px; border-top:1px solid #e2e8f0; background-color:#f8fafc; border-bottom-left-radius:8px; border-bottom-right-radius:8px;">
|
||||
<p style="margin:0; font-size:12px; color:#64748b; text-align:center;">
|
||||
A community service of <a href="https://hamlab.lt" style="color:#475569; font-weight:500; text-decoration:none;">hamlab.lt</a>.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
<!-- Plain-text footnote that still degrades nicely in dark-mode clients -->
|
||||
<p style="margin:16px 0 0 0; font-size:12px; color:#94a3b8; text-align:center; max-width:560px;">
|
||||
You're receiving this because you submitted a print at hamprint.
|
||||
</p>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
39
templates/emails/confirmation.body.html
Normal file
39
templates/emails/confirmation.body.html
Normal file
@@ -0,0 +1,39 @@
|
||||
{% extends "emails/_base.html" %}
|
||||
{% block body %}
|
||||
<h1 style="margin:0 0 12px 0; font-size:22px; font-weight:700; color:#0f172a; letter-spacing:-0.01em;">Confirm your submission</h1>
|
||||
|
||||
<p style="margin:0 0 18px 0; color:#334155; font-size:15px; line-height:1.55;">
|
||||
You submitted a 3D print to hamlab.lt. Your codename is:
|
||||
</p>
|
||||
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:0 0 22px 0;">
|
||||
<tr>
|
||||
<td style="padding:12px 16px; background-color:#fef3c7; border-radius:6px; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:18px; font-weight:700; color:#92400e;">
|
||||
{{ submission.slug }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin:0 0 24px 0; color:#334155; font-size:15px; line-height:1.55;">
|
||||
To put it in the queue, click the button below within <strong style="color:#0f172a;">24 hours</strong>. Otherwise the submission (and any uploaded <span style="font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;">.stl</span>) is deleted automatically.
|
||||
</p>
|
||||
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 24px 0;">
|
||||
<tr>
|
||||
<td style="background-color:#f59e0b; border-radius:6px;">
|
||||
<a href="{{ confirm_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;">
|
||||
Confirm my submission
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin:0 0 4px 0; font-size:13px; color:#64748b;">Or copy this link into your browser:</p>
|
||||
<p style="margin:0 0 24px 0; font-size:13px; color:#475569; word-break:break-all; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;">{{ confirm_url }}</p>
|
||||
|
||||
<hr style="margin:0 0 18px 0; border:none; border-top:1px solid #e2e8f0;">
|
||||
|
||||
<p style="margin:0; font-size:13px; color:#64748b; line-height:1.55;">
|
||||
If you didn't submit this, you can safely ignore the email — nothing happens until you click the link.
|
||||
</p>
|
||||
{% endblock %}
|
||||
16
templates/emails/confirmation.body.txt
Normal file
16
templates/emails/confirmation.body.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
Hi,
|
||||
|
||||
You submitted a 3D print to hamlab.lt. Your codename is:
|
||||
|
||||
{{ submission.slug }}
|
||||
|
||||
To put it in the queue, click the link below within 24 hours. If you don't,
|
||||
the submission (and any uploaded .stl) will be deleted automatically.
|
||||
|
||||
{{ confirm_url }}
|
||||
|
||||
If you didn't submit this, you can safely ignore this email -- nothing
|
||||
happens until you click the link.
|
||||
|
||||
— hamprint
|
||||
{{ site_url }}
|
||||
1
templates/emails/confirmation.subject.txt
Normal file
1
templates/emails/confirmation.subject.txt
Normal file
@@ -0,0 +1 @@
|
||||
Confirm your hamprint submission: {{ submission.slug }}
|
||||
55
templates/emails/status_update.body.html
Normal file
55
templates/emails/status_update.body.html
Normal file
@@ -0,0 +1,55 @@
|
||||
{% extends "emails/_base.html" %}
|
||||
{% block body %}
|
||||
<h1 style="margin:0 0 12px 0; font-size:22px; font-weight:700; color:#0f172a; letter-spacing:-0.01em;">Status update</h1>
|
||||
|
||||
<p style="margin:0 0 16px 0; color:#334155; font-size:15px; line-height:1.55;">
|
||||
Your submission <strong style="font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; color:#92400e; font-weight:700;">{{ submission.slug }}</strong> has moved to:
|
||||
</p>
|
||||
|
||||
{% comment %}
|
||||
Status pill -- per-status hex colours from `Submission.STATUS_EMAIL_COLORS`.
|
||||
Wrapped in a table so Outlook respects padding + border-radius.
|
||||
{% endcomment %}
|
||||
<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 previous_status %}
|
||||
<p style="margin:0 0 20px 0; font-size:13px; color:#64748b;">
|
||||
Previously: <span style="color:#475569;">{{ previous_status }}</span>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if submission.operator_notes %}
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:0 0 24px 0;">
|
||||
<tr>
|
||||
<td style="padding:16px; background-color:#f8fafc; border-left:3px solid #f59e0b; border-radius:0 4px 4px 0;">
|
||||
<p style="margin:0 0 6px 0; font-size:11px; font-weight:600; color:#64748b; 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 %}
|
||||
|
||||
<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;">
|
||||
View submission
|
||||
</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 %}
|
||||
16
templates/emails/status_update.body.txt
Normal file
16
templates/emails/status_update.body.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
Hi,
|
||||
|
||||
Your hamprint submission "{{ submission.slug }}" has moved to:
|
||||
|
||||
{{ submission.get_status_display }}{% if previous_status %} (was: {{ previous_status }}){% endif %}
|
||||
|
||||
{% if submission.operator_notes %}A note from the operator:
|
||||
|
||||
{{ submission.operator_notes }}
|
||||
|
||||
{% endif %}You can see the latest state at:
|
||||
|
||||
{{ detail_url }}
|
||||
|
||||
— hamprint
|
||||
{{ site_url }}
|
||||
1
templates/emails/status_update.subject.txt
Normal file
1
templates/emails/status_update.subject.txt
Normal file
@@ -0,0 +1 @@
|
||||
hamprint update: {{ submission.slug }} is now {{ submission.get_status_display }}
|
||||
37
templates/emails/verifying.body.html
Normal file
37
templates/emails/verifying.body.html
Normal 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:#0f172a; letter-spacing:-0.01em;">Cleared auto-checks</h1>
|
||||
|
||||
<p style="margin:0 0 16px 0; color:#334155; font-size:15px; line-height:1.55;">
|
||||
Your submission <strong style="font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; color:#92400e; font-weight:700;">{{ submission.slug }}</strong> passed our automated checks (size, format, host allow-list) and is now waiting for an operator to take a manual look.
|
||||
</p>
|
||||
|
||||
{# Pill colours come from `Submission.STATUS_EMAIL_COLORS` -- same per-status palette as the website badge. #}
|
||||
<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;">
|
||||
If the operator is happy with the print job, it moves into the print queue shortly after — and we'll email you again once that happens. You don't need to do anything right now.
|
||||
</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;">
|
||||
View submission
|
||||
</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 %}
|
||||
18
templates/emails/verifying.body.txt
Normal file
18
templates/emails/verifying.body.txt
Normal file
@@ -0,0 +1,18 @@
|
||||
Hi,
|
||||
|
||||
Good news -- your hamprint submission "{{ submission.slug }}" passed the
|
||||
automated checks (size, format, host allow-list) and is now waiting for an
|
||||
operator to take a manual look.
|
||||
|
||||
Codename : {{ submission.slug }}
|
||||
Status : Verifying
|
||||
|
||||
If the operator is happy with the print job, it moves into the print queue
|
||||
shortly after and we'll email you again. You don't need to do anything
|
||||
right now.
|
||||
|
||||
You can also check on it here:
|
||||
{{ detail_url }}
|
||||
|
||||
— hamprint
|
||||
{{ site_url }}
|
||||
1
templates/emails/verifying.subject.txt
Normal file
1
templates/emails/verifying.subject.txt
Normal file
@@ -0,0 +1 @@
|
||||
hamprint: {{ submission.slug }} cleared auto-checks, awaiting operator review
|
||||
Reference in New Issue
Block a user