Add proper email notifications

This commit is contained in:
2026-05-14 23:19:50 +03:00
parent fe62575790
commit 9e16b78793
34 changed files with 1313 additions and 83 deletions

50
.dockerignore Normal file
View 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/

View File

@@ -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
View 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
View 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"),
]

View File

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

View File

@@ -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"),
]

View File

@@ -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> &mdash; "
"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
View 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})

View 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}")

View File

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

View 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"
)

View File

@@ -1,45 +1,33 @@
"""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:
OAuth user -> processing (email already verified)
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.
try:
submission.save()
except IntegrityError:
# Extremely rare slug collision on the unique index; one more try.
submission.slug = _generate_unique_slug()
submission.save()
# 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; 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> &mdash; "
"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 &mdash; it skips email "
"confirmation entirely.",
slug=submission.slug,
email=submission.guest_email,
login_url=reverse("account_login"),
),
)

55
compose.dev.yaml Normal file
View 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:

View File

@@ -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)
# Logs: podman-compose logs -f web
# Tear down: podman-compose down (keeps the pgdata volume)
# podman-compose down -v (drops the pgdata volume too)
# 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`.
#
# 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.
# 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 pgdata + media
# podman-compose down -v # nukes both volumes too
#
# `.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
View 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 -

View File

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

View File

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

View File

@@ -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")),

View File

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

View 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 %}

View 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 %}

View File

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

View File

@@ -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>
<a href="{% url 'account_logout' %}" class="text-slate-500 hover:underline">Sign out</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 %}

View 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>

View 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 %}

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

View File

@@ -0,0 +1 @@
Confirm your hamprint submission: {{ submission.slug }}

View 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 %}

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

View File

@@ -0,0 +1 @@
hamprint update: {{ submission.slug }} is now {{ submission.get_status_display }}

View File

@@ -0,0 +1,37 @@
{% extends "emails/_base.html" %}
{% block body %}
<h1 style="margin:0 0 12px 0; font-size:22px; font-weight:700; color:#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 &mdash; 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 %}

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

View File

@@ -0,0 +1 @@
hamprint: {{ submission.slug }} cleared auto-checks, awaiting operator review