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
|
DEBUG=true
|
||||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
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 ---
|
# --- PostgreSQL ---
|
||||||
# Used by the `db` container and interpolated into DATABASE_URL inside
|
# Used by the `db` container and interpolated into DATABASE_URL inside
|
||||||
# compose.yaml; the `web` container reads DATABASE_URL via dj-database-url.
|
# compose.yaml; the `web` container reads DATABASE_URL via dj-database-url.
|
||||||
@@ -16,15 +21,20 @@ POSTGRES_DB=hamprint
|
|||||||
POSTGRES_USER=hamprint
|
POSTGRES_USER=hamprint
|
||||||
POSTGRES_PASSWORD=changeme
|
POSTGRES_PASSWORD=changeme
|
||||||
|
|
||||||
# --- Mailjet (transactional email) ---
|
# --- Mailtrap (transactional email) ---
|
||||||
# Only used when running with `hamprint.settings.prod`; `hamprint.settings.dev`
|
# When MAILTRAP_API_TOKEN is set, Django sends through Mailtrap's HTTPS API
|
||||||
# overrides EMAIL_BACKEND to the console backend so emails are printed to
|
# via django-anymail. When blank, settings/base.py falls back to the console
|
||||||
# the `web` container logs.
|
# email backend -- handy for local development without burning real quota or
|
||||||
# Get keys at https://app.mailjet.com/account/apikeys
|
# spamming real addresses.
|
||||||
MAILJET_API_KEY=
|
# Get a token at https://mailtrap.io/api-tokens
|
||||||
MAILJET_API_SECRET=
|
MAILTRAP_API_TOKEN=
|
||||||
DEFAULT_FROM_EMAIL=hamprint <noreply@hamlab.lt>
|
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) ---
|
# --- Google OAuth (django-allauth Google provider) ---
|
||||||
# Configure at https://console.cloud.google.com/apis/credentials and add a
|
# Configure at https://console.cloud.google.com/apis/credentials and add a
|
||||||
# SocialApp via Django admin (/admin/socialaccount/socialapp/add/).
|
# 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 = [
|
urlpatterns = [
|
||||||
path("", views.IndexView.as_view(), name="index"),
|
path("", views.IndexView.as_view(), name="index"),
|
||||||
path("my-prints/", views.MyPrintsView.as_view(), name="my_prints"),
|
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):
|
# 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>/", views.SubmissionDetailView.as_view(), name="detail"),
|
||||||
# path("p/<slug:slug>/status/", views.SubmissionStatusFragment.as_view(), name="status"),
|
# 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"),
|
# 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.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.db.models import Count, Q
|
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 django.views.generic import ListView
|
||||||
|
|
||||||
from apps.submissions.models import Submission
|
from apps.submissions.models import Submission
|
||||||
@@ -54,6 +60,76 @@ class IndexView(ListView):
|
|||||||
return ctx
|
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):
|
class MyPrintsView(LoginRequiredMixin, ListView):
|
||||||
"""Private listing -- every submission the signed-in user has ever made.
|
"""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 uuid
|
||||||
|
|
||||||
|
import namesgenerator
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.validators import FileExtensionValidator
|
from django.core.validators import FileExtensionValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
@@ -178,6 +179,29 @@ class Submission(models.Model):
|
|||||||
# dashboard index so post-submit redirects always land somewhere real.
|
# dashboard index so post-submit redirects always land somewhere real.
|
||||||
return reverse("dashboard:index")
|
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) -------------
|
# --- presentation helpers (consumed by dashboard templates) -------------
|
||||||
|
|
||||||
SOURCE_LABEL = {
|
SOURCE_LABEL = {
|
||||||
@@ -201,6 +225,20 @@ class Submission(models.Model):
|
|||||||
Status.FAILED: "bg-red-100 text-red-800",
|
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
|
@property
|
||||||
def source_label(self) -> str:
|
def source_label(self) -> str:
|
||||||
return self.SOURCE_LABEL.get(self.source_type, self.source_type)
|
return self.SOURCE_LABEL.get(self.source_type, self.source_type)
|
||||||
@@ -208,3 +246,9 @@ class Submission(models.Model):
|
|||||||
@property
|
@property
|
||||||
def status_badge_class(self) -> str:
|
def status_badge_class(self) -> str:
|
||||||
return self.STATUS_BADGE_CLASS.get(self.status, "bg-slate-100 text-slate-700")
|
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
|
The view persists a `Submission` and, depending on whether the request is
|
||||||
authenticated, sets the initial state machine state per plan.md §7.3:
|
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)
|
OAuth user -> processing (email already verified)
|
||||||
guest with email -> identifying (waiting for confirmation link click)
|
guest with email -> identifying (waiting for confirmation link click)
|
||||||
|
|
||||||
Slug generation, email sending, and validation cron are out of scope here and
|
For the guest path, the confirmation email is sent **synchronously** after
|
||||||
deferred to plan.md §7.5 (`processor` sidecar) and §7.5 confirmation flow.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
import namesgenerator
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.db import IntegrityError, transaction
|
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 import timezone
|
||||||
|
from django.utils.html import format_html
|
||||||
from django.views.generic import CreateView
|
from django.views.generic import CreateView
|
||||||
|
|
||||||
|
from .emails import send_confirmation_email
|
||||||
from .forms import SubmissionForm
|
from .forms import SubmissionForm
|
||||||
from .models import Submission
|
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):
|
class SubmitView(CreateView):
|
||||||
"""Public submit form. GET renders, POST creates a Submission."""
|
"""Public submit form. GET renders, POST creates a Submission."""
|
||||||
|
|
||||||
@@ -52,10 +40,12 @@ class SubmitView(CreateView):
|
|||||||
kwargs["user"] = self.request.user
|
kwargs["user"] = self.request.user
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
@transaction.atomic
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
submission: Submission = form.save(commit=False)
|
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:
|
if self.request.user.is_authenticated:
|
||||||
submission.submitted_by = self.request.user
|
submission.submitted_by = self.request.user
|
||||||
@@ -70,28 +60,65 @@ class SubmitView(CreateView):
|
|||||||
submission.status = Submission.Status.IDENTIFYING
|
submission.status = Submission.Status.IDENTIFYING
|
||||||
submission.confirmation_token = secrets.token_urlsafe(32)
|
submission.confirmation_token = secrets.token_urlsafe(32)
|
||||||
submission.confirmation_sent_at = timezone.now()
|
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:
|
try:
|
||||||
submission.save()
|
submission.save()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
# Extremely rare slug collision on the unique index; one more try.
|
# Extremely rare slug collision on the unique index; clearing
|
||||||
submission.slug = _generate_unique_slug()
|
# `slug` makes `Submission.save()` regenerate it on retry.
|
||||||
|
submission.slug = ""
|
||||||
submission.save()
|
submission.save()
|
||||||
|
|
||||||
|
self.object = submission
|
||||||
|
|
||||||
if submission.status == Submission.Status.IDENTIFYING:
|
if submission.status == Submission.Status.IDENTIFYING:
|
||||||
messages.info(
|
self._notify_guest(submission)
|
||||||
self.request,
|
|
||||||
f"Submission {submission.slug} created. Check your email "
|
|
||||||
f"({submission.guest_email}) for a confirmation link -- you "
|
|
||||||
f"have 24 hours.",
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
messages.success(
|
messages.success(
|
||||||
self.request,
|
self.request,
|
||||||
f"Submission {submission.slug} accepted. We'll start "
|
format_html(
|
||||||
f"validating it shortly.",
|
"Submission <strong class=\"font-mono\">{}</strong> accepted. "
|
||||||
|
"We'll start validating it shortly.",
|
||||||
|
submission.slug,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.object = submission
|
|
||||||
return super().form_valid(form)
|
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
|
# Logs: podman-compose logs -f web
|
||||||
# Tear down: podman-compose down (keeps the pgdata volume)
|
# Tear down: podman-compose down # keeps pgdata + media
|
||||||
# podman-compose down -v (drops the pgdata volume too)
|
# podman-compose down -v # nukes both volumes too
|
||||||
#
|
#
|
||||||
# The `web` service uses a stock python:3.14-slim image and bind-mounts this
|
# `.env` keys you'll want set (see `.env.example` for the full list):
|
||||||
# repo at /app. On every start it installs from requirements.txt plus
|
# SECRET_KEY - long random string
|
||||||
# psycopg[binary] (the pip-cache volume makes subsequent starts fast) and
|
# ALLOWED_HOSTS - e.g. "print.hamlab.lt,localhost"
|
||||||
# runs Django's dev server. There is intentionally no Containerfile yet --
|
# SITE_URL - e.g. "https://print.hamlab.lt" (for emails)
|
||||||
# swap `image:` for `build: .` once Section 10's image lands.
|
# 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:
|
services:
|
||||||
db:
|
db:
|
||||||
image: docker.io/library/postgres:16
|
image: docker.io/library/postgres:16
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${POSTGRES_DB}
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
POSTGRES_USER: ${POSTGRES_USER}
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
@@ -26,30 +45,24 @@ services:
|
|||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 10
|
retries: 10
|
||||||
env_file: .env
|
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: docker.io/library/python:3.14-slim
|
build:
|
||||||
working_dir: /app
|
context: .
|
||||||
command: >
|
dockerfile: Containerfile
|
||||||
bash -c "
|
restart: unless-stopped
|
||||||
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
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
DJANGO_SETTINGS_MODULE: hamprint.settings.dev
|
DJANGO_SETTINGS_MODULE: hamprint.settings.prod
|
||||||
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app:Z
|
- media:/app/media
|
||||||
- pipcache:/root/.cache/pip
|
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
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"
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
# Email --- Mailjet via django-anymail. The env var name MAILJET_API_SECRET is
|
# Email --- Mailtrap HTTPS API via django-anymail, with a console fallback so
|
||||||
# mapped onto anymail's MAILJET_SECRET_KEY so our two env vars stay symmetrical.
|
# a dev without credentials still sees emails in the runserver log instead of
|
||||||
EMAIL_BACKEND = "anymail.backends.mailjet.EmailBackend"
|
# 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 = {
|
ANYMAIL = {
|
||||||
"MAILJET_API_KEY": env("MAILJET_API_KEY", default=""),
|
"MAILTRAP_API_TOKEN": _mailtrap_token,
|
||||||
"MAILJET_SECRET_KEY": env("MAILJET_API_SECRET", default=""),
|
|
||||||
}
|
}
|
||||||
|
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 = env(
|
||||||
"DEFAULT_FROM_EMAIL", default="hamprint <noreply@hamlab.lt>"
|
"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_ALLOWED_TEMPLATE_PACKS = "tailwind"
|
||||||
CRISPY_TEMPLATE_PACK = "tailwind"
|
CRISPY_TEMPLATE_PACK = "tailwind"
|
||||||
|
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ from .base import * # noqa: F401, F403
|
|||||||
DEBUG = True
|
DEBUG = True
|
||||||
ALLOWED_HOSTS = ["localhost", "127.0.0.1", "0.0.0.0"]
|
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 is auto-detected in base.py: Mailtrap if MAILTRAP_API_TOKEN
|
||||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
# 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 = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
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("accounts/", include("allauth.urls")),
|
||||||
path("", include("apps.dashboard.urls")),
|
path("", include("apps.dashboard.urls")),
|
||||||
path("submit/", include("apps.submissions.urls")),
|
path("submit/", include("apps.submissions.urls")),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ idna==3.14
|
|||||||
markdown-it-py==4.2.0
|
markdown-it-py==4.2.0
|
||||||
mdurl==0.1.2
|
mdurl==0.1.2
|
||||||
namesgenerator==0.3
|
namesgenerator==0.3
|
||||||
|
numpy-stl>=3.0
|
||||||
packaging==26.2
|
packaging==26.2
|
||||||
pycparser==3.0
|
pycparser==3.0
|
||||||
Pygments==2.20.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>
|
</nav>
|
||||||
|
|
||||||
<main class="mx-auto max-w-6xl px-4 py-8">
|
<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 %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,11 @@
|
|||||||
|
|
||||||
<div class="mt-6 flex items-center justify-between text-sm">
|
<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 '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>
|
<a href="{% url 'account_logout' %}" class="text-slate-500 hover:underline">Sign out</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% 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