Add proper email notifications
This commit is contained in:
14
apps/accounts/urls.py
Normal file
14
apps/accounts/urls.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Local additions under `/accounts/`. Mounted in `hamprint/urls.py` BEFORE
|
||||
`include("allauth.urls")` so any path defined here wins; everything we
|
||||
don't claim falls through to allauth.
|
||||
"""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = "accounts"
|
||||
|
||||
urlpatterns = [
|
||||
path("close/", views.close_account, name="close"),
|
||||
]
|
||||
@@ -1,3 +1,68 @@
|
||||
from django.shortcuts import render
|
||||
"""Local account views -- the ones allauth doesn't ship.
|
||||
|
||||
# Create your views here.
|
||||
Today: just `close_account` ("permanently delete this user"). Logout is
|
||||
allauth's; we override its template, not its view.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import logout
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db import transaction
|
||||
from django.http import HttpResponseForbidden
|
||||
from django.shortcuts import redirect, render
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.submissions.models import Submission
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def close_account(request):
|
||||
"""Permanently delete the signed-in user and all of their submissions.
|
||||
|
||||
GET -> render a "are you sure?" confirmation page, showing how many of
|
||||
their submissions will go with the user.
|
||||
POST -> delete the rows, log the user out, redirect to the dashboard.
|
||||
|
||||
Refuses staff users (`is_staff=True`): they'd be locking themselves
|
||||
(and possibly the only operator) out of /admin/ with no recourse, so
|
||||
that path requires another operator to remove the row via
|
||||
/admin/auth/user/ instead.
|
||||
|
||||
Why delete the submissions too: `Submission.submitted_by` uses
|
||||
`on_delete=SET_NULL`, but the `CheckConstraint` on the model requires
|
||||
that EITHER `submitted_by` OR `guest_email` is non-null. OAuth-created
|
||||
submissions have `guest_email=NULL`, so SET_NULL would violate the
|
||||
constraint at delete time. Simpler + matches user expectation of
|
||||
"delete my account": wipe the rows wholesale. The `post_delete` signal
|
||||
in `apps/submissions/signals.py` unlinks the uploaded STLs at the
|
||||
same time.
|
||||
"""
|
||||
if request.user.is_staff:
|
||||
return HttpResponseForbidden(
|
||||
"Staff users cannot close their own account from here. "
|
||||
"Ask another operator to remove the row via /admin/auth/user/."
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
user = request.user
|
||||
username = user.get_username()
|
||||
with transaction.atomic():
|
||||
Submission.objects.filter(submitted_by=user).delete()
|
||||
logout(request)
|
||||
user.delete()
|
||||
messages.info(
|
||||
request,
|
||||
f"Account {username} and all of your prints have been "
|
||||
f"permanently deleted.",
|
||||
)
|
||||
return redirect("dashboard:index")
|
||||
|
||||
submission_count = Submission.objects.filter(submitted_by=request.user).count()
|
||||
return render(
|
||||
request,
|
||||
"account/close.html",
|
||||
{"submission_count": submission_count},
|
||||
)
|
||||
|
||||
@@ -7,9 +7,9 @@ app_name = "dashboard"
|
||||
urlpatterns = [
|
||||
path("", views.IndexView.as_view(), name="index"),
|
||||
path("my-prints/", views.MyPrintsView.as_view(), name="my_prints"),
|
||||
path("p/<slug:slug>/confirm/<str:token>/", views.ConfirmEmailView.as_view(), name="confirm"),
|
||||
# Routes to be added as features land (see plan.md Section 7):
|
||||
# path("p/<slug:slug>/", views.SubmissionDetailView.as_view(), name="detail"),
|
||||
# path("p/<slug:slug>/status/", views.SubmissionStatusFragment.as_view(), name="status"),
|
||||
# path("p/<slug:slug>/confirm/<str:token>/", views.ConfirmEmailView.as_view(), name="confirm"),
|
||||
# path("p/<slug:slug>/resend/", views.ResendConfirmationView.as_view(), name="resend"),
|
||||
]
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import secrets
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.db.models import Count, Q
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.utils.html import format_html
|
||||
from django.views import View
|
||||
from django.views.generic import ListView
|
||||
|
||||
from apps.submissions.models import Submission
|
||||
@@ -54,6 +60,76 @@ class IndexView(ListView):
|
||||
return ctx
|
||||
|
||||
|
||||
class ConfirmEmailView(View):
|
||||
"""Email-confirmation landing page (plan.md §7.4 step 8).
|
||||
|
||||
The URL `/p/<slug>/confirm/<token>/` is what the welcome email's button
|
||||
points at. Hitting it does plan.md §7.3's `identifying -> processing`
|
||||
transition (`email_confirmed = True`, `confirmation_token` cleared),
|
||||
then bounces the user back to the dashboard with a green notice. The
|
||||
`processing` worker (plan.md §7.5) picks the row up within ~30 s and
|
||||
moves it to `verifying`, where it becomes visible on the public list.
|
||||
|
||||
Idempotent: hitting the link twice (or after expiry / approval / rejection)
|
||||
doesn't crash -- it just surfaces a neutral "already past the
|
||||
confirmation step" notice and redirects. Constant-time string compare
|
||||
on the token, so a stranger guessing tokens can't sniff prefix matches
|
||||
via response-time differences.
|
||||
"""
|
||||
|
||||
def get(self, request, slug, token):
|
||||
# 404 if the row doesn't exist -- including the case where
|
||||
# cleanup_stale has already nuked an unconfirmed submission.
|
||||
sub = get_object_or_404(Submission, slug=slug)
|
||||
|
||||
if sub.status != Submission.Status.IDENTIFYING:
|
||||
# Already past the confirmation step -- treat as a no-op rather
|
||||
# than an error so refreshing / re-clicking is harmless.
|
||||
messages.info(
|
||||
request,
|
||||
format_html(
|
||||
"Submission <strong class=\"font-mono\">{slug}</strong> is "
|
||||
"already past the confirmation step (current state: "
|
||||
"<em>{status}</em>). Nothing more to do.",
|
||||
slug=sub.slug,
|
||||
status=sub.get_status_display(),
|
||||
),
|
||||
)
|
||||
return redirect("dashboard:index")
|
||||
|
||||
# Constant-time compare on the token to make timing-side-channel
|
||||
# attacks against the 32-byte secret impractical.
|
||||
stored = sub.confirmation_token or ""
|
||||
if not stored or not secrets.compare_digest(stored, token):
|
||||
messages.error(
|
||||
request,
|
||||
format_html(
|
||||
"Couldn't confirm submission <strong class=\"font-mono\">{slug}</strong> — "
|
||||
"the link is invalid or has expired. If you submitted "
|
||||
"this print, please <a href=\"{submit_url}\" class=\"underline font-medium\">"
|
||||
"submit again</a>.",
|
||||
slug=sub.slug,
|
||||
submit_url="/submit/",
|
||||
),
|
||||
)
|
||||
return redirect("dashboard:index")
|
||||
|
||||
sub.status = Submission.Status.PROCESSING
|
||||
sub.email_confirmed = True
|
||||
sub.confirmation_token = ""
|
||||
sub.save()
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
format_html(
|
||||
"Submission <strong class=\"font-mono\">{slug}</strong> confirmed! "
|
||||
"It'll appear on the dashboard shortly, after validation.",
|
||||
slug=sub.slug,
|
||||
),
|
||||
)
|
||||
return redirect("dashboard:index")
|
||||
|
||||
|
||||
class MyPrintsView(LoginRequiredMixin, ListView):
|
||||
"""Private listing -- every submission the signed-in user has ever made.
|
||||
|
||||
|
||||
129
apps/submissions/emails.py
Normal file
129
apps/submissions/emails.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Outgoing email -- plan.md §7 (state-transition side effects).
|
||||
|
||||
Two public functions:
|
||||
|
||||
send_confirmation_email(submission)
|
||||
Guest path: token-link emailed immediately after `SubmitView` creates
|
||||
an `identifying` row. The user must click within 24 h (plan.md §7.6)
|
||||
or the row is cleaned up.
|
||||
|
||||
send_status_update_email(submission, *, previous_status=None)
|
||||
Generic notifier for any state transition the user should know about
|
||||
(queued, rejected, completed, failed). Callers pick when to fire it
|
||||
-- typically operator admin actions and the validation worker.
|
||||
|
||||
Both delegate to Django's email machinery. The backend is wired in
|
||||
`hamprint/settings/base.py`: Mailtrap via `django-anymail` when
|
||||
`MAILTRAP_API_TOKEN` is present, console when not. Send failures are caught
|
||||
+ logged so a flaky transport never blocks the submission flow.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
from .models import Submission
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _recipient(sub: Submission) -> str | None:
|
||||
"""Resolve the email address for a submission.
|
||||
|
||||
OAuth users -> `User.email`; guests -> `guest_email`. Returns None if
|
||||
neither is set, which shouldn't happen thanks to the model's
|
||||
`CheckConstraint` but we keep the belt-and-braces check anyway.
|
||||
"""
|
||||
if sub.submitted_by_id and sub.submitted_by.email:
|
||||
return sub.submitted_by.email
|
||||
return sub.guest_email or None
|
||||
|
||||
|
||||
def _render(name: str, context: dict) -> tuple[str, str, str]:
|
||||
"""Load `templates/emails/{name}.subject.txt` + `.body.txt` + `.body.html`
|
||||
and render each with the same context.
|
||||
|
||||
The subject is stripped of trailing whitespace so a multi-line subject
|
||||
template (which can happen when an author hits Enter at the end) doesn't
|
||||
accidentally include a newline -- which would make the transport API
|
||||
reject the message as malformed.
|
||||
"""
|
||||
subject = render_to_string(f"emails/{name}.subject.txt", context).strip()
|
||||
body_text = render_to_string(f"emails/{name}.body.txt", context)
|
||||
body_html = render_to_string(f"emails/{name}.body.html", context)
|
||||
return subject, body_text, body_html
|
||||
|
||||
|
||||
def _send(name: str, sub: Submission, extra_context: dict) -> bool:
|
||||
"""Common send path. Builds a `multipart/alternative` message with both
|
||||
the plain-text and HTML bodies attached so clients that don't render
|
||||
HTML (or where the user has opted out) still see a readable email.
|
||||
Returns True if the message was handed off to the transport backend
|
||||
successfully, False on any failure (we never propagate)."""
|
||||
to = _recipient(sub)
|
||||
if not to:
|
||||
logger.warning("no recipient for submission %s; skipping %s email", sub.slug, name)
|
||||
return False
|
||||
context = {
|
||||
"submission": sub,
|
||||
"site_url": settings.SITE_URL,
|
||||
**extra_context,
|
||||
}
|
||||
subject, body_text, body_html = _render(name, context)
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=body_text, # text/plain root part
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
to=[to],
|
||||
)
|
||||
msg.attach_alternative(body_html, "text/html") # rich version
|
||||
try:
|
||||
msg.send()
|
||||
except Exception: # broad on purpose: transport-level surprises shouldn't crash callers
|
||||
logger.exception("failed to send %s email for %s", name, sub.slug)
|
||||
return False
|
||||
logger.info("sent %s email for %s to %s", name, sub.slug, to)
|
||||
return True
|
||||
|
||||
|
||||
def send_confirmation_email(sub: Submission) -> bool:
|
||||
"""Send the email-confirmation link to a guest submitter.
|
||||
|
||||
The URL is built by string concatenation rather than `reverse()` because
|
||||
`dashboard:confirm` is currently scaffolded as a commented stub in
|
||||
`apps/dashboard/urls.py` (plan.md §7). Once that route is wired,
|
||||
switching to `reverse()` is a one-line change.
|
||||
"""
|
||||
confirm_url = (
|
||||
f"{settings.SITE_URL}/p/{sub.slug}/confirm/{sub.confirmation_token}/"
|
||||
)
|
||||
return _send("confirmation", sub, {"confirm_url": confirm_url})
|
||||
|
||||
|
||||
def send_status_update_email(
|
||||
sub: Submission, *, previous_status: str | None = None
|
||||
) -> bool:
|
||||
"""Notify the submitter that their submission moved to a new state.
|
||||
|
||||
Pass `previous_status` so the email can render "was X, now Y" when
|
||||
useful; omit it for first-time-ever notifications.
|
||||
"""
|
||||
detail_url = f"{settings.SITE_URL}/p/{sub.slug}/"
|
||||
return _send(
|
||||
"status_update",
|
||||
sub,
|
||||
{"detail_url": detail_url, "previous_status": previous_status},
|
||||
)
|
||||
|
||||
|
||||
def send_verifying_email(sub: Submission) -> bool:
|
||||
"""Notify the submitter that auto-validation passed (plan.md §7.3
|
||||
`processing -> verifying` transition). The print is now queued for
|
||||
a manual operator review; if the operator approves, the next email
|
||||
will be the queued / printing one."""
|
||||
detail_url = f"{settings.SITE_URL}/p/{sub.slug}/"
|
||||
return _send("verifying", sub, {"detail_url": detail_url})
|
||||
80
apps/submissions/management/commands/process_submissions.py
Normal file
80
apps/submissions/management/commands/process_submissions.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""`python manage.py process_submissions` -- implements plan.md §7.5.
|
||||
|
||||
Drains one batch of submissions stuck in `processing`: validates each row's
|
||||
STL (uploads) or URL (printables / makerworld / thingiverse) and transitions
|
||||
to `verifying` on success or `rejected` on failure. Runs on a 30-second
|
||||
loop from the `web` container's entrypoint (plan.md §10) -- one invocation
|
||||
of this command per tick.
|
||||
|
||||
Concurrency: `select_for_update(skip_locked=True)` keeps replicas / stray
|
||||
cron ticks / a re-entrant restart from grabbing the same row. On SQLite the
|
||||
locks are no-ops (dev only); Postgres in prod gets the non-blocking lock
|
||||
semantics the design assumes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.submissions.emails import send_status_update_email, send_verifying_email
|
||||
from apps.submissions.models import Submission
|
||||
from apps.submissions.validation import (
|
||||
ValidationError,
|
||||
validate_external_url,
|
||||
validate_stl_file,
|
||||
)
|
||||
|
||||
BATCH = 50
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = (
|
||||
"Drain submissions stuck in `processing` -- one batch per invocation. "
|
||||
"Designed to be called on a loop from the web container entrypoint; "
|
||||
"safe to also call ad-hoc for debugging."
|
||||
)
|
||||
|
||||
def add_arguments(self, parser) -> None:
|
||||
parser.add_argument(
|
||||
"--batch",
|
||||
type=int,
|
||||
default=BATCH,
|
||||
help=f"Max rows to process per invocation (default: {BATCH}).",
|
||||
)
|
||||
|
||||
def handle(self, *args, batch: int, **opts) -> None:
|
||||
with transaction.atomic():
|
||||
# `list(...)` materialises the slice inside the transaction so
|
||||
# subsequent `.save()` calls don't invalidate the queryset.
|
||||
queue = list(
|
||||
Submission.objects
|
||||
.select_for_update(skip_locked=True)
|
||||
.filter(status=Submission.Status.PROCESSING)
|
||||
.order_by("updated_at")[:batch]
|
||||
)
|
||||
|
||||
if not queue:
|
||||
return
|
||||
|
||||
for sub in queue:
|
||||
try:
|
||||
if sub.source_type == Submission.SourceType.UPLOAD:
|
||||
validate_stl_file(sub.stl_file.path)
|
||||
else:
|
||||
validate_external_url(sub.source_url, sub.source_type)
|
||||
except ValidationError as exc:
|
||||
sub.status = Submission.Status.REJECTED
|
||||
sub.operator_notes = f"Automatic rejection: {exc}"
|
||||
sub.closed_at = timezone.now()
|
||||
# closed_by stays NULL -- the validator did the rejecting,
|
||||
# not an operator (plan.md §5 / §7.3).
|
||||
sub.save()
|
||||
send_status_update_email(sub, previous_status="processing")
|
||||
self.stdout.write(f"rejected {sub.slug}: {exc}")
|
||||
else:
|
||||
sub.status = Submission.Status.VERIFYING
|
||||
sub.save()
|
||||
send_verifying_email(sub)
|
||||
self.stdout.write(f"verifying {sub.slug}")
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import namesgenerator
|
||||
from django.conf import settings
|
||||
from django.core.validators import FileExtensionValidator
|
||||
from django.db import models
|
||||
@@ -178,6 +179,29 @@ class Submission(models.Model):
|
||||
# dashboard index so post-submit redirects always land somewhere real.
|
||||
return reverse("dashboard:index")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Auto-generate `slug` on first save so any creation path -- admin,
|
||||
`SubmitView`, fixtures, `objects.create()` -- gets a Docker-style
|
||||
codename without callers having to remember to set one."""
|
||||
if not self.slug:
|
||||
self.slug = self._generate_unique_slug()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def _generate_unique_slug(cls, max_attempts: int = 16) -> str:
|
||||
"""`namesgenerator.get_random_name` + collision retries in Python
|
||||
(rather than a DB-side loop) so the rare collision path stays
|
||||
observable. If the surrounding transaction still loses a race with
|
||||
a concurrent insert, the DB's `unique=True` constraint fires an
|
||||
`IntegrityError` and the caller can retry."""
|
||||
for _ in range(max_attempts):
|
||||
candidate = namesgenerator.get_random_name()
|
||||
if not cls.objects.filter(slug=candidate).exists():
|
||||
return candidate
|
||||
# Fall through: extremely unlikely with the size of namesgenerator's
|
||||
# adjective/surname space.
|
||||
return namesgenerator.get_random_name()
|
||||
|
||||
# --- presentation helpers (consumed by dashboard templates) -------------
|
||||
|
||||
SOURCE_LABEL = {
|
||||
@@ -201,6 +225,20 @@ class Submission(models.Model):
|
||||
Status.FAILED: "bg-red-100 text-red-800",
|
||||
}
|
||||
|
||||
# Same palette as STATUS_BADGE_CLASS but expressed as hex pairs because
|
||||
# email clients strip <style> blocks and don't load external CSS, so the
|
||||
# HTML email templates use inline `background-color` / `color`.
|
||||
STATUS_EMAIL_COLORS = {
|
||||
Status.IDENTIFYING: {"bg": "#fef3c7", "fg": "#92400e"}, # amber
|
||||
Status.PROCESSING: {"bg": "#f1f5f9", "fg": "#334155"}, # slate
|
||||
Status.VERIFYING: {"bg": "#ede9fe", "fg": "#5b21b6"}, # violet
|
||||
Status.QUEUED: {"bg": "#dbeafe", "fg": "#1e40af"}, # blue
|
||||
Status.PRINTING: {"bg": "#ffedd5", "fg": "#9a3412"}, # orange
|
||||
Status.COMPLETED: {"bg": "#d1fae5", "fg": "#065f46"}, # emerald
|
||||
Status.REJECTED: {"bg": "#fee2e2", "fg": "#991b1b"}, # red
|
||||
Status.FAILED: {"bg": "#fee2e2", "fg": "#991b1b"}, # red
|
||||
}
|
||||
|
||||
@property
|
||||
def source_label(self) -> str:
|
||||
return self.SOURCE_LABEL.get(self.source_type, self.source_type)
|
||||
@@ -208,3 +246,9 @@ class Submission(models.Model):
|
||||
@property
|
||||
def status_badge_class(self) -> str:
|
||||
return self.STATUS_BADGE_CLASS.get(self.status, "bg-slate-100 text-slate-700")
|
||||
|
||||
@property
|
||||
def status_email_style(self) -> dict:
|
||||
return self.STATUS_EMAIL_COLORS.get(
|
||||
self.status, {"bg": "#f1f5f9", "fg": "#334155"}
|
||||
)
|
||||
|
||||
190
apps/submissions/validation.py
Normal file
190
apps/submissions/validation.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""STL + URL validation for the `processing` worker (plan.md §7.5.2 / §7.5.3).
|
||||
|
||||
Two public callables:
|
||||
|
||||
validate_stl_file(path)
|
||||
Magic-byte probe + numpy-stl mesh load + bounding-box vs build plate +
|
||||
triangle-count sanity. Raises `ValidationError` with the user-visible
|
||||
reason on any rejection.
|
||||
|
||||
validate_external_url(url, source_type)
|
||||
Host allow-list re-check + HEAD reachability (fallback to GET with a
|
||||
Range header on 405). Raises `ValidationError` likewise.
|
||||
|
||||
`numpy-stl` is imported lazily so this module can be imported on a host venv
|
||||
that hasn't run `pip install -r requirements.txt` yet -- the failure only
|
||||
fires when an STL is actually validated.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
from .models import Submission
|
||||
|
||||
|
||||
class ValidationError(Exception):
|
||||
"""Raised when a Submission fails its validation pass. The message
|
||||
becomes the operator-visible (and user-visible) rejection reason."""
|
||||
|
||||
|
||||
# Host allow-list per source_type. Mirrors `apps.submissions.forms`
|
||||
# deliberately rather than DRYing through a shared import: the
|
||||
# `processing` worker shouldn't be coupled to the form layer.
|
||||
_URL_HOSTS: dict[str, set[str]] = {
|
||||
Submission.SourceType.PRINTABLES: {"printables.com", "www.printables.com"},
|
||||
Submission.SourceType.MAKERWORLD: {"makerworld.com", "www.makerworld.com"},
|
||||
Submission.SourceType.THINGIVERSE: {"thingiverse.com", "www.thingiverse.com"},
|
||||
}
|
||||
|
||||
_MIN_AXIS_MM = 5.0
|
||||
_MIN_TRIANGLES = 4
|
||||
_MAX_TRIANGLES = 5_000_000
|
||||
|
||||
# Be a polite citizen so anti-bot defenses on the model platforms don't 403 us.
|
||||
_HTTP_HEADERS = {
|
||||
"User-Agent": "hamprint-validator/1.0 (+https://hamlab.lt)",
|
||||
}
|
||||
|
||||
|
||||
def _build_volume() -> tuple[float, float, float]:
|
||||
"""Read `PRINTER_BUILD_VOLUME_MM` (env-driven, default 235,235,250)."""
|
||||
raw = getattr(settings, "PRINTER_BUILD_VOLUME_MM", "235,235,250")
|
||||
parts = [float(x) for x in raw.split(",")]
|
||||
if len(parts) != 3:
|
||||
raise RuntimeError(
|
||||
f"PRINTER_BUILD_VOLUME_MM must be 'x,y,z'; got: {raw!r}"
|
||||
)
|
||||
return parts[0], parts[1], parts[2]
|
||||
|
||||
|
||||
def _fmt(value: float) -> str:
|
||||
"""Render `235.0` as `235`, `235.5` as `235.5`. Just for clean error text."""
|
||||
return str(int(value)) if value.is_integer() else f"{value:g}"
|
||||
|
||||
|
||||
# ---- STL ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def validate_stl_file(path: str) -> None:
|
||||
"""Four-pass check; raises `ValidationError` on first failure."""
|
||||
p = Path(path)
|
||||
if not p.is_file():
|
||||
raise ValidationError("STL file is missing on disk")
|
||||
|
||||
size = p.stat().st_size
|
||||
|
||||
# 1. Magic-byte / format probe.
|
||||
with p.open("rb") as f:
|
||||
head_80 = f.read(80)
|
||||
if head_80.startswith(b"solid "):
|
||||
# Could be ASCII -- but some binary STLs also start with "solid ".
|
||||
# Confirm by looking for an ASCII facet marker in the first 4 KB.
|
||||
with p.open("rb") as f:
|
||||
first_4k = f.read(4096)
|
||||
if b"facet normal" not in first_4k:
|
||||
raise ValidationError(
|
||||
"file is not a valid STL: header / size mismatch"
|
||||
)
|
||||
# ASCII STL accepted; the mesh load below catches anything subtler.
|
||||
else:
|
||||
# Binary STL: 80-byte header + uint32 triangle count + 50 B per tri.
|
||||
with p.open("rb") as f:
|
||||
f.seek(80)
|
||||
tri_bytes = f.read(4)
|
||||
if len(tri_bytes) != 4:
|
||||
raise ValidationError(
|
||||
"file is not a valid STL: header / size mismatch"
|
||||
)
|
||||
triangle_count = struct.unpack("<I", tri_bytes)[0]
|
||||
if size != 84 + 50 * triangle_count:
|
||||
raise ValidationError(
|
||||
"file is not a valid STL: header / size mismatch"
|
||||
)
|
||||
|
||||
# 2. Mesh load. Imported lazily so the module imports without numpy-stl.
|
||||
try:
|
||||
from stl import mesh # type: ignore[import-not-found]
|
||||
except ImportError as exc: # pragma: no cover -- only hit on host venv
|
||||
raise ValidationError(
|
||||
"numpy-stl is not installed in this environment"
|
||||
) from exc
|
||||
try:
|
||||
m = mesh.Mesh.from_file(str(p))
|
||||
except Exception: # numpy-stl raises a variety of types; broad on purpose
|
||||
raise ValidationError("STL could not be parsed")
|
||||
|
||||
# 3. Bounding-box check. mesh.vectors has shape (N, 3, 3).
|
||||
vertices = m.vectors.reshape(-1, 3)
|
||||
mins = vertices.min(axis=0)
|
||||
maxs = vertices.max(axis=0)
|
||||
extents = [float(maxs[i] - mins[i]) for i in range(3)]
|
||||
bv_x, bv_y, bv_z = _build_volume()
|
||||
|
||||
if extents[0] > bv_x or extents[1] > bv_y or extents[2] > bv_z:
|
||||
raise ValidationError(
|
||||
f"part is {_fmt(extents[0])}x{_fmt(extents[1])}x{_fmt(extents[2])} mm; "
|
||||
f"doesn't fit on our {_fmt(bv_x)}x{_fmt(bv_y)}x{_fmt(bv_z)} build plate"
|
||||
)
|
||||
if max(extents) < _MIN_AXIS_MM:
|
||||
raise ValidationError(
|
||||
f"part is too small to print reliably "
|
||||
f"(under {_fmt(_MIN_AXIS_MM)} mm on every axis)"
|
||||
)
|
||||
|
||||
# 4. Triangle-count sanity.
|
||||
tri_count = int(len(m.vectors))
|
||||
if tri_count < _MIN_TRIANGLES:
|
||||
raise ValidationError(
|
||||
f"mesh is degenerate (fewer than {_MIN_TRIANGLES} triangles)"
|
||||
)
|
||||
if tri_count > _MAX_TRIANGLES:
|
||||
raise ValidationError(f"mesh is too dense ({tri_count:,} triangles)")
|
||||
|
||||
|
||||
# ---- URL ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def validate_external_url(source_url: str, source_type: str) -> None:
|
||||
"""Host check + HEAD reachability; raises `ValidationError` on rejection."""
|
||||
if not source_url:
|
||||
raise ValidationError("URL is empty")
|
||||
|
||||
# 1. Host re-check. We already did this at form time but the row may have
|
||||
# been edited via the admin since.
|
||||
host = (urlparse(source_url).hostname or "").lower()
|
||||
allowed = _URL_HOSTS.get(source_type, set())
|
||||
if host not in allowed:
|
||||
raise ValidationError("URL host doesn't match source type")
|
||||
|
||||
# 2. HEAD reachability.
|
||||
try:
|
||||
r = requests.head(
|
||||
source_url,
|
||||
timeout=5,
|
||||
allow_redirects=True,
|
||||
headers=_HTTP_HEADERS,
|
||||
)
|
||||
# Some CDNs refuse HEAD with 405; fall back to a tiny ranged GET.
|
||||
if r.status_code == 405:
|
||||
r = requests.get(
|
||||
source_url,
|
||||
timeout=5,
|
||||
allow_redirects=True,
|
||||
headers={**_HTTP_HEADERS, "Range": "bytes=0-1023"},
|
||||
)
|
||||
except requests.exceptions.RequestException as exc:
|
||||
raise ValidationError(
|
||||
f"URL unreachable ({exc.__class__.__name__})"
|
||||
) from exc
|
||||
|
||||
if r.status_code >= 400:
|
||||
raise ValidationError(
|
||||
f"URL returned HTTP {r.status_code}; the model may have been "
|
||||
f"removed or set to private"
|
||||
)
|
||||
@@ -1,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> — "
|
||||
"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"),
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user