Compare commits

10 Commits

Author SHA1 Message Date
05ac0057a6 Pickup dirty status field 2026-05-15 00:22:04 +03:00
15dc6147dd Add detail page 2026-05-15 00:08:14 +03:00
219f0a5259 Remove generic status update email template 2026-05-15 00:07:55 +03:00
46fc07a1ae Add better email verification 2026-05-14 23:49:54 +03:00
569d57e144 Stop tracking .pyc / __pycache__ 2026-05-14 23:26:18 +03:00
9e16b78793 Add proper email notifications 2026-05-14 23:19:50 +03:00
fe62575790 Add signup form 2026-05-12 21:10:54 +03:00
6f19d10426 Update demo submit view 2026-05-12 19:35:26 +03:00
0fdb8b8a02 App v0.1 2026-05-12 19:35:15 +03:00
c451a106a1 Add tailwind cli 2026-05-12 18:00:29 +03:00
54 changed files with 3569 additions and 78 deletions

50
.dockerignore Normal file
View File

@@ -0,0 +1,50 @@
# Keep the image lean and reproducible: don't ship anything we'd regenerate
# inside the container anyway, and never ship host-only / secret state.
# Host venv -- container has its own Python install.
.venv/
# VCS metadata.
.git/
.gitignore
.gitattributes
# Local databases / dev artefacts.
*.sqlite3
db.sqlite3*
__pycache__/
*.py[cod]
*.swp
*.log
*.tmp
# Secrets / env files: container reads .env via --env-file or compose env_file:.
.env
.env.local
# Tailwind binary cache: re-downloaded by `manage.py tailwind build` during
# image build, then removed in the same layer. Host copy might be a different
# arch / version, so always skip it.
.django_tailwind_cli/
# Output of `collectstatic` -- the Containerfile runs this fresh at build time.
staticfiles/
# Documentation, design assets, and prototype HTML that operators don't need
# at runtime.
demo/
plan.md
plan.pdf
plan.html
CLAUDE.md
CONTRIBUTING.md
README.md
.claude/
# IDE / cache directories.
.vscode/
.idea/
.pytest_cache/
.mypy_cache/
.ruff_cache/
node_modules/

View File

@@ -9,6 +9,11 @@ SECRET_KEY=replace-me-with-a-long-random-string
DEBUG=true 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/).

43
.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# Local data + secrets
media/
*.sqlite*
.env
# Python bytecode / build artefacts -- never commit these.
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
dist/
*.egg-info/
.installed.cfg
*.egg
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
.coverage.*
htmlcov/
# Virtualenv
.venv/
venv/
env/
# Django collectstatic output (regenerated by the Containerfile)
staticfiles/
# Tailwind CLI binary + auto-generated source.css (re-downloaded by build)
.django_tailwind_cli/
# IDE / editor cruft
.vscode/
.idea/
*.swp
*.swo
.DS_Store
# Logs
*.log

62
Containerfile Normal file
View File

@@ -0,0 +1,62 @@
# hamprint -- single-container image (plan.md §10).
#
# Runs Gunicorn plus the two periodic jobs (process_submissions every 30 s,
# cleanup_stale every 5 min) in the same process group. No sidecars.
#
# Build: podman build -t hamprint:latest .
# Run: podman run --rm -p 8000:8000 --env-file .env hamprint:latest
#
# In production the host typically mounts a volume at /app/media so uploaded
# STLs survive container restarts; the database connection comes from
# DATABASE_URL (Postgres in prod, SQLite if you really want).
FROM docker.io/library/python:3.14-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1 \
DJANGO_SETTINGS_MODULE=hamprint.settings.prod
WORKDIR /app
# System packages python:3.14-slim doesn't ship:
# tini -- PID 1 for clean signal forwarding to gunicorn + the loops
# libgomp1 -- numpy/numpy-stl runtime on some kernels
# curl -- handy for healthchecks if compose.yaml grows one
RUN apt-get update \
&& apt-get install -y --no-install-recommends tini curl libgomp1 \
&& rm -rf /var/lib/apt/lists/*
# Python deps as their own layer so app-code edits don't invalidate the wheel
# cache. psycopg[binary] is added explicitly because requirements.txt is
# kept Postgres-driver-agnostic for the host-venv (SQLite) path.
COPY requirements.txt .
RUN pip install -r requirements.txt 'psycopg[binary]'
# Application code (.dockerignore excludes .venv, .git, db.sqlite3, demo/, etc).
COPY . .
# Build Tailwind CSS, gather everything under STATIC_ROOT for WhiteNoise, then
# drop the ~120 MB Tailwind CLI download so it doesn't ride along. The source
# .css lives at assets/tailwind.source.css (per TAILWIND_CLI_SRC_CSS in
# settings/base.py); only the CLI binary cache is purged.
RUN python manage.py tailwind build --force \
&& python manage.py collectstatic --noinput \
&& rm -rf /app/.django_tailwind_cli
# Default writable dirs. Mount a volume at /app/media in prod for persistence.
RUN mkdir -p /app/media /app/staticfiles
# Drop privileges. uid 1000 maps cleanly to the typical host user in rootless
# podman, so a bind-mounted media volume stays writable without extra fuss.
RUN useradd -m -u 1000 app \
&& chown -R app:app /app
USER app
EXPOSE 8000
# tini reaps zombies + forwards SIGTERM to all children, so when the orchestrator
# stops the container both Gunicorn AND the two background loops get the signal.
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["bash", "entrypoint.sh"]

14
apps/accounts/urls.py Normal file
View File

@@ -0,0 +1,14 @@
"""Local additions under `/accounts/`. Mounted in `hamprint/urls.py` BEFORE
`include("allauth.urls")` so any path defined here wins; everything we
don't claim falls through to allauth.
"""
from django.urls import path
from . import views
app_name = "accounts"
urlpatterns = [
path("close/", views.close_account, name="close"),
]

View File

@@ -1,3 +1,68 @@
from django.shortcuts import render """Local account views -- the ones allauth doesn't ship.
# Create your views here. Today: just `close_account` ("permanently delete this user"). Logout is
allauth's; we override its template, not its view.
"""
from __future__ import annotations
from django.contrib import messages
from django.contrib.auth import logout
from django.contrib.auth.decorators import login_required
from django.db import transaction
from django.http import HttpResponseForbidden
from django.shortcuts import redirect, render
from django.views.decorators.http import require_http_methods
from apps.submissions.models import Submission
@login_required
@require_http_methods(["GET", "POST"])
def close_account(request):
"""Permanently delete the signed-in user and all of their submissions.
GET -> render a "are you sure?" confirmation page, showing how many of
their submissions will go with the user.
POST -> delete the rows, log the user out, redirect to the dashboard.
Refuses staff users (`is_staff=True`): they'd be locking themselves
(and possibly the only operator) out of /admin/ with no recourse, so
that path requires another operator to remove the row via
/admin/auth/user/ instead.
Why delete the submissions too: `Submission.submitted_by` uses
`on_delete=SET_NULL`, but the `CheckConstraint` on the model requires
that EITHER `submitted_by` OR `guest_email` is non-null. OAuth-created
submissions have `guest_email=NULL`, so SET_NULL would violate the
constraint at delete time. Simpler + matches user expectation of
"delete my account": wipe the rows wholesale. The `post_delete` signal
in `apps/submissions/signals.py` unlinks the uploaded STLs at the
same time.
"""
if request.user.is_staff:
return HttpResponseForbidden(
"Staff users cannot close their own account from here. "
"Ask another operator to remove the row via /admin/auth/user/."
)
if request.method == "POST":
user = request.user
username = user.get_username()
with transaction.atomic():
Submission.objects.filter(submitted_by=user).delete()
logout(request)
user.delete()
messages.info(
request,
f"Account {username} and all of your prints have been "
f"permanently deleted.",
)
return redirect("dashboard:index")
submission_count = Submission.objects.filter(submitted_by=request.user).count()
return render(
request,
"account/close.html",
{"submission_count": submission_count},
)

15
apps/dashboard/urls.py Normal file
View File

@@ -0,0 +1,15 @@
from django.urls import path
from . import views
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"),
path("p/<slug:slug>/", views.SubmissionDetailView.as_view(), name="detail"),
# Routes to be added as features land (see plan.md Section 7):
# path("p/<slug:slug>/status/", views.SubmissionStatusFragment.as_view(), name="status"),
# path("p/<slug:slug>/resend/", views.ResendConfirmationView.as_view(), name="resend"),
]

View File

@@ -1,3 +1,213 @@
from django.shortcuts import render import secrets
# Create your views here. 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 DetailView, ListView
from apps.submissions.models import Submission, VerifiedEmail
class IndexView(ListView):
"""Public dashboard (plan.md §8).
Lists every submission whose status is one of the four dashboard-visible
states -- `verifying`, `queued`, `printing`, `completed`. Anything in
`identifying`, `processing`, `rejected`, or `failed` is excluded from
the listing (still reachable by direct slug URL by the submitter).
Status-chip filtering via `?status=<value>`; only the four dashboard-
visible values are honoured. Anything else falls back to the unfiltered
list, so the chips stay safe even if someone hand-edits the URL.
"""
model = Submission
template_name = "dashboard/index.html"
context_object_name = "submissions"
paginate_by = 20
def _requested_status(self) -> str:
raw = self.request.GET.get("status", "")
allowed = {str(s) for s in Submission.DASHBOARD_VISIBLE_STATUSES}
return raw if raw in allowed else ""
def get_queryset(self):
qs = Submission.objects.filter(
status__in=Submission.DASHBOARD_VISIBLE_STATUSES
).select_related("requested_filament")
status = self._requested_status()
if status:
qs = qs.filter(status=status)
return qs
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
# One conditional-aggregate for the chip counts, scoped to the same
# dashboard-visible filter so `total` matches what "All" would list.
ctx["counts"] = Submission.objects.filter(
status__in=Submission.DASHBOARD_VISIBLE_STATUSES
).aggregate(
total=Count("id"),
verifying=Count("id", filter=Q(status=Submission.Status.VERIFYING)),
queued=Count("id", filter=Q(status=Submission.Status.QUEUED)),
printing=Count("id", filter=Q(status=Submission.Status.PRINTING)),
completed=Count("id", filter=Q(status=Submission.Status.COMPLETED)),
)
ctx["active_status"] = self._requested_status()
return ctx
class SubmissionDetailView(DetailView):
"""Per-submission detail page at `/p/<slug>/` (plan.md §7.4 step 7).
Two access tiers, gated on whether the viewer owns the row:
- **Anyone with the slug** sees a minimal card -- slug, status badge,
age. That's it. Keeps the URL safe to share, gives anonymous /
non-owner visitors enough to confirm they're looking at the right
submission without leaking source URLs, uploaded filenames,
operator notes, or the submitter's notes-to-operator.
- **Logged-in owner only** (`submission.submitted_by == request.user`)
sees the full demo/detail-completed.html layout: status banner,
source card, operator notes, the user's own notes_for_op, details
sidebar with submitter / created / closed timestamps.
Guests never see the owner view -- they aren't logged in by
definition, so condition (1) fails. They can still navigate to /p/X/
via the URL we email them, they just get the minimal card. Once
operator admin actions get wired, we'll grow a `?token=` fast-path
for guests to view their own row, but that's plan.md §7.6 territory.
"""
model = Submission
template_name = "dashboard/detail.html"
context_object_name = "submission"
slug_url_kwarg = "slug"
slug_field = "slug"
def get_queryset(self):
return super().get_queryset().select_related(
"submitted_by", "requested_filament", "closed_by"
)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
sub: Submission = self.object
ctx["is_owner"] = (
self.request.user.is_authenticated
and sub.submitted_by_id is not None
and sub.submitted_by_id == self.request.user.id
)
return ctx
class ConfirmEmailView(View):
"""Email-confirmation landing page (plan.md §7.4 step 8).
The URL `/p/<slug>/confirm/<token>/` is what the welcome email's button
points at. Hitting it does plan.md §7.3's `identifying -> processing`
transition (`email_confirmed = True`, `confirmation_token` cleared),
then bounces the user back to the dashboard with a green notice. The
`processing` worker (plan.md §7.5) picks the row up within ~30 s and
moves it to `verifying`, where it becomes visible on the public list.
Idempotent: hitting the link twice (or after expiry / approval / rejection)
doesn't crash -- it just surfaces a neutral "already past the
confirmation step" notice and redirects. Constant-time string compare
on the token, so a stranger guessing tokens can't sniff prefix matches
via response-time differences.
"""
def get(self, request, slug, token):
# 404 if the row doesn't exist -- including the case where
# cleanup_stale has already nuked an unconfirmed submission.
sub = get_object_or_404(Submission, slug=slug)
if sub.status != Submission.Status.IDENTIFYING:
# Already past the confirmation step -- treat as a no-op rather
# than an error so refreshing / re-clicking is harmless.
messages.info(
request,
format_html(
"Submission <strong class=\"font-mono\">{slug}</strong> is "
"already past the confirmation step (current state: "
"<em>{status}</em>). Nothing more to do.",
slug=sub.slug,
status=sub.get_status_display(),
),
)
return redirect("dashboard:index")
# Constant-time compare on the token to make timing-side-channel
# attacks against the 32-byte secret impractical.
stored = sub.confirmation_token or ""
if not stored or not secrets.compare_digest(stored, token):
messages.error(
request,
format_html(
"Couldn't confirm submission <strong class=\"font-mono\">{slug}</strong> &mdash; "
"the link is invalid or has expired. If you submitted "
"this print, please <a href=\"{submit_url}\" class=\"underline font-medium\">"
"submit again</a>.",
slug=sub.slug,
submit_url="/submit/",
),
)
return redirect("dashboard:index")
sub.status = Submission.Status.PROCESSING
sub.email_confirmed = True
sub.confirmation_token = ""
sub.save()
# Refresh / record the trust-list entry for this email so subsequent
# guest submissions from the same address (or any +tag variant of
# it) skip the `identifying` step for the next 30 days (plan.md §6).
if sub.guest_email:
VerifiedEmail.record_verification(sub.guest_email)
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.
Anonymous users are bounced to allauth's `account_login` (the default
`LoginRequiredMixin.login_url`, configured via `LOGIN_URL`). Guests don't
have a `submitted_by`, so they have nothing to list here anyway.
"""
model = Submission
template_name = "dashboard/my_prints.html"
context_object_name = "submissions"
paginate_by = 50
def get_queryset(self):
# Ordering inherited from `Submission.Meta` (-created_at).
return Submission.objects.filter(
submitted_by=self.request.user
).select_related("requested_filament")
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
# Counts for the summary line in the page header. A single
# conditional-aggregate query beats N separate `.count()` calls.
agg = Submission.objects.filter(submitted_by=self.request.user).aggregate(
total=Count("id"),
queued=Count("id", filter=Q(status=Submission.Status.QUEUED)),
printing=Count("id", filter=Q(status=Submission.Status.PRINTING)),
completed=Count("id", filter=Q(status=Submission.Status.COMPLETED)),
)
ctx["counts"] = agg
return ctx

View File

@@ -1,3 +1,101 @@
from django.contrib import admin from django.contrib import admin
from django.utils.html import format_html
# Register your models here. from .models import Filament, Submission, VerifiedEmail
@admin.register(VerifiedEmail)
class VerifiedEmailAdmin(admin.ModelAdmin):
list_display = ("email", "validated_at")
search_fields = ("email",)
readonly_fields = ("validated_at",)
ordering = ("-validated_at",)
@admin.register(Filament)
class FilamentAdmin(admin.ModelAdmin):
list_display = (
"display_label",
"material",
"color_name",
"swatch",
"is_available",
"sort_order",
"notes",
)
list_editable = ("is_available", "sort_order", "notes")
list_filter = ("material", "is_available")
search_fields = ("color_name", "material", "notes")
readonly_fields = ("id", "created_at", "updated_at")
ordering = ("sort_order", "color_name")
@admin.display(description="Swatch")
def swatch(self, obj: Filament) -> str:
if not obj.swatch_hex:
return ""
return format_html(
'<span title="{0}" style="display:inline-block;width:1.25rem;height:1.25rem;border-radius:0.25rem;border:1px solid #cbd5e1;background:{0};"></span>',
obj.swatch_hex,
)
@admin.register(Submission)
class SubmissionAdmin(admin.ModelAdmin):
list_display = (
"slug",
"status",
"submitter_display",
"source_type",
"requested_filament",
"created_at",
"closed_by",
)
list_filter = ("status", "source_type", "requested_filament")
search_fields = (
"slug",
"guest_email",
"submitted_by__username",
"submitted_by__email",
)
date_hierarchy = "created_at"
ordering = ("-created_at",)
autocomplete_fields = ("requested_filament",)
readonly_fields = (
"id",
"slug",
"confirmation_token",
"confirmation_sent_at",
"email_confirmed",
"created_at",
"updated_at",
"closed_at",
)
fieldsets = (
("Identity", {
"fields": ("slug", "id", "submitted_by", "guest_email"),
}),
("Source", {
"fields": ("source_type", "stl_file", "source_url", "requested_filament"),
}),
("User-provided", {
"fields": ("notes_for_op",),
"description": "Private notes from the submitter -- never shown publicly.",
}),
("Status & operator", {
"fields": ("status", "operator_notes", "closed_by", "closed_at"),
}),
("Confirmation (guest path)", {
"fields": ("email_confirmed", "confirmation_token", "confirmation_sent_at"),
"classes": ("collapse",),
}),
("Timestamps", {
"fields": ("created_at", "updated_at"),
"classes": ("collapse",),
}),
)
@admin.display(description="Submitter", ordering="submitted_by")
def submitter_display(self, obj: Submission) -> str:
if obj.submitted_by_id:
return str(obj.submitted_by)
return f"guest <{obj.guest_email}>" if obj.guest_email else "guest"

View File

@@ -3,3 +3,9 @@ from django.apps import AppConfig
class SubmissionsConfig(AppConfig): class SubmissionsConfig(AppConfig):
name = "apps.submissions" name = "apps.submissions"
def ready(self) -> None:
# Imports the module for its `@receiver` side-effect (registers
# `unlink_stl_file_on_delete` with the `post_delete` signal).
# See `apps/submissions/signals.py` and plan.md §7.6.
from . import signals # noqa: F401

148
apps/submissions/emails.py Normal file
View File

@@ -0,0 +1,148 @@
"""Outgoing email -- plan.md §7 (state-transition side effects).
Three public functions, one per dedicated template:
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. Template: `emails/confirmation.*`.
send_verifying_email(submission)
Fired by the `process_submissions` worker on the `processing ->
verifying` success branch: "auto-checks cleared, awaiting operator
review". Template: `emails/verifying.*`.
send_rejection_email(submission, *, previous_status=None)
Always fired on any transition into `status = rejected`, whether
auto (validator failure from `processing`) or operator-driven (admin
"Reject" action from `verifying`). The body renders
`submission.operator_notes` verbatim so the user sees the same
rejection reason in the email as on the public detail page.
Template: `emails/rejected.*`.
All three 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_rejection_email(
sub: Submission, *, previous_status: str | None = None
) -> bool:
"""Notify the submitter that their submission was rejected.
Always fired on any transition into `status = rejected`:
- automatic rejection from `processing` (URL/STL validation failure),
- operator rejection from `verifying` (admin "Reject" action).
The email body renders `submission.operator_notes` verbatim -- that's
the same string the auto-validator writes ("Automatic rejection: …")
or that an operator types when clicking "Reject" in admin, so the
user sees one consistent reason across the email + the public detail
page.
`previous_status` is the state we left to land in `rejected`. Useful
so the email can subtly distinguish "rejected before a human even
looked" (from `processing`) vs. "rejected after operator review"
(from `verifying`); both render with the same template.
"""
return _send(
"rejected",
sub,
{"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})

175
apps/submissions/forms.py Normal file
View File

@@ -0,0 +1,175 @@
"""SubmissionForm -- the public submit form (plan.md §5 + demo/submit.html).
Mixes crispy-forms (FormHelper for layout consistency) with manually rendered
radio cards in the template, because the four source-type tabs need
`peer-checked/<name>:` Tailwind classes that crispy can't emit by itself.
"""
from __future__ import annotations
from urllib.parse import urlparse
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Field, Layout, Submit
from django import forms
from .models import Filament, Submission
# Per plan.md §7.5.3 + the form's host allow-list. Subdomains (www. and bare)
# are both accepted.
URL_HOST_ALLOW_LIST: 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"},
}
# Shared Tailwind class string for text-ish inputs, matched against demo/submit.html.
INPUT_CSS = (
"w-full px-3 py-2 rounded-md border border-slate-300 bg-white "
"focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-amber-500"
)
class SubmissionForm(forms.ModelForm):
"""Single-page submit form.
The view passes `user=request.user` so the form can decide whether
`guest_email` is required (anonymous) or hidden (Google-authenticated).
"""
source_type = forms.ChoiceField(
choices=Submission.SourceType.choices,
initial=Submission.SourceType.UPLOAD,
widget=forms.RadioSelect,
)
stl_file = forms.FileField(
required=False,
widget=forms.ClearableFileInput(attrs={"accept": ".stl", "class": "sr-only"}),
)
source_url = forms.URLField(
required=False,
widget=forms.URLInput(
attrs={"placeholder": "https://...", "class": INPUT_CSS}
),
)
class Meta:
model = Submission
fields = (
"source_type",
"stl_file",
"source_url",
"requested_filament",
"notes_for_op",
"guest_email",
)
widgets = {
"notes_for_op": forms.Textarea(
attrs={
"rows": 3,
"class": INPUT_CSS,
"placeholder": (
"e.g. 0.2 mm layer height, 20% infill, pickup Saturday afternoon."
),
}
),
"guest_email": forms.EmailInput(
attrs={"class": INPUT_CSS, "placeholder": "you@example.com"}
),
"requested_filament": forms.Select(attrs={"class": INPUT_CSS}),
}
def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs)
# Stash for `clean()` -- the per-email cap needs to know whether the
# incoming submission is OAuth (user.email) or guest (guest_email).
self.user = user
# Filament dropdown shows only operator-curated, currently-loaded rows.
self.fields["requested_filament"].queryset = Filament.objects.filter(
is_available=True
)
self.fields["requested_filament"].label = "Filament"
self.fields["requested_filament"].empty_label = (
"No preference — operator's choice"
)
self.fields["requested_filament"].required = False
self.fields["notes_for_op"].required = False
if user is not None and user.is_authenticated:
# OAuth user: email is already verified through Google, no need to
# ask again. Drop the field so it doesn't render or accept input.
self.fields.pop("guest_email")
else:
self.fields["guest_email"].required = True
self.fields["guest_email"].label = "Your email"
# Crispy helper -- the template still owns the <form> tag (CSRF +
# enctype + custom radio-card markup live there), so we keep form_tag
# off and lean on crispy only for individual {{ field|as_crispy_field }}
# renders of the boring fields.
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.disable_csrf = True
layout_fields = ["source_type", "stl_file", "source_url", "requested_filament", "notes_for_op"]
if "guest_email" in self.fields:
layout_fields.append("guest_email")
self.helper.layout = Layout(*[Field(f) for f in layout_fields])
self.helper.add_input(Submit("submit", "Submit print"))
# --- validation ---------------------------------------------------------
def clean(self):
cleaned = super().clean()
# Per-email cap (plan.md §6). Run this BEFORE the source-type checks
# so a user already at quota doesn't get a misleading "pick a source"
# error message; the cap is the real reason their submission failed.
owner_email = (
self.user.email
if (self.user and self.user.is_authenticated)
else cleaned.get("guest_email")
)
if owner_email:
active = Submission.active_count_for_email(owner_email)
cap = Submission.MAX_ACTIVE_SUBMISSIONS_PER_EMAIL
if active >= cap:
raise forms.ValidationError(
f"You already have {active} active submission(s) -- "
f"that's the per-email cap of {cap}. Wait for some to "
f"finish printing (or be cleaned up after rejection) "
f"before submitting another."
)
source_type = cleaned.get("source_type")
stl_file = cleaned.get("stl_file")
source_url = cleaned.get("source_url")
if source_type == Submission.SourceType.UPLOAD:
if not stl_file:
self.add_error(
"stl_file",
"Pick an .stl file to upload (max 4 MB) or switch to a URL source.",
)
# The user might have left text in source_url from a prior selection;
# silently drop it so the saved row stays consistent.
cleaned["source_url"] = ""
else:
if not source_url:
self.add_error(
"source_url",
"Paste the model URL or switch to a raw .stl upload.",
)
else:
allowed = URL_HOST_ALLOW_LIST.get(source_type, set())
host = urlparse(source_url).hostname or ""
if host not in allowed:
pretty_hosts = ", ".join(sorted(allowed))
self.add_error(
"source_url",
f"URL host must be one of: {pretty_hosts}. Got '{host}'.",
)
# Conversely, drop any uploaded file that doesn't apply.
cleaned["stl_file"] = None
return cleaned

View File

View File

@@ -0,0 +1,74 @@
"""`python manage.py cleanup_stale` -- implements plan.md §7.6.
Permanently deletes submissions that have been stuck in `identifying`
(guest awaiting email confirmation) or `rejected` (auto- or operator-
rejected) for longer than the TTL window (default 24 h). Both the DB row
and any uploaded `.stl` file under `MEDIA_ROOT` are removed -- the
file-on-disk side is handled by the `post_delete` signal in
`apps/submissions/signals.py`.
Designed to be invoked on a loop from a sidecar service (plan.md §10):
while true; do python manage.py cleanup_stale; sleep 300; done
"""
from __future__ import annotations
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.db.models import Q
from django.utils import timezone
from apps.submissions.models import Submission
class Command(BaseCommand):
help = (
"Delete submissions stuck in `identifying` or `rejected` for more "
"than --ttl-hours. Removes both the DB row and any uploaded STL."
)
def add_arguments(self, parser) -> None:
parser.add_argument(
"--ttl-hours",
type=int,
default=24,
help="How long a row can sit in `identifying` / `rejected` before "
"being deleted. Default: 24, matching plan.md §7.6.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Report what would be deleted but make no changes.",
)
def handle(self, *args, ttl_hours: int, dry_run: bool, **opts) -> None:
cutoff = timezone.now() - timedelta(hours=ttl_hours)
# `identifying` rows are aged off `created_at` (the row hasn't moved
# state since it was created). `rejected` rows are aged off
# `closed_at` (the moment the operator or auto-validator closed them).
stale = Submission.objects.filter(
Q(status=Submission.Status.IDENTIFYING, created_at__lt=cutoff)
| Q(status=Submission.Status.REJECTED, closed_at__lt=cutoff)
)
if dry_run:
count = stale.count()
self.stdout.write(
f"DRY RUN: would delete {count} stale submission(s) older than "
f"{ttl_hours} h."
)
for sub in stale:
self.stdout.write(f" - {sub.slug} ({sub.status})")
return
# `QuerySet.delete()` fires `post_delete` per row, which is how the
# uploaded `.stl` file gets unlinked from MEDIA_ROOT (see signals.py).
total, _by_model = stale.delete()
if total:
self.stdout.write(
self.style.SUCCESS(
f"Deleted {total} stale submission(s) older than {ttl_hours} h."
)
)

View File

@@ -0,0 +1,82 @@
"""`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_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).
# `Submission.save()` detects the `processing -> rejected`
# transition and queues `send_rejection_email()` via
# transaction.on_commit -- no explicit email call needed.
sub.save()
self.stdout.write(f"rejected {sub.slug}: {exc}")
else:
sub.status = Submission.Status.VERIFYING
sub.save()
send_verifying_email(sub)
self.stdout.write(f"verifying {sub.slug}")

View File

@@ -0,0 +1,193 @@
# Generated by Django 6.0.5 on 2026-05-12 15:32
import apps.submissions.models
import django.core.validators
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Filament",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"material",
models.CharField(
choices=[
("PLA", "PLA"),
("PLA+", "PLA+"),
("PETG", "PETG"),
("ABS", "ABS"),
("TPU", "TPU"),
("ASA", "ASA"),
("Nylon", "Nylon"),
("Other", "Other"),
],
max_length=16,
),
),
("color_name", models.CharField(max_length=64)),
(
"swatch_hex",
models.CharField(blank=True, help_text="#RRGGBB", max_length=7),
),
("is_available", models.BooleanField(default=True)),
("notes", models.CharField(blank=True, max_length=200)),
("sort_order", models.IntegerField(default=0)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"ordering": ("sort_order", "color_name"),
"indexes": [
models.Index(
fields=["is_available"], name="submissions_is_avai_6de7b0_idx"
),
models.Index(
fields=["sort_order", "color_name"],
name="submissions_sort_or_59555c_idx",
),
],
},
),
migrations.CreateModel(
name="Submission",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("slug", models.CharField(db_index=True, max_length=64, unique=True)),
(
"guest_email",
models.EmailField(blank=True, max_length=254, null=True),
),
("email_confirmed", models.BooleanField(default=False)),
(
"confirmation_token",
models.CharField(
blank=True, db_index=True, max_length=64, null=True
),
),
("confirmation_sent_at", models.DateTimeField(blank=True, null=True)),
(
"source_type",
models.CharField(
choices=[
("upload", "Raw .stl upload"),
("printables", "Printables.com"),
("makerworld", "MakerWorld"),
("thingiverse", "Thingiverse"),
],
default="upload",
max_length=16,
),
),
(
"stl_file",
models.FileField(
blank=True,
null=True,
upload_to="stl/",
validators=[
django.core.validators.FileExtensionValidator(
allowed_extensions=["stl"]
),
apps.submissions.models._validate_stl_size,
],
),
),
("source_url", models.URLField(blank=True, null=True)),
("notes_for_op", models.TextField(blank=True)),
(
"status",
models.CharField(
choices=[
("identifying", "Identifying"),
("processing", "Processing"),
("verifying", "Verifying"),
("queued", "Queued"),
("printing", "Printing"),
("completed", "Completed"),
("rejected", "Rejected"),
("failed", "Failed"),
],
db_index=True,
default="identifying",
max_length=16,
),
),
("operator_notes", models.TextField(blank=True)),
("closed_at", models.DateTimeField(blank=True, null=True)),
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"closed_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="closed_submissions",
to=settings.AUTH_USER_MODEL,
),
),
(
"requested_filament",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="submissions",
to="submissions.filament",
),
),
(
"submitted_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="submissions",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ("-created_at",),
"constraints": [
models.CheckConstraint(
condition=models.Q(
("submitted_by__isnull", False),
("guest_email__isnull", False),
_connector="OR",
),
name="submission_has_contact_identity",
)
],
},
),
]

View File

@@ -0,0 +1,36 @@
# Generated by Django 6.0.5 on 2026-05-14 20:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("submissions", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="VerifiedEmail",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"email",
models.EmailField(db_index=True, max_length=254, unique=True),
),
("verified_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"ordering": ("-updated_at",),
},
),
]

View File

@@ -0,0 +1,39 @@
"""Backfill `VerifiedEmail` from any historical submission that already had
`email_confirmed=True` (i.e. the user clicked the confirmation link before
the trust list existed). One-shot; safe to re-run thanks to
`update_or_create`."""
from django.db import migrations
def backfill_verified_emails(apps, schema_editor):
Submission = apps.get_model("submissions", "Submission")
VerifiedEmail = apps.get_model("submissions", "VerifiedEmail")
# `update_or_create` keeps the migration idempotent.
seen: set[str] = set()
qs = Submission.objects.filter(email_confirmed=True).exclude(
guest_email__isnull=True
).exclude(guest_email="").values_list("guest_email", flat=True)
for email in qs:
email = email.strip().lower()
if not email or email in seen:
continue
seen.add(email)
VerifiedEmail.objects.update_or_create(email=email)
def noop_reverse(apps, schema_editor):
"""We don't try to undo the backfill -- the trust list is a forward-only
derived artefact; rolling back the migration leaves the rows alone."""
class Migration(migrations.Migration):
dependencies = [
("submissions", "0002_verifiedemail"),
]
operations = [
migrations.RunPython(backfill_verified_emails, noop_reverse),
]

View File

@@ -0,0 +1,51 @@
"""Schema changes for the email normalisation / TTL / cap work.
- `Submission.canonical_email`: new indexed column populated by
`Submission.save()`. Used to count active submissions per email for the
10-cap, and to look up the `VerifiedEmail` trust list.
- `VerifiedEmail.verified_at` -> `validated_at`: keeps the data, drops the
`auto_now_add` so `update_or_create` can roll the timestamp forward on
every re-confirmation (rolling 30-day TTL).
- `VerifiedEmail.updated_at`: removed -- `validated_at` IS the most recent
confirmation timestamp now, no need for a second column.
The data backfill (populate canonical_email, re-normalise existing
VerifiedEmail rows) lives in 0005_normalize_existing_data so this
migration stays a clean schema-only change.
"""
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("submissions", "0003_backfill_verified_emails"),
]
operations = [
migrations.AddField(
model_name="submission",
name="canonical_email",
field=models.EmailField(blank=True, db_index=True, max_length=254),
),
migrations.RenameField(
model_name="verifiedemail",
old_name="verified_at",
new_name="validated_at",
),
migrations.RemoveField(
model_name="verifiedemail",
name="updated_at",
),
migrations.AlterField(
model_name="verifiedemail",
name="validated_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AlterModelOptions(
name="verifiedemail",
options={"ordering": ("-validated_at",)},
),
]

View File

@@ -0,0 +1,92 @@
"""One-shot data backfill for the schema change in 0004:
1. Populate `Submission.canonical_email` for every existing row by deriving
it from `submitted_by.email` (OAuth) or `guest_email` (guest) and
running through the same normaliser the live `save()` uses.
2. Re-normalise every `VerifiedEmail.email` already in the table. Rows that
collapse to the same canonical form are deduped: we keep the row with
the most recent `validated_at` and delete the others.
Defensive: both passes use `update_fields=` and `update_or_create`-style
logic so re-running the migration is a no-op once it's been applied.
"""
from django.db import migrations
def _normalize_email(email):
if not email or "@" not in email:
return (email or "").lower()
local, _, domain = email.lower().rpartition("@")
if "+" in local:
local = local.split("+", 1)[0]
return f"{local}@{domain}"
def forward(apps, schema_editor):
Submission = apps.get_model("submissions", "Submission")
VerifiedEmail = apps.get_model("submissions", "VerifiedEmail")
User = apps.get_model("auth", "User")
# ---- Submission.canonical_email -----------------------------------------
# Pull all related users up front so we don't do an O(N) round-trip
# per submission.
user_emails = dict(
User.objects.exclude(email="").values_list("pk", "email")
)
to_update = []
for sub in Submission.objects.all().only(
"pk", "submitted_by_id", "guest_email", "canonical_email"
):
owner_email = ""
if sub.submitted_by_id and user_emails.get(sub.submitted_by_id):
owner_email = user_emails[sub.submitted_by_id]
elif sub.guest_email:
owner_email = sub.guest_email
new = _normalize_email(owner_email)
if new != sub.canonical_email:
sub.canonical_email = new
to_update.append(sub)
if to_update:
Submission.objects.bulk_update(to_update, ["canonical_email"], batch_size=500)
# ---- VerifiedEmail re-normalisation + dedup ----------------------------
# First pass: pick the surviving row per normalised form (most recent
# validated_at wins). Delete the losers.
survivors: dict[str, tuple[int, object]] = {} # norm -> (pk, validated_at)
for row in VerifiedEmail.objects.all().only("pk", "email", "validated_at"):
norm = _normalize_email(row.email)
if not norm:
row.delete()
continue
prev = survivors.get(norm)
if prev is None:
survivors[norm] = (row.pk, row.validated_at)
else:
prev_pk, prev_at = prev
if row.validated_at > prev_at:
VerifiedEmail.objects.filter(pk=prev_pk).delete()
survivors[norm] = (row.pk, row.validated_at)
else:
row.delete()
# Second pass: rewrite the surviving row's email to its normalised form
# (no-op when already normalised; safe because all duplicates are gone).
for norm, (pk, _at) in survivors.items():
VerifiedEmail.objects.filter(pk=pk).update(email=norm)
def reverse(apps, schema_editor):
"""The forward pass is a derived backfill; there's nothing meaningful
to undo. Leaving rows alone is the right thing on rollback."""
class Migration(migrations.Migration):
dependencies = [
("submissions", "0004_email_normalization"),
]
operations = [
migrations.RunPython(forward, reverse),
]

View File

@@ -1,3 +1,427 @@
from django.db import models """Submission + Filament models -- the data layer for plan.md §5.
# Create your models here. The state machine lives in `Submission.Status`; transitions and side-effects
are documented in plan.md §7.
"""
from __future__ import annotations
import uuid
from datetime import timedelta
import namesgenerator
from django.conf import settings
from django.core.validators import FileExtensionValidator
from django.db import models
from django.urls import reverse
from django.utils import timezone
def normalize_email(email: str) -> str:
"""Canonicalise an email for per-email accounting (the trust list AND
the active-submission cap).
- Lowercase the entire address.
- Strip Gmail-style `+tag` from the local part:
`user+anything@host.com` -> `user@host.com`.
Same return value for `User@Gmail.com`, `user+a@gmail.com`,
`user+b@gmail.com`. Returns `""` for falsy / malformed input.
"""
if not email or "@" not in email:
return (email or "").lower()
local, _, domain = email.lower().rpartition("@")
if "+" in local:
local = local.split("+", 1)[0]
return f"{local}@{domain}"
# Email trust lasts 30 days from `validated_at`; after that the user has to
# click a fresh confirmation link to re-prove inbox ownership.
EMAIL_TRUST_TTL = timedelta(days=30)
def _validate_stl_size(uploaded_file) -> None:
"""Hard 4 MB cap on uploaded `.stl` files -- mirrors plan.md §5."""
from django.core.exceptions import ValidationError
max_bytes = 4 * 1024 * 1024
if uploaded_file.size > max_bytes:
raise ValidationError(
f"STL file is {uploaded_file.size // 1024} KB; max is "
f"{max_bytes // 1024} KB."
)
class VerifiedEmail(models.Model):
"""An email address that has confirmed at least one hamprint submission.
Stored **normalised** (lowercased, `+tag` stripped) so
`user@gmail.com`, `user+a@gmail.com`, and `user+b@gmail.com` all
collapse to a single row.
Each entry carries a single `validated_at` timestamp. Trust expires
after `EMAIL_TRUST_TTL` (30 days); after that the next guest submission
from the same address falls back to `identifying` and gets a fresh
confirmation link, which on success bumps `validated_at` back to now
via `record_verification()`.
Trade-off worth knowing: anyone who knows a verified address can use
it to bypass confirmation for the next 30 days. We accept that for
hamlab's small-scale, operator-moderated workflow -- the operator
still has to manually approve every submission before it prints.
Operators can also revoke an entry via the admin to force the next
submission back through the welcome-email flow.
"""
email = models.EmailField(unique=True, db_index=True)
validated_at = models.DateTimeField(default=timezone.now)
class Meta:
ordering = ("-validated_at",)
def __str__(self) -> str:
return self.email
@classmethod
def is_trusted(cls, email: str) -> bool:
"""True if `email` (after normalisation) has a fresh trust entry --
i.e., `validated_at` is within `EMAIL_TRUST_TTL` of now."""
norm = normalize_email(email)
if not norm:
return False
cutoff = timezone.now() - EMAIL_TRUST_TTL
return cls.objects.filter(email=norm, validated_at__gte=cutoff).exists()
@classmethod
def record_verification(cls, email: str) -> "VerifiedEmail | None":
"""Mark `email` as freshly verified. Normalises first, then
`update_or_create`-s with a current `validated_at`. Returns the
row (or None if `email` was falsy)."""
norm = normalize_email(email)
if not norm:
return None
obj, _ = cls.objects.update_or_create(
email=norm,
defaults={"validated_at": timezone.now()},
)
return obj
class Filament(models.Model):
"""Operator-curated filament inventory (plan.md §5).
The submit form's "Filament" dropdown is populated from rows with
`is_available = True`. Filaments are protected from deletion while
referenced by an in-flight `Submission`; the correct workflow is to
flip `is_available = False`.
"""
class Material(models.TextChoices):
PLA = "PLA", "PLA"
PLA_PLUS = "PLA+", "PLA+"
PETG = "PETG", "PETG"
ABS = "ABS", "ABS"
TPU = "TPU", "TPU"
ASA = "ASA", "ASA"
NYLON = "Nylon", "Nylon"
OTHER = "Other", "Other"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
material = models.CharField(max_length=16, choices=Material.choices)
color_name = models.CharField(max_length=64)
swatch_hex = models.CharField(max_length=7, blank=True, help_text="#RRGGBB")
is_available = models.BooleanField(default=True)
notes = models.CharField(max_length=200, blank=True)
sort_order = models.IntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ("sort_order", "color_name")
indexes = [
models.Index(fields=["is_available"]),
models.Index(fields=["sort_order", "color_name"]),
]
def __str__(self) -> str:
return self.display_label
@property
def display_label(self) -> str:
return f"{self.color_name} {self.material}"
class Submission(models.Model):
"""A 3D-print job. State machine documented in plan.md §7."""
class Status(models.TextChoices):
IDENTIFYING = "identifying", "Identifying"
PROCESSING = "processing", "Processing"
VERIFYING = "verifying", "Verifying"
QUEUED = "queued", "Queued"
PRINTING = "printing", "Printing"
COMPLETED = "completed", "Completed"
REJECTED = "rejected", "Rejected"
FAILED = "failed", "Failed"
class SourceType(models.TextChoices):
UPLOAD = "upload", "Raw .stl upload"
PRINTABLES = "printables", "Printables.com"
MAKERWORLD = "makerworld", "MakerWorld"
THINGIVERSE = "thingiverse", "Thingiverse"
DASHBOARD_VISIBLE_STATUSES = (
Status.VERIFYING,
Status.QUEUED,
Status.PRINTING,
Status.COMPLETED,
)
TERMINAL_STATUSES = (Status.COMPLETED, Status.FAILED, Status.REJECTED)
# Per-email rate limit. A single email address (after normalisation --
# see `normalize_email`) is allowed at most this many submissions
# whose status is NOT in `STATUSES_EXCLUDED_FROM_LIMIT`. The exclusion
# is intentional: `printing` jobs are short-lived and operator-driven,
# and `rejected` jobs are already cleaned up after 24 h, so neither
# should count against the user's quota.
MAX_ACTIVE_SUBMISSIONS_PER_EMAIL = 10
STATUSES_EXCLUDED_FROM_LIMIT = (Status.PRINTING, Status.REJECTED)
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
slug = models.CharField(max_length=64, unique=True, db_index=True)
submitted_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="submissions",
)
guest_email = models.EmailField(null=True, blank=True)
email_confirmed = models.BooleanField(default=False)
confirmation_token = models.CharField(
max_length=64, null=True, blank=True, db_index=True
)
confirmation_sent_at = models.DateTimeField(null=True, blank=True)
source_type = models.CharField(
max_length=16,
choices=SourceType.choices,
default=SourceType.UPLOAD,
)
stl_file = models.FileField(
upload_to="stl/",
null=True,
blank=True,
validators=[
FileExtensionValidator(allowed_extensions=["stl"]),
_validate_stl_size,
],
)
source_url = models.URLField(null=True, blank=True)
requested_filament = models.ForeignKey(
Filament,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="submissions",
)
notes_for_op = models.TextField(blank=True)
status = models.CharField(
max_length=16,
choices=Status.choices,
default=Status.IDENTIFYING,
db_index=True,
)
operator_notes = models.TextField(blank=True)
closed_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="closed_submissions",
)
closed_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
updated_at = models.DateTimeField(auto_now=True)
# Normalised email (lowercased, +tag stripped) of whoever owns this row
# -- the OAuth user if `submitted_by` is set, otherwise the guest. Used
# for the active-submissions cap + the `VerifiedEmail` trust lookup,
# both of which need to treat `user+a@gmail.com` and `user@gmail.com`
# as the same mailbox. Populated in `save()`.
canonical_email = models.EmailField(blank=True, db_index=True)
class Meta:
ordering = ("-created_at",)
constraints = [
models.CheckConstraint(
name="submission_has_contact_identity",
condition=(
models.Q(submitted_by__isnull=False)
| models.Q(guest_email__isnull=False)
),
),
]
def __str__(self) -> str:
return f"{self.slug} ({self.get_status_display()})"
def get_absolute_url(self) -> str:
# The dashboard detail route is not wired yet; fall back to the
# dashboard index so post-submit redirects always land somewhere real.
return reverse("dashboard:index")
@classmethod
def from_db(cls, db, field_names, values):
"""Capture the `status` value the row had when it was loaded, so
`save()` can detect status transitions later. Stored on the instance
as `_original_status`; refreshed at the end of every `save()` so
successive saves compare against the freshly-persisted state."""
instance = super().from_db(db, field_names, values)
instance._original_status = instance.status
return instance
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. Also keeps
`canonical_email` in sync with whichever side (OAuth user / guest)
currently owns the row, so the per-email cap and trust list don't
depend on the caller remembering to set it.
Additionally: when an UPDATE flips `status` to `rejected` from any
other state, this method queues `send_rejection_email()` via
`transaction.on_commit`. Centralising the email here means **every**
save path -- admin, the validation worker, ad-hoc shell, any future
view -- fires the email through a single hook. Plan.md §7.3.
"""
if not self.slug:
self.slug = self._generate_unique_slug()
# Re-derive canonical_email every save: cheap, and survives an
# operator flipping `submitted_by` / `guest_email` in admin.
owner_email = ""
if self.submitted_by_id and self.submitted_by and self.submitted_by.email:
owner_email = self.submitted_by.email
elif self.guest_email:
owner_email = self.guest_email
self.canonical_email = normalize_email(owner_email)
# Snapshot for the transition check. `_state.adding` is the canonical
# Django way to distinguish "first save" from "subsequent update".
is_new = self._state.adding
new_status = self.status
old_status = getattr(self, "_original_status", None)
super().save(*args, **kwargs)
# Fire on TRANSITIONS only: an UPDATE that flips status to rejected.
# Don't fire on inserts that start out as rejected -- those should
# be impossible by design (plan.md §7.3 doesn't define a (none) ->
# rejected edge), and even if some weird path creates one we'd
# rather stay silent than spam a fresh victim.
if (
not is_new
and old_status != new_status
and new_status == self.Status.REJECTED
):
# Local imports keep this module out of the apps/submissions
# import-cycle (emails.py imports from here).
from django.db import transaction
from .emails import send_rejection_email
transaction.on_commit(
lambda sub=self, prev=old_status: send_rejection_email(
sub, previous_status=prev
)
)
# Refresh the snapshot so a follow-up save on the same instance
# compares against the just-persisted state, not the original load.
self._original_status = new_status
@classmethod
def active_count_for_email(cls, email: str) -> int:
"""Return how many of this email's submissions count against the
`MAX_ACTIVE_SUBMISSIONS_PER_EMAIL` cap.
Submissions in `STATUSES_EXCLUDED_FROM_LIMIT` (`printing`,
`rejected`) are excluded -- they're transient or already-cleaned
states that shouldn't pin the user's quota.
"""
norm = normalize_email(email)
if not norm:
return 0
return cls.objects.filter(canonical_email=norm).exclude(
status__in=cls.STATUSES_EXCLUDED_FROM_LIMIT
).count()
@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 = {
SourceType.UPLOAD: ".stl upload",
SourceType.PRINTABLES: "printables.com",
SourceType.MAKERWORLD: "makerworld.com",
SourceType.THINGIVERSE: "thingiverse.com",
}
# Tailwind class string per status, kept here (not in the template) so the
# palette is defined in one place and Tailwind's @source-scanner picks the
# literal class strings up from apps/.
STATUS_BADGE_CLASS = {
Status.IDENTIFYING: "bg-yellow-100 text-yellow-800",
Status.PROCESSING: "bg-slate-100 text-slate-700",
Status.VERIFYING: "bg-violet-100 text-violet-800",
Status.QUEUED: "bg-blue-100 text-blue-800",
Status.PRINTING: "bg-orange-100 text-orange-800",
Status.COMPLETED: "bg-emerald-100 text-emerald-800",
Status.REJECTED: "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
def source_label(self) -> str:
return self.SOURCE_LABEL.get(self.source_type, self.source_type)
@property
def status_badge_class(self) -> str:
return self.STATUS_BADGE_CLASS.get(self.status, "bg-slate-100 text-slate-700")
@property
def status_email_style(self) -> dict:
return self.STATUS_EMAIL_COLORS.get(
self.status, {"bg": "#f1f5f9", "fg": "#334155"}
)

View File

@@ -0,0 +1,29 @@
"""Signal handlers for the submissions app.
The cleanup story (plan.md §7.6): when a `Submission` row is deleted --
manually, via `cleanup_stale`, or for any other reason -- the uploaded
`.stl` file must also be unlinked from `MEDIA_ROOT`. Django's `FileField`
does NOT do this automatically (the behaviour was removed in 1.3, ages
ago), so we wire a `post_delete` signal to take care of it.
"""
from __future__ import annotations
from django.db.models.signals import post_delete
from django.dispatch import receiver
from .models import Submission
@receiver(post_delete, sender=Submission)
def unlink_stl_file_on_delete(sender, instance: Submission, **kwargs) -> None:
"""Remove the on-disk `.stl` after a Submission row is deleted.
`FileField.delete(save=False)` is idempotent: it just calls
`Storage.delete(name)`, and `FileSystemStorage.delete` catches
`FileNotFoundError`. Safe to call on rows that never had a file
(e.g. URL-source submissions) -- the `if instance.stl_file` truthiness
check handles that.
"""
if instance.stl_file:
instance.stl_file.delete(save=False)

9
apps/submissions/urls.py Normal file
View File

@@ -0,0 +1,9 @@
from django.urls import path
from . import views
app_name = "submissions"
urlpatterns = [
path("", views.SubmitView.as_view(), name="create"),
]

View File

@@ -0,0 +1,190 @@
"""STL + URL validation for the `processing` worker (plan.md §7.5.2 / §7.5.3).
Two public callables:
validate_stl_file(path)
Magic-byte probe + numpy-stl mesh load + bounding-box vs build plate +
triangle-count sanity. Raises `ValidationError` with the user-visible
reason on any rejection.
validate_external_url(url, source_type)
Host allow-list re-check + HEAD reachability (fallback to GET with a
Range header on 405). Raises `ValidationError` likewise.
`numpy-stl` is imported lazily so this module can be imported on a host venv
that hasn't run `pip install -r requirements.txt` yet -- the failure only
fires when an STL is actually validated.
"""
from __future__ import annotations
import struct
from pathlib import Path
from urllib.parse import urlparse
import requests
from django.conf import settings
from .models import Submission
class ValidationError(Exception):
"""Raised when a Submission fails its validation pass. The message
becomes the operator-visible (and user-visible) rejection reason."""
# Host allow-list per source_type. Mirrors `apps.submissions.forms`
# deliberately rather than DRYing through a shared import: the
# `processing` worker shouldn't be coupled to the form layer.
_URL_HOSTS: dict[str, set[str]] = {
Submission.SourceType.PRINTABLES: {"printables.com", "www.printables.com"},
Submission.SourceType.MAKERWORLD: {"makerworld.com", "www.makerworld.com"},
Submission.SourceType.THINGIVERSE: {"thingiverse.com", "www.thingiverse.com"},
}
_MIN_AXIS_MM = 5.0
_MIN_TRIANGLES = 4
_MAX_TRIANGLES = 5_000_000
# Be a polite citizen so anti-bot defenses on the model platforms don't 403 us.
_HTTP_HEADERS = {
"User-Agent": "hamprint-validator/1.0 (+https://hamlab.lt)",
}
def _build_volume() -> tuple[float, float, float]:
"""Read `PRINTER_BUILD_VOLUME_MM` (env-driven, default 235,235,250)."""
raw = getattr(settings, "PRINTER_BUILD_VOLUME_MM", "235,235,250")
parts = [float(x) for x in raw.split(",")]
if len(parts) != 3:
raise RuntimeError(
f"PRINTER_BUILD_VOLUME_MM must be 'x,y,z'; got: {raw!r}"
)
return parts[0], parts[1], parts[2]
def _fmt(value: float) -> str:
"""Render `235.0` as `235`, `235.5` as `235.5`. Just for clean error text."""
return str(int(value)) if value.is_integer() else f"{value:g}"
# ---- STL ---------------------------------------------------------------------
def validate_stl_file(path: str) -> None:
"""Four-pass check; raises `ValidationError` on first failure."""
p = Path(path)
if not p.is_file():
raise ValidationError("STL file is missing on disk")
size = p.stat().st_size
# 1. Magic-byte / format probe.
with p.open("rb") as f:
head_80 = f.read(80)
if head_80.startswith(b"solid "):
# Could be ASCII -- but some binary STLs also start with "solid ".
# Confirm by looking for an ASCII facet marker in the first 4 KB.
with p.open("rb") as f:
first_4k = f.read(4096)
if b"facet normal" not in first_4k:
raise ValidationError(
"file is not a valid STL: header / size mismatch"
)
# ASCII STL accepted; the mesh load below catches anything subtler.
else:
# Binary STL: 80-byte header + uint32 triangle count + 50 B per tri.
with p.open("rb") as f:
f.seek(80)
tri_bytes = f.read(4)
if len(tri_bytes) != 4:
raise ValidationError(
"file is not a valid STL: header / size mismatch"
)
triangle_count = struct.unpack("<I", tri_bytes)[0]
if size != 84 + 50 * triangle_count:
raise ValidationError(
"file is not a valid STL: header / size mismatch"
)
# 2. Mesh load. Imported lazily so the module imports without numpy-stl.
try:
from stl import mesh # type: ignore[import-not-found]
except ImportError as exc: # pragma: no cover -- only hit on host venv
raise ValidationError(
"numpy-stl is not installed in this environment"
) from exc
try:
m = mesh.Mesh.from_file(str(p))
except Exception: # numpy-stl raises a variety of types; broad on purpose
raise ValidationError("STL could not be parsed")
# 3. Bounding-box check. mesh.vectors has shape (N, 3, 3).
vertices = m.vectors.reshape(-1, 3)
mins = vertices.min(axis=0)
maxs = vertices.max(axis=0)
extents = [float(maxs[i] - mins[i]) for i in range(3)]
bv_x, bv_y, bv_z = _build_volume()
if extents[0] > bv_x or extents[1] > bv_y or extents[2] > bv_z:
raise ValidationError(
f"part is {_fmt(extents[0])}x{_fmt(extents[1])}x{_fmt(extents[2])} mm; "
f"doesn't fit on our {_fmt(bv_x)}x{_fmt(bv_y)}x{_fmt(bv_z)} build plate"
)
if max(extents) < _MIN_AXIS_MM:
raise ValidationError(
f"part is too small to print reliably "
f"(under {_fmt(_MIN_AXIS_MM)} mm on every axis)"
)
# 4. Triangle-count sanity.
tri_count = int(len(m.vectors))
if tri_count < _MIN_TRIANGLES:
raise ValidationError(
f"mesh is degenerate (fewer than {_MIN_TRIANGLES} triangles)"
)
if tri_count > _MAX_TRIANGLES:
raise ValidationError(f"mesh is too dense ({tri_count:,} triangles)")
# ---- URL ---------------------------------------------------------------------
def validate_external_url(source_url: str, source_type: str) -> None:
"""Host check + HEAD reachability; raises `ValidationError` on rejection."""
if not source_url:
raise ValidationError("URL is empty")
# 1. Host re-check. We already did this at form time but the row may have
# been edited via the admin since.
host = (urlparse(source_url).hostname or "").lower()
allowed = _URL_HOSTS.get(source_type, set())
if host not in allowed:
raise ValidationError("URL host doesn't match source type")
# 2. HEAD reachability.
try:
r = requests.head(
source_url,
timeout=5,
allow_redirects=True,
headers=_HTTP_HEADERS,
)
# Some CDNs refuse HEAD with 405; fall back to a tiny ranged GET.
if r.status_code == 405:
r = requests.get(
source_url,
timeout=5,
allow_redirects=True,
headers={**_HTTP_HEADERS, "Range": "bytes=0-1023"},
)
except requests.exceptions.RequestException as exc:
raise ValidationError(
f"URL unreachable ({exc.__class__.__name__})"
) from exc
if r.status_code >= 400:
raise ValidationError(
f"URL returned HTTP {r.status_code}; the model may have been "
f"removed or set to private"
)

View File

@@ -1,3 +1,133 @@
from django.shortcuts import render """Submit form view (plan.md §7.4).
# Create your views here. 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)
guest with email -> identifying (waiting for confirmation link click)
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
from django.contrib import messages
from django.db import IntegrityError, transaction
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, VerifiedEmail
class SubmitView(CreateView):
"""Public submit form. GET renders, POST creates a Submission."""
form_class = SubmissionForm
template_name = "submissions/submit.html"
success_url = reverse_lazy("dashboard:index")
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["user"] = self.request.user
return kwargs
def form_valid(self, form):
submission: Submission = form.save(commit=False)
# 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
submission.guest_email = None
submission.email_confirmed = True
submission.status = Submission.Status.PROCESSING
elif submission.guest_email and VerifiedEmail.is_trusted(submission.guest_email):
# Returning guest: their email is on the trust list AND the
# 30-day TTL hasn't lapsed (plan.md §6). Skip `identifying` and
# the welcome email entirely -- straight to `processing` like
# an OAuth submitter. Email normalisation happens inside
# `is_trusted`, so `user+a@gmail.com` and `user@gmail.com`
# collapse to the same lookup.
submission.submitted_by = None
submission.email_confirmed = True
submission.status = Submission.Status.PROCESSING
else:
submission.submitted_by = None
# guest_email is already on the form's cleaned_data, ModelForm
# populated it onto the instance.
submission.email_confirmed = False
submission.status = Submission.Status.IDENTIFYING
submission.confirmation_token = secrets.token_urlsafe(32)
submission.confirmation_sent_at = timezone.now()
# 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:
self._notify_guest(submission)
else:
messages.success(
self.request,
format_html(
"Submission <strong class=\"font-mono\">{}</strong> accepted. "
"We'll start validating it shortly.",
submission.slug,
),
)
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 you a confirmation link &mdash; check your inbox and "
"click it within 24 hours to add your print to the queue.",
slug=submission.slug,
),
)
else:
messages.error(
self.request,
format_html(
"Submission <strong class=\"font-mono\">{slug}</strong> was saved, "
"but we couldn't send the confirmation email to <strong>{email}</strong>. "
"Try <a href=\"{login_url}\" class=\"underline font-medium\">"
"signing in with Google</a> instead &mdash; it skips email "
"confirmation entirely.",
slug=submission.slug,
email=submission.guest_email,
login_url=reverse("account_login"),
),
)

View File

@@ -0,0 +1,9 @@
@import "tailwindcss";
/* Tailwind 4 auto-detects most file types but does not scan Python files,
* and its default walk excludes folders outside the CWD it was started in.
* Be explicit about the two locations where we put Tailwind utility classes:
* Django templates and Python widget-attrs.
*/
@source "../templates";
@source "../apps";

55
compose.dev.yaml Normal file
View File

@@ -0,0 +1,55 @@
# Podman / Docker Compose stack for local hamprint development.
#
# Bring it up: podman-compose up -d (or: docker compose up -d)
# Logs: podman-compose logs -f web
# Tear down: podman-compose down (keeps the pgdata volume)
# podman-compose down -v (drops the pgdata volume too)
#
# The `web` service uses a stock python:3.14-slim image and bind-mounts this
# repo at /app. On every start it installs from requirements.txt plus
# psycopg[binary] (the pip-cache volume makes subsequent starts fast) and
# runs Django's dev server. There is intentionally no Containerfile yet --
# swap `image:` for `build: .` once Section 10's image lands.
services:
db:
image: docker.io/library/postgres:16
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 10
env_file: .env
web:
image: docker.io/library/python:3.14-slim
working_dir: /app
command: >
bash -c "
pip install --quiet --no-input -r requirements.txt 'psycopg[binary]' &&
python manage.py migrate --noinput &&
python manage.py runserver 0.0.0.0:8000
"
env_file: .env
environment:
DJANGO_SETTINGS_MODULE: hamprint.settings.dev
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
ports:
- "8000:8000"
volumes:
- .:/app:Z
- pipcache:/root/.cache/pip
depends_on:
db:
condition: service_healthy
volumes:
pgdata:
pipcache:

View File

@@ -1,20 +1,39 @@
# Podman / Docker Compose stack for local hamprint development. # Production compose stack for hamprint.
# #
# Bring it up: podman-compose up -d (or: docker compose up -d) # 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:

View File

@@ -8,12 +8,14 @@
<style> <style>
body { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; } body { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
/* show/hide source inputs based on radio selection */ /* show/hide source inputs based on radio selection. Radios live inside a
#src-upload:checked ~ .src-pane[data-pane="upload"] { display: block; } grid <div> while the panes are siblings of the grid, so a plain `~`
#src-printables:checked ~ .src-pane[data-pane="printables"] { display: block; } combinator never matches -- we use `fieldset:has(...)` instead. */
#src-makerworld:checked ~ .src-pane[data-pane="makerworld"] { display: block; }
#src-thingiverse:checked ~ .src-pane[data-pane="thingiverse"] { display: block; }
.src-pane { display: none; } .src-pane { display: none; }
fieldset:has(#src-upload:checked) .src-pane[data-pane="upload"],
fieldset:has(#src-printables:checked) .src-pane[data-pane="url"],
fieldset:has(#src-makerworld:checked) .src-pane[data-pane="url"],
fieldset:has(#src-thingiverse:checked) .src-pane[data-pane="url"] { display: block; }
</style> </style>
</head> </head>
<body class="min-h-screen bg-slate-50 text-slate-900"> <body class="min-h-screen bg-slate-50 text-slate-900">
@@ -81,37 +83,27 @@
<label for="src-thingiverse" class="cursor-pointer border border-slate-300 rounded-md px-3 py-2 text-center text-sm hover:border-amber-500 peer-checked/thingiverse:border-amber-500 peer-checked/thingiverse:bg-amber-50 peer-checked/thingiverse:text-amber-900 peer-checked/thingiverse:font-medium">Thingiverse</label> <label for="src-thingiverse" class="cursor-pointer border border-slate-300 rounded-md px-3 py-2 text-center text-sm hover:border-amber-500 peer-checked/thingiverse:border-amber-500 peer-checked/thingiverse:bg-amber-50 peer-checked/thingiverse:text-amber-900 peer-checked/thingiverse:font-medium">Thingiverse</label>
</div> </div>
<!-- Upload pane --> <!-- Upload pane: hidden native file input + dropzone label + JS-driven
filename feedback (see <script> at the bottom). -->
<div class="src-pane" data-pane="upload"> <div class="src-pane" data-pane="upload">
<label class="block"> <label class="block cursor-pointer">
<div class="border-2 border-dashed border-slate-300 rounded-md p-6 text-center hover:border-amber-500 hover:bg-amber-50/40 cursor-pointer"> <div class="border-2 border-dashed border-slate-300 rounded-md p-6 text-center hover:border-amber-500 hover:bg-amber-50/40 transition">
<svg class="w-8 h-8 mx-auto text-slate-400" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 16.5V9.75m0 0L9 12.75M12 9.75l3 3M3 17.25V18a2.25 2.25 0 002.25 2.25h13.5A2.25 2.25 0 0021 18v-.75" /></svg> <svg class="w-8 h-8 mx-auto text-slate-400" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 16.5V9.75m0 0L9 12.75M12 9.75l3 3M3 17.25V18a2.25 2.25 0 002.25 2.25h13.5A2.25 2.25 0 0021 18v-.75" /></svg>
<p class="mt-2 text-sm font-medium text-slate-700">Drop your .stl here or click to browse</p> <p data-stl-prompt class="mt-2 text-sm font-medium text-slate-700">Drop your .stl here or click to browse</p>
<p data-stl-filename class="hidden mt-2 text-sm font-medium text-amber-700"></p>
<p class="text-xs text-slate-500 mt-1">Raw <span class="mono">.stl</span> only, max 4 MB. No <span class="mono">.3mf</span>, <span class="mono">.gcode</span>, <span class="mono">.zip</span>, etc.</p> <p class="text-xs text-slate-500 mt-1">Raw <span class="mono">.stl</span> only, max 4 MB. No <span class="mono">.3mf</span>, <span class="mono">.gcode</span>, <span class="mono">.zip</span>, etc.</p>
</div> </div>
<input type="file" accept=".stl" class="sr-only" /> <input id="stl-file-input" type="file" accept=".stl" class="sr-only" />
</label> </label>
</div> </div>
<!-- Printables pane --> <!-- URL pane (shared by Printables / MakerWorld / Thingiverse). The
<div class="src-pane" data-pane="printables"> label, placeholder, and help text are rewritten per source-type by
<label class="block text-sm font-medium text-slate-700 mb-1">Printables.com URL</label> the <script> at the bottom so the user gets host-specific guidance. -->
<input type="url" placeholder="https://www.printables.com/model/…" class="w-full px-3 py-2 rounded-md border border-slate-300 bg-white focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-amber-500" /> <div class="src-pane" data-pane="url">
<p class="text-xs text-slate-500 mt-1">Must be a <span class="mono">printables.com</span> link.</p> <label for="url-input" data-url-label class="block text-sm font-medium text-slate-700 mb-1">Printables.com URL</label>
</div> <input id="url-input" type="url" placeholder="https://www.printables.com/model/…" class="w-full px-3 py-2 rounded-md border border-slate-300 bg-white focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-amber-500" />
<p data-url-help class="text-xs text-slate-500 mt-1">Must be a <span class="mono">printables.com</span> link.</p>
<!-- Makerworld pane -->
<div class="src-pane" data-pane="makerworld">
<label class="block text-sm font-medium text-slate-700 mb-1">MakerWorld URL</label>
<input type="url" placeholder="https://makerworld.com/en/models/…" class="w-full px-3 py-2 rounded-md border border-slate-300 bg-white focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-amber-500" />
<p class="text-xs text-slate-500 mt-1">Must be a <span class="mono">makerworld.com</span> link.</p>
</div>
<!-- Thingiverse pane -->
<div class="src-pane" data-pane="thingiverse">
<label class="block text-sm font-medium text-slate-700 mb-1">Thingiverse URL</label>
<input type="url" placeholder="https://www.thingiverse.com/thing:…" class="w-full px-3 py-2 rounded-md border border-slate-300 bg-white focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-amber-500" />
<p class="text-xs text-slate-500 mt-1">Must be a <span class="mono">thingiverse.com</span> link.</p>
</div> </div>
<p class="text-xs text-slate-500 mt-3">Got something from elsewhere? Download the <span class="mono">.stl</span> and upload it as a file.</p> <p class="text-xs text-slate-500 mt-3">Got something from elsewhere? Download the <span class="mono">.stl</span> and upload it as a file.</p>
@@ -168,5 +160,52 @@
</div> </div>
</footer> </footer>
<script>
// Filename feedback: swap the "Drop your .stl here" prompt for the picked
// file's name + size as soon as the user selects something.
(function() {
var input = document.getElementById("stl-file-input");
var prompt = document.querySelector("[data-stl-prompt]");
var filename = document.querySelector("[data-stl-filename]");
if (!input || !prompt || !filename) return;
input.addEventListener("change", function(e) {
var file = e.target.files && e.target.files[0];
if (file) {
prompt.classList.add("hidden");
filename.classList.remove("hidden");
filename.textContent = "✓ " + file.name + " (" + Math.round(file.size / 1024) + " KB)";
} else {
prompt.classList.remove("hidden");
filename.classList.add("hidden");
filename.textContent = "";
}
});
})();
// URL-pane copy: rewrite the label, the input's placeholder, and the help
// text per source-type so the user gets host-specific guidance.
(function() {
var COPY = {
printables: { label: "Printables.com URL", ph: "https://www.printables.com/model/…", help: "Must be a printables.com link." },
makerworld: { label: "MakerWorld URL", ph: "https://makerworld.com/en/models/…", help: "Must be a makerworld.com link." },
thingiverse: { label: "Thingiverse URL", ph: "https://www.thingiverse.com/thing:…", help: "Must be a thingiverse.com link." }
};
var labelEl = document.querySelector("[data-url-label]");
var inputEl = document.getElementById("url-input");
var helpEl = document.querySelector("[data-url-help]");
function apply(sourceType) {
var c = COPY[sourceType];
if (!c) return;
if (labelEl) labelEl.textContent = c.label;
if (inputEl) inputEl.placeholder = c.ph;
if (helpEl) helpEl.textContent = c.help;
}
document.querySelectorAll('input[name="source"]').forEach(function(radio) {
radio.addEventListener("change", function(e) { apply(e.target.value); });
if (radio.checked) apply(radio.value);
});
})();
</script>
</body> </body>
</html> </html>

38
entrypoint.sh Normal file
View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# hamprint container entrypoint.
#
# Runs everything inside the single `web` container per plan.md §10:
# 1. apply DB migrations (idempotent; runs on every start)
# 2. background `process_submissions` (plan.md §7.5, 30 s loop)
# 3. background `cleanup_stale` (plan.md §7.6, 5 min loop)
# 4. exec Gunicorn so it becomes the foreground process for signal handling
#
# tini (set as ENTRYPOINT in the Containerfile) reaps zombies and forwards
# SIGTERM to all three child processes on container stop.
set -euo pipefail
python manage.py migrate --noinput
# Validation worker. `|| true` keeps the loop alive across a transient
# failure (e.g. a flaky external HEAD request); the next tick retries.
(
while true; do
python manage.py process_submissions || true
sleep 30
done
) &
# Stale-row reaper. Same pattern, different cadence.
(
while true; do
python manage.py cleanup_stale || true
sleep 300
done
) &
exec gunicorn hamprint.wsgi:application \
--bind 0.0.0.0:8000 \
--workers "${GUNICORN_WORKERS:-3}" \
--access-logfile - \
--error-logfile -

View File

@@ -30,6 +30,7 @@ INSTALLED_APPS = [
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"django.contrib.sites", "django.contrib.sites",
"django.contrib.humanize",
# Third-party # Third-party
"allauth", "allauth",
"allauth.account", "allauth.account",
@@ -38,6 +39,7 @@ INSTALLED_APPS = [
"anymail", "anymail",
"crispy_forms", "crispy_forms",
"crispy_tailwind", "crispy_tailwind",
"django_tailwind_cli",
# Local # Local
"apps.submissions", "apps.submissions",
"apps.dashboard", "apps.dashboard",
@@ -117,20 +119,47 @@ 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.
ANYMAIL = { #
"MAILJET_API_KEY": env("MAILJET_API_KEY", default=""), # If MAILTRAP_TEST_INBOX_ID is set, anymail routes to Mailtrap's sandbox
"MAILJET_SECRET_KEY": env("MAILJET_API_SECRET", default=""), # (testing inbox) instead of doing real delivery -- handy for staging.
} _mailtrap_token = env("MAILTRAP_API_TOKEN", default="")
_mailtrap_test_inbox = env("MAILTRAP_TEST_INBOX_ID", default="")
if _mailtrap_token:
EMAIL_BACKEND = "anymail.backends.mailtrap.EmailBackend"
ANYMAIL = {
"MAILTRAP_API_TOKEN": _mailtrap_token,
}
if _mailtrap_test_inbox:
ANYMAIL["MAILTRAP_TEST_INBOX_ID"] = _mailtrap_test_inbox
ANYMAIL["MAILTRAP_TESTING"] = True
else:
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
DEFAULT_FROM_EMAIL = env( DEFAULT_FROM_EMAIL = 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"
# Tailwind CSS --- keep source.css in the repo (and outside the
# auto-managed .django_tailwind_cli/ directory) so we can add explicit
# `@source` directives for the template + app directories without the
# next `tailwind build` reverting them.
TAILWIND_CLI_SRC_CSS = BASE_DIR / "assets" / "tailwind.source.css"
# Allauth. We run our own confirmation flow for guests, so allauth's own email # Allauth. We run our own confirmation flow for guests, so allauth's own email
# verification stays off for Google-authenticated users (they're already verified). # verification stays off for Google-authenticated users (they're already verified).
ACCOUNT_EMAIL_VERIFICATION = "none" ACCOUNT_EMAIL_VERIFICATION = "none"

View File

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

View File

@@ -16,8 +16,15 @@ Including another URLconf
""" """
from django.contrib import admin from django.contrib import admin
from django.urls import path 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("", include("apps.dashboard.urls")),
path("submit/", include("apps.submissions.urls")),
] ]

View File

@@ -1,7 +1,9 @@
annotated-doc==0.0.4
asgiref==3.11.1 asgiref==3.11.1
certifi==2026.4.22 certifi==2026.4.22
cffi==2.0.0 cffi==2.0.0
charset-normalizer==3.4.7 charset-normalizer==3.4.7
click==8.3.3
crispy-tailwind==1.0.3 crispy-tailwind==1.0.3
cryptography==48.0.0 cryptography==48.0.0
dj-database-url==3.1.2 dj-database-url==3.1.2
@@ -10,13 +12,23 @@ django-allauth==65.16.1
django-anymail==15.0 django-anymail==15.0
django-crispy-forms==2.6 django-crispy-forms==2.6
django-environ==0.13.0 django-environ==0.13.0
django-tailwind-cli==4.6.1
django-typer==3.7.2
gunicorn==26.0.0 gunicorn==26.0.0
idna==3.14 idna==3.14
markdown-it-py==4.2.0
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
PyJWT==2.12.1 PyJWT==2.12.1
requests==2.34.0 requests==2.34.0
rich==15.0.0
semver==3.0.4
shellingham==1.5.4
sqlparse==0.5.5 sqlparse==0.5.5
typer==0.25.1
urllib3==2.7.0 urllib3==2.7.0
whitenoise==6.12.0 whitenoise==6.12.0

2
static/css/tailwind.css Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}Close your hamprint account{% endblock %}
{% block content %}
<div class="max-w-md mx-auto py-8">
<div class="bg-white border border-red-200 rounded-lg p-8 shadow-sm">
<h1 class="text-2xl font-bold tracking-tight text-red-700">Close your account</h1>
<p class="text-slate-700 mt-2 text-sm">
Signed in as
<span class="font-medium">{{ user.email|default:user.get_username }}</span>.
This action permanently deletes your account.
</p>
<div class="mt-4 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-900">
<p class="font-semibold">There is no undo.</p>
<ul class="mt-2 list-disc pl-5 space-y-1">
<li>Your user row is deleted from the database.</li>
<li>
{% if submission_count == 0 %}
You don't have any prints in the system right now -- nothing else to remove.
{% elif submission_count == 1 %}
Your <strong>1</strong> print is deleted along with your account, including any uploaded STL.
{% else %}
All <strong>{{ submission_count }}</strong> of your prints are deleted along with your account, including any uploaded STLs.
{% endif %}
</li>
<li>Any active queue items are cancelled. Operators won't be able to contact you afterwards.</li>
</ul>
</div>
<form method="post" class="mt-6">
{% csrf_token %}
<button type="submit" class="w-full px-4 py-3 rounded-md bg-red-600 text-white hover:bg-red-700 font-medium">
Yes, delete my account permanently
</button>
</form>
<div class="mt-4 text-center">
<a href="{% url 'dashboard:my_prints' %}" class="text-sm text-slate-600 hover:underline">← Cancel, take me back</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block title %}Sign in — 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">
<div class="text-center mb-6">
<div class="inline-grid place-items-center w-12 h-12 rounded-lg bg-amber-500 text-white font-bold text-xl mb-3">h</div>
<h1 class="text-2xl font-bold tracking-tight">Sign in to hamprint</h1>
<p class="text-slate-600 mt-2 text-sm">
Optional &mdash; you can also
<a href="{% url 'submissions:create' %}" class="text-amber-700 font-medium hover:underline">submit as a guest</a>.
</p>
</div>
{% comment %}
The button is a GET to allauth's google_login view -- not a POST form --
because the actual OAuth handshake happens via a 302 redirect to Google.
The `?next=` from `/accounts/login/?next=/wherever/` is forwarded so the
user lands back on the page they were trying to reach.
{% endcomment %}
<a href="{% url 'google_login' %}{% if request.GET.next %}?next={{ request.GET.next|urlencode }}{% endif %}"
class="flex items-center justify-center gap-3 w-full px-4 py-3 rounded-md border border-slate-300 bg-white hover:bg-slate-50 text-slate-900 font-medium shadow-sm">
<svg class="w-5 h-5" viewBox="0 0 24 24" aria-hidden="true">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.75h3.57c2.08-1.92 3.28-4.74 3.28-8.07z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.75c-.99.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.12A6.97 6.97 0 015.46 12c0-.74.13-1.45.36-2.12V7.04H2.18A10.99 10.99 0 001 12c0 1.77.42 3.45 1.18 4.96l3.66-2.84z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.04l3.66 2.84C6.71 7.31 9.14 5.38 12 5.38z"/>
</svg>
Continue with Google
</a>
<div class="mt-6 text-center">
<a href="{% url 'submissions:create' %}" class="text-sm text-slate-600 hover:underline">No thanks, just submit as a guest →</a>
</div>
<div class="mt-8 pt-6 border-t border-slate-200 text-xs text-slate-500 space-y-2">
<p class="flex items-start gap-2"><span class="text-emerald-600 mt-0.5"></span> Signed-in prints skip email confirmation.</p>
<p class="flex items-start gap-2"><span class="text-emerald-600 mt-0.5"></span> See all your past prints in one place.</p>
<p class="flex items-start gap-2"><span class="text-emerald-600 mt-0.5"></span> We don't share your email, ever.</p>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Sign out — hamprint{% endblock %}
{% block content %}
<div class="max-w-md mx-auto py-8">
<div class="bg-white border border-slate-200 rounded-lg p-8 shadow-sm">
<h1 class="text-2xl font-bold tracking-tight">Sign out?</h1>
<p class="text-slate-600 mt-2 text-sm">
You're signed in as
<span class="font-medium">{{ user.email|default:user.get_username }}</span>.
Your prints stay on the dashboard either way -- you can come back any time.
</p>
<form method="post" action="{% url 'account_logout' %}" class="mt-6">
{% csrf_token %}
<button type="submit" class="w-full px-4 py-3 rounded-md bg-amber-500 text-white hover:bg-amber-600 font-medium">
Sign me out
</button>
</form>
<div class="mt-4 text-center">
<a href="{% url 'dashboard:my_prints' %}" class="text-sm text-slate-600 hover:underline">← Back to my prints</a>
</div>
</div>
</div>
{% endblock %}

78
templates/base.html Normal file
View File

@@ -0,0 +1,78 @@
{% load tailwind_cli %}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}hamprint — public 3D print dashboard{% endblock %}</title>
{% tailwind_css %}
<style>
body { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
</style>
{% block extra_head %}{% endblock %}
</head>
<body class="min-h-screen bg-slate-50 text-slate-900">
<nav class="border-b border-slate-200 bg-white">
<div class="mx-auto max-w-6xl px-4 py-3 flex items-center justify-between">
<a href="/" class="flex items-center gap-2">
<span class="inline-grid place-items-center w-8 h-8 rounded-md bg-amber-500 text-white font-bold">h</span>
<span class="font-bold text-lg tracking-tight">hamprint</span>
<span class="hidden sm:inline text-xs text-slate-500 ml-1">· hamlab.lt</span>
</a>
<div class="flex items-center gap-1">
<a href="/" class="px-3 py-1.5 text-sm rounded-md {% if request.path == '/' %}text-slate-900 bg-slate-100 font-medium{% else %}text-slate-700 hover:bg-slate-100{% endif %}">Dashboard</a>
{% if user.is_authenticated %}
<a href="{% url 'dashboard:my_prints' %}" class="px-3 py-1.5 text-sm rounded-md {% if request.resolver_match.view_name == 'dashboard:my_prints' %}text-slate-900 bg-slate-100 font-medium{% else %}text-slate-700 hover:bg-slate-100{% endif %}">My prints</a>
{% endif %}
<a href="/submit/" class="px-3 py-1.5 text-sm rounded-md bg-amber-500 text-white hover:bg-amber-600 font-medium">+ Submit a print</a>
{% if user.is_authenticated %}
<div class="ml-2 flex items-center gap-2 px-2 py-1">
<span class="w-7 h-7 rounded-full bg-gradient-to-br from-emerald-400 to-blue-500 grid place-items-center text-white text-xs font-bold">{{ user.username|slice:":1"|upper }}</span>
<span class="text-sm text-slate-700 hidden sm:inline">{{ user.username }}</span>
<a href="{% url 'account_logout' %}" title="Sign out" aria-label="Sign out" class="ml-1 p-1 rounded-md text-slate-400 hover:text-slate-700 hover:bg-slate-100">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9"/>
</svg>
</a>
</div>
{% else %}
<a href="{% url 'account_login' %}" class="px-3 py-1.5 text-sm rounded-md text-slate-700 hover:bg-slate-100">Sign in</a>
{% endif %}
</div>
</div>
</nav>
<main class="mx-auto max-w-6xl px-4 py-8">
{% if messages %}
{% comment %}
One styled card per Django messages.add_message() call. The text is
rendered via {{ message|safe }} so that callers that built their
string with django.utils.html.format_html() can include <a>/<strong>
markup (variables are auto-escaped by format_html).
{% endcomment %}
<div class="mb-6 space-y-2">
{% for message in messages %}
<div class="px-4 py-3 rounded-md border text-sm
{% if 'success' in message.tags %}border-emerald-200 bg-emerald-50 text-emerald-900
{% elif 'error' in message.tags %}border-red-200 bg-red-50 text-red-900
{% elif 'warning' in message.tags %}border-amber-200 bg-amber-50 text-amber-900
{% else %}border-slate-200 bg-slate-50 text-slate-700{% endif %}">
{{ message|safe }}
</div>
{% endfor %}
</div>
{% endif %}
{% block content %}{% endblock %}
</main>
<footer class="mt-12 border-t border-slate-200 bg-white">
<div class="mx-auto max-w-6xl px-4 py-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 text-sm text-slate-500">
<p>A community service of <a href="https://hamlab.lt" class="font-medium text-slate-700 hover:underline">hamlab.lt</a>.</p>
{% if user.is_staff %}<p>Operators: <a href="{% url 'admin:index' %}" class="hover:underline">admin panel</a>.</p>{% endif %}
</div>
</footer>
</body>
</html>

View File

@@ -0,0 +1,210 @@
{% extends "base.html" %}
{% load humanize %}
{% block title %}{{ submission.slug }} — hamprint{% endblock %}
{% block content %}
{% if not is_owner %}
{% comment %}
─── Minimal public view ────────────────────────────────────────────────
Shown to anonymous visitors AND to authenticated users who don't own
the row. Slug, status badge, age -- that's the contract. Anything more
could leak the submitter's notes, source URL, uploaded filename, etc.
{% endcomment %}
<div class="max-w-md mx-auto pt-12 pb-8 text-center">
<h1 class="mono text-4xl font-bold tracking-tight text-amber-700 mb-5 break-words">{{ submission.slug }}</h1>
<div class="mb-3">
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full {{ submission.status_badge_class }} text-sm font-medium">
{{ submission.get_status_display }}
</span>
</div>
<p class="text-sm text-slate-500">Submitted {{ submission.created_at|naturaltime }}</p>
{% if request.user.is_authenticated %}
<p class="mt-10 text-xs text-slate-500">
This isn't one of your submissions &mdash; only limited info is shown publicly.
</p>
{% else %}
<p class="mt-10 text-xs text-slate-500">
Public view of this submission. <a href="{% url 'account_login' %}" class="text-amber-700 hover:underline">Sign in</a> if this print is yours to see more.
</p>
{% endif %}
</div>
{% else %}
{% comment %}
─── Owner view ────────────────────────────────────────────────────────
Mirrors demo/detail-completed.html's structure: status banner, header
with slug + age, two-column grid with the substantive content on the
left and a Details sidebar on the right.
{% endcomment %}
{# Status banner -- one card per state, palette matches the badge. #}
{% if submission.status == 'completed' %}
<div class="rounded-lg border border-emerald-200 bg-emerald-50 p-5 mb-6 flex items-start gap-4">
<svg class="w-8 h-8 text-emerald-600 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd"/></svg>
<div class="flex-1">
<h2 class="font-semibold text-emerald-900">Ready for pickup!</h2>
<p class="text-sm text-emerald-900/80 mt-1">Your print is finished and waiting at the hamlab.lt space. See pickup instructions below.</p>
</div>
</div>
{% elif submission.status == 'printing' %}
<div class="rounded-lg border border-orange-200 bg-orange-50 p-5 mb-6 flex items-start gap-4">
<svg class="w-8 h-8 text-orange-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/></svg>
<div class="flex-1">
<h2 class="font-semibold text-orange-900">Currently printing</h2>
<p class="text-sm text-orange-900/80 mt-1">An operator is running this print at the hamlab.lt printer right now.</p>
</div>
</div>
{% elif submission.status == 'queued' %}
<div class="rounded-lg border border-blue-200 bg-blue-50 p-5 mb-6 flex items-start gap-4">
<svg class="w-8 h-8 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<div class="flex-1">
<h2 class="font-semibold text-blue-900">In the print queue</h2>
<p class="text-sm text-blue-900/80 mt-1">An operator approved your submission. Printing starts shortly.</p>
</div>
</div>
{% elif submission.status == 'verifying' %}
<div class="rounded-lg border border-violet-200 bg-violet-50 p-5 mb-6 flex items-start gap-4">
<svg class="w-8 h-8 text-violet-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<div class="flex-1">
<h2 class="font-semibold text-violet-900">Awaiting operator review</h2>
<p class="text-sm text-violet-900/80 mt-1">Auto-checks cleared. An operator will take a manual look next; you'll get an email when the status changes.</p>
</div>
</div>
{% elif submission.status == 'processing' %}
<div class="rounded-lg border border-slate-300 bg-slate-50 p-5 mb-6 flex items-start gap-4">
<svg class="w-8 h-8 text-slate-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"/></svg>
<div class="flex-1">
<h2 class="font-semibold text-slate-900">Running automated checks</h2>
<p class="text-sm text-slate-700 mt-1">We're validating your file or URL. This usually finishes in under a minute.</p>
</div>
</div>
{% elif submission.status == 'identifying' %}
<div class="rounded-lg border-2 border-yellow-300 bg-yellow-50 p-5 mb-6 flex items-start gap-4">
<svg class="w-8 h-8 text-yellow-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"/></svg>
<div class="flex-1">
<h2 class="font-semibold text-yellow-900">Check your inbox to confirm</h2>
<p class="text-sm text-yellow-900/80 mt-1">We sent a confirmation link. Click it within 24 hours or this submission (and any uploaded STL) will be deleted automatically.</p>
</div>
</div>
{% elif submission.status == 'rejected' %}
<div class="rounded-lg border border-red-200 bg-red-50 p-5 mb-6 flex items-start gap-4">
<svg class="w-8 h-8 text-red-600 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd"/></svg>
<div class="flex-1">
<h2 class="font-semibold text-red-900">Submission rejected</h2>
<p class="text-sm text-red-900/80 mt-1">{% if submission.operator_notes %}See the reason below.{% else %}No reason was recorded.{% endif %} This row will be deleted automatically within 24 hours.</p>
</div>
</div>
{% elif submission.status == 'failed' %}
<div class="rounded-lg border border-red-200 bg-red-50 p-5 mb-6 flex items-start gap-4">
<svg class="w-8 h-8 text-red-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"/></svg>
<div class="flex-1">
<h2 class="font-semibold text-red-900">Print failed</h2>
<p class="text-sm text-red-900/80 mt-1">{% if submission.operator_notes %}The operator left a comment below.{% else %}The operator hasn't left a comment yet.{% endif %}</p>
</div>
</div>
{% endif %}
<header class="mb-6 flex flex-wrap items-start justify-between gap-4">
<div>
<div class="flex items-center gap-3 mb-2">
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full {{ submission.status_badge_class }} text-xs font-medium">{{ submission.get_status_display }}</span>
<span class="text-xs text-slate-500">Submitted {{ submission.created_at|naturaltime }}</span>
</div>
<h1 class="mono text-3xl font-bold tracking-tight text-amber-700 break-words">{{ submission.slug }}</h1>
</div>
</header>
<div class="grid lg:grid-cols-3 gap-6">
<section class="lg:col-span-2 space-y-4">
{% if submission.operator_notes %}
<div class="bg-white border border-slate-200 rounded-lg p-5">
<h3 class="font-semibold text-sm uppercase tracking-wide text-slate-500 mb-3">
{% if submission.status == 'completed' %}Pickup instructions
{% elif submission.status == 'rejected' %}Reason for rejection
{% elif submission.status == 'failed' %}Operator comments
{% else %}Notes from the operator{% endif %}
</h3>
<p class="text-slate-800 text-sm whitespace-pre-line">{{ submission.operator_notes }}</p>
</div>
{% endif %}
{# Source: uploaded .stl OR external URL link #}
<div class="bg-white border border-slate-200 rounded-lg p-5">
<h3 class="font-semibold text-sm uppercase tracking-wide text-slate-500 mb-3">Source</h3>
{% if submission.source_type == 'upload' %}
<div class="flex items-center gap-3 p-3 rounded-md bg-slate-50 border border-slate-200">
<svg class="w-8 h-8 text-slate-400 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg>
<div class="flex-1 min-w-0">
<p class="font-medium text-sm break-all">{% if submission.stl_file %}{{ submission.stl_file.name }}{% else %}(file not on disk){% endif %}</p>
<p class="text-xs text-slate-500">Raw <span class="mono">.stl</span> upload</p>
</div>
</div>
{% else %}
<a href="{{ submission.source_url }}" target="_blank" rel="noopener" class="flex items-center gap-3 p-3 rounded-md bg-slate-50 border border-slate-200 hover:border-amber-400 hover:bg-amber-50/40">
<span class="w-10 h-10 rounded-md bg-amber-100 grid place-items-center text-amber-700 font-bold text-xs flex-shrink-0">{{ submission.source_type|slice:":1"|upper }}</span>
<div class="flex-1 min-w-0">
<p class="font-medium text-sm truncate">{{ submission.source_url }}</p>
<p class="text-xs text-slate-500">{{ submission.get_source_type_display }} &middot; external link</p>
</div>
<svg class="w-4 h-4 text-slate-400 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/></svg>
</a>
{% endif %}
</div>
{% comment %}
The user's own notes (private from public dashboard, but obviously
visible to the user themselves).
{% endcomment %}
{% if submission.notes_for_op %}
<div class="bg-white border border-slate-200 rounded-lg p-5">
<h3 class="font-semibold text-sm uppercase tracking-wide text-slate-500 mb-2">Your notes to the operator</h3>
<p class="text-slate-700 text-sm whitespace-pre-line">{{ submission.notes_for_op }}</p>
</div>
{% endif %}
</section>
<aside class="space-y-4">
<div class="bg-white border border-slate-200 rounded-lg p-5 text-sm">
<h3 class="font-semibold text-sm uppercase tracking-wide text-slate-500 mb-3">Details</h3>
<dl class="space-y-2">
<div class="flex justify-between gap-3">
<dt class="text-slate-500">Source</dt>
<dd class="text-right">{{ submission.source_label }}</dd>
</div>
{% if submission.requested_filament %}
<div class="flex justify-between gap-3">
<dt class="text-slate-500">Filament</dt>
<dd class="text-right">{{ submission.requested_filament.display_label }}</dd>
</div>
{% endif %}
<div class="flex justify-between gap-3">
<dt class="text-slate-500">Submitter</dt>
<dd class="text-right">{% if submission.submitted_by %}{{ submission.submitted_by.get_username }}{% else %}Guest{% endif %}</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-slate-500">Created</dt>
<dd class="text-right">{{ submission.created_at|naturaltime }}</dd>
</div>
{% if submission.closed_at %}
<div class="flex justify-between gap-3">
<dt class="text-slate-500">Closed</dt>
<dd class="text-right">{{ submission.closed_at|naturaltime }}{% if submission.closed_by %}, by <span class="font-medium">{{ submission.closed_by.get_username }}</span>{% endif %}</dd>
</div>
{% endif %}
</dl>
</div>
<div class="text-center">
<a href="{% url 'dashboard:index' %}" class="text-sm text-slate-500 hover:underline">&larr; Back to dashboard</a>
</div>
</aside>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,105 @@
{% extends "base.html" %}
{% load humanize %}
{% block title %}Print dashboard — hamprint{% endblock %}
{% block content %}
<header class="mb-6">
<h1 class="text-2xl font-bold tracking-tight">Print dashboard</h1>
<p class="text-slate-600 mt-1">Every public print job submitted to the hamlab.lt 3d printers. Look up your submission by its codename. Prints are <b>pickup-only</b>.</p>
</header>
{% comment %}
Status filter chips. The "All" chip is active when no ?status= filter is
set. Each specific chip is active when its value matches `active_status`.
{% endcomment %}
<div class="flex flex-wrap items-center gap-2 mb-4">
<a href="?" class="px-3 py-1.5 text-sm rounded-full border {% if not active_status %}border-slate-900 bg-slate-900 text-white{% else %}border-slate-300 bg-white text-slate-700 hover:bg-slate-100{% endif %}">
All <span class="{% if not active_status %}opacity-70{% else %}text-slate-400{% endif %}">{{ counts.total }}</span>
</a>
<a href="?status=verifying" class="px-3 py-1.5 text-sm rounded-full border {% if active_status == 'verifying' %}border-slate-900 bg-slate-900 text-white{% else %}border-slate-300 bg-white text-slate-700 hover:bg-slate-100{% endif %}">
Verifying <span class="{% if active_status == 'verifying' %}opacity-70{% else %}text-slate-400{% endif %}">{{ counts.verifying }}</span>
</a>
<a href="?status=queued" class="px-3 py-1.5 text-sm rounded-full border {% if active_status == 'queued' %}border-slate-900 bg-slate-900 text-white{% else %}border-slate-300 bg-white text-slate-700 hover:bg-slate-100{% endif %}">
Queued <span class="{% if active_status == 'queued' %}opacity-70{% else %}text-slate-400{% endif %}">{{ counts.queued }}</span>
</a>
<a href="?status=printing" class="px-3 py-1.5 text-sm rounded-full border {% if active_status == 'printing' %}border-slate-900 bg-slate-900 text-white{% else %}border-slate-300 bg-white text-slate-700 hover:bg-slate-100{% endif %}">
Printing <span class="{% if active_status == 'printing' %}opacity-70{% else %}text-slate-400{% endif %}">{{ counts.printing }}</span>
</a>
<a href="?status=completed" class="px-3 py-1.5 text-sm rounded-full border {% if active_status == 'completed' %}border-slate-900 bg-slate-900 text-white{% else %}border-slate-300 bg-white text-slate-700 hover:bg-slate-100{% endif %}">
Completed <span class="{% if active_status == 'completed' %}opacity-70{% else %}text-slate-400{% endif %}">{{ counts.completed }}</span>
</a>
</div>
{% if submissions %}
<div class="bg-white border border-slate-200 rounded-lg overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-slate-50 border-b border-slate-200 text-slate-600">
<tr class="text-left">
<th class="px-4 py-2.5 font-medium">Codename</th>
<th class="px-4 py-2.5 font-medium">Status</th>
<th class="px-4 py-2.5 font-medium hidden sm:table-cell">Age</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for sub in submissions %}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3">
<a href="{% url 'dashboard:detail' sub.slug %}" class="mono text-amber-700 hover:underline font-medium">{{ sub.slug }}</a>
{% if user.is_authenticated and sub.submitted_by_id == user.id %}
<span class="ml-2 inline-flex items-center px-1.5 py-0.5 rounded bg-amber-100 text-amber-900 text-[10px] font-semibold uppercase tracking-wide" title="You submitted this print">yours</span>
{% endif %}
</td>
<td class="px-4 py-3">
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full {{ sub.status_badge_class }} text-xs font-medium">{{ sub.get_status_display }}</span>
</td>
<td class="px-4 py-3 hidden sm:table-cell text-slate-500">{{ sub.created_at|naturaltime }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if is_paginated %}
<div class="flex items-center justify-between mt-4 text-sm">
<p class="text-slate-500">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
&middot; {{ page_obj.paginator.count }} total
</p>
<div class="flex items-center gap-1">
{% if page_obj.has_previous %}
<a href="?{% if active_status %}status={{ active_status }}&{% endif %}page={{ page_obj.previous_page_number }}" class="px-3 py-1.5 rounded-md border border-slate-300 bg-white text-slate-700 hover:bg-slate-100">← Previous</a>
{% else %}
<span class="px-3 py-1.5 rounded-md border border-slate-200 text-slate-400 cursor-not-allowed">← Previous</span>
{% endif %}
{% if page_obj.has_next %}
<a href="?{% if active_status %}status={{ active_status }}&{% endif %}page={{ page_obj.next_page_number }}" class="px-3 py-1.5 rounded-md border border-slate-300 bg-white text-slate-700 hover:bg-slate-100">Next →</a>
{% else %}
<span class="px-3 py-1.5 rounded-md border border-slate-200 text-slate-400 cursor-not-allowed">Next →</span>
{% endif %}
</div>
</div>
{% endif %}
{% else %}
<div class="rounded-lg border border-slate-200 bg-white p-8 text-center">
<p class="text-slate-700 font-medium">
{% if active_status %}No submissions in <span class="lowercase">{{ active_status }}</span> right now.{% else %}No submissions yet.{% endif %}
</p>
<p class="text-slate-500 text-sm mt-1">
{% if active_status %}Try one of the other filter chips above, or{% else %}Be the first &mdash;{% endif %}
submit a print and you'll see it appear here once an operator has verified it.
</p>
<a href="{% url 'submissions:create' %}" class="inline-block mt-5 px-4 py-2 rounded-md bg-amber-500 text-white hover:bg-amber-600 font-medium text-sm">+ Submit a print</a>
</div>
{% endif %}
{% if not user.is_authenticated %}
<div class="mt-10 rounded-lg border border-amber-200 bg-amber-50 p-4 flex items-start gap-3">
<div class="w-8 h-8 rounded-full bg-amber-500 text-white grid place-items-center flex-shrink-0 font-bold">?</div>
<div class="text-sm">
<p class="font-semibold text-amber-900">Don't have an account?</p>
<p class="text-amber-800/90 mt-0.5">You don't need one. Just hit <a href="{% url 'submissions:create' %}" class="underline font-medium">Submit a print</a>, give us an email, and we'll send you a codename to track your job.</p>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,90 @@
{% extends "base.html" %}
{% load humanize %}
{% block title %}My prints — hamprint{% endblock %}
{% block content %}
<header class="mb-6 flex items-end justify-between flex-wrap gap-3">
<div>
<h1 class="text-2xl font-bold tracking-tight">My prints</h1>
<p class="text-slate-600 mt-1">
All prints submitted with
<span class="font-medium text-slate-800">{{ user.email|default:user.get_username }}</span>.
</p>
</div>
<div class="text-sm text-slate-500">
<span class="font-medium text-slate-900">{{ counts.total }}</span> total
{% if counts.queued %}· <span class="font-medium text-blue-700">{{ counts.queued }}</span> queued{% endif %}
{% if counts.printing %}· <span class="font-medium text-orange-700">{{ counts.printing }}</span> printing{% endif %}
{% if counts.completed %}· <span class="font-medium text-emerald-700">{{ counts.completed }}</span> completed{% endif %}
</div>
</header>
{% if submissions %}
<div class="bg-white border border-slate-200 rounded-lg overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-slate-50 border-b border-slate-200 text-slate-600">
<tr class="text-left">
<th class="px-4 py-2.5 font-medium">Codename</th>
<th class="px-4 py-2.5 font-medium">Source</th>
<th class="px-4 py-2.5 font-medium">Status</th>
<th class="px-4 py-2.5 font-medium hidden sm:table-cell">Submitted</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for sub in submissions %}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3">
<a href="{% url 'dashboard:detail' sub.slug %}" class="mono text-amber-700 hover:underline font-medium">{{ sub.slug }}</a>
</td>
<td class="px-4 py-3 text-slate-600">{{ sub.source_label }}</td>
<td class="px-4 py-3">
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full {{ sub.status_badge_class }} text-xs font-medium">
{{ sub.get_status_display }}
</span>
</td>
<td class="px-4 py-3 hidden sm:table-cell text-slate-500">{{ sub.created_at|naturaltime }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if is_paginated %}
<div class="flex items-center justify-between mt-4 text-sm">
<p class="text-slate-500">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
&middot; {{ page_obj.paginator.count }} total
</p>
<div class="flex items-center gap-1">
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}" class="px-3 py-1.5 rounded-md border border-slate-300 bg-white text-slate-700 hover:bg-slate-100">← Previous</a>
{% else %}
<span class="px-3 py-1.5 rounded-md border border-slate-200 text-slate-400 cursor-not-allowed">← Previous</span>
{% endif %}
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}" class="px-3 py-1.5 rounded-md border border-slate-300 bg-white text-slate-700 hover:bg-slate-100">Next →</a>
{% else %}
<span class="px-3 py-1.5 rounded-md border border-slate-200 text-slate-400 cursor-not-allowed">Next →</span>
{% endif %}
</div>
</div>
{% endif %}
{% else %}
<div class="rounded-lg border border-slate-200 bg-white p-8 text-center">
<p class="text-slate-700 font-medium">No prints yet.</p>
<p class="text-slate-500 text-sm mt-1">Once you submit one, it'll show up here.</p>
<a href="{% url 'submissions:create' %}" class="inline-block mt-5 px-4 py-2 rounded-md bg-amber-500 text-white hover:bg-amber-600 font-medium text-sm">+ Submit a print</a>
</div>
{% endif %}
<div class="mt-6 flex items-center justify-between text-sm">
<a href="{% url 'submissions:create' %}" class="text-amber-700 hover:underline font-medium">+ Submit another print</a>
<div class="flex items-center gap-4">
{% if not user.is_staff %}
<a href="{% url 'accounts:close' %}" class="text-red-600 hover:underline">Close account</a>
{% endif %}
<a href="{% url 'account_logout' %}" class="text-slate-500 hover:underline">Sign out</a>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,70 @@
{# Base email shell. Child templates fill {% block body %}{% endblock %}. #}
{# Inline styles only -- most clients strip <style> tags or block CDNs. #}
{# Table-based layout -- Outlook/Windows Mail still rely on it for spacing. #}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<title>hamprint</title>
</head>
<body style="margin:0; padding:0; background-color:#f8fafc; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif; color:#0f172a; -webkit-font-smoothing:antialiased;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f8fafc;">
<tr>
<td align="center" style="padding:32px 16px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="560" style="max-width:560px; width:100%; background-color:#ffffff; border:1px solid #e2e8f0; border-radius:8px; box-shadow:0 1px 2px rgba(15,23,42,0.04);">
<!-- Header -->
<tr>
<td style="padding:20px 28px; border-bottom:1px solid #e2e8f0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="padding-right:10px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="32" height="32" style="background-color:#f59e0b; border-radius:6px;">
<tr>
<td align="center" valign="middle" height="32" style="color:#ffffff; font-weight:700; font-size:18px; line-height:1; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">h</td>
</tr>
</table>
</td>
<td style="vertical-align:middle;">
<span style="font-weight:700; font-size:18px; color:#0f172a; letter-spacing:-0.01em;">hamprint</span>
<span style="font-size:12px; color:#64748b; margin-left:4px;">· hamlab.lt</span>
</td>
</tr>
</table>
</td>
</tr>
<!-- Body -->
<tr>
<td style="padding:28px;">
{% block body %}{% endblock %}
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding:16px 28px; border-top:1px solid #e2e8f0; background-color:#f8fafc; border-bottom-left-radius:8px; border-bottom-right-radius:8px;">
<p style="margin:0; font-size:12px; color:#64748b; text-align:center;">
A community service of <a href="https://hamlab.lt" style="color:#475569; font-weight:500; text-decoration:none;">hamlab.lt</a>.
</p>
</td>
</tr>
</table>
<!-- Plain-text footnote that still degrades nicely in dark-mode clients -->
<p style="margin:16px 0 0 0; font-size:12px; color:#94a3b8; text-align:center; max-width:560px;">
You're receiving this because you submitted a print at hamprint.
</p>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,39 @@
{% extends "emails/_base.html" %}
{% block body %}
<h1 style="margin:0 0 12px 0; font-size:22px; font-weight:700; color:#0f172a; letter-spacing:-0.01em;">Confirm your submission</h1>
<p style="margin:0 0 18px 0; color:#334155; font-size:15px; line-height:1.55;">
You submitted a 3D print to hamlab.lt. Your codename is:
</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:0 0 22px 0;">
<tr>
<td style="padding:12px 16px; background-color:#fef3c7; border-radius:6px; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:18px; font-weight:700; color:#92400e;">
{{ submission.slug }}
</td>
</tr>
</table>
<p style="margin:0 0 24px 0; color:#334155; font-size:15px; line-height:1.55;">
To put it in the queue, click the button below within <strong style="color:#0f172a;">24 hours</strong>. Otherwise the submission (and any uploaded <span style="font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;">.stl</span>) is deleted automatically.
</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 24px 0;">
<tr>
<td style="background-color:#f59e0b; border-radius:6px;">
<a href="{{ confirm_url }}" style="display:inline-block; padding:12px 24px; color:#ffffff; font-weight:600; font-size:15px; text-decoration:none; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
Confirm my submission
</a>
</td>
</tr>
</table>
<p style="margin:0 0 4px 0; font-size:13px; color:#64748b;">Or copy this link into your browser:</p>
<p style="margin:0 0 24px 0; font-size:13px; color:#475569; word-break:break-all; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;">{{ confirm_url }}</p>
<hr style="margin:0 0 18px 0; border:none; border-top:1px solid #e2e8f0;">
<p style="margin:0; font-size:13px; color:#64748b; line-height:1.55;">
If you didn't submit this, you can safely ignore the email — nothing happens until you click the link.
</p>
{% endblock %}

View File

@@ -0,0 +1,16 @@
Hi,
You submitted a 3D print to hamlab.lt. Your codename is:
{{ submission.slug }}
To put it in the queue, click the link below within 24 hours. If you don't,
the submission (and any uploaded .stl) will be deleted automatically.
{{ confirm_url }}
If you didn't submit this, you can safely ignore this email -- nothing
happens until you click the link.
— hamprint
{{ site_url }}

View File

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

View File

@@ -0,0 +1,53 @@
{% extends "emails/_base.html" %}
{% block body %}
<h1 style="margin:0 0 12px 0; font-size:22px; font-weight:700; color:#991b1b; letter-spacing:-0.01em;">Submission rejected</h1>
<p style="margin:0 0 16px 0; color:#334155; font-size:15px; line-height:1.55;">
Unfortunately your submission <strong style="font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; color:#92400e; font-weight:700;">{{ submission.slug }}</strong> was rejected{% if previous_status %} during <em>{{ previous_status }}</em>{% endif %}.
</p>
{# Status pill -- coloured from `Submission.STATUS_EMAIL_COLORS` (red for `rejected`). #}
<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>
{% comment %}
Reason callout -- always rendered. Falls back to a placeholder if
operator_notes is somehow blank (shouldn't happen for a rejected row but
we're defensive).
{% endcomment %}
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:0 0 24px 0;">
<tr>
<td style="padding:16px; background-color:#fef2f2; border-left:3px solid #ef4444; border-radius:0 4px 4px 0;">
<p style="margin:0 0 6px 0; font-size:11px; font-weight:600; color:#991b1b; text-transform:uppercase; letter-spacing:0.06em;">
Reason
</p>
<p style="margin:0; color:#334155; font-size:14px; line-height:1.55; white-space:pre-line;">{{ submission.operator_notes|default:"(no reason recorded)" }}</p>
</td>
</tr>
</table>
<p style="margin:0 0 20px 0; color:#334155; font-size:15px; line-height:1.55;">
You're welcome to fix the issue and submit a new print. This rejected submission will be removed from our records in 24 hours.
</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="background-color:#f59e0b; border-radius:6px;">
<a href="{{ site_url }}/submit/" 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;">
Try a new submission
</a>
</td>
</tr>
</table>
<p style="margin:18px 0 0 0; font-size:13px; color:#64748b; line-height:1.55;">
Think this rejection was a mistake? Reply to this email or reach out to the operators at <a href="https://hamlab.lt" style="color:#475569; text-decoration:underline;">hamlab.lt</a>.
</p>
{% endblock %}

View File

@@ -0,0 +1,17 @@
Hi,
Unfortunately your hamprint submission "{{ submission.slug }}" was rejected{% if previous_status %} during {{ previous_status }}{% endif %}.
Reason:
{{ submission.operator_notes|default:"(no reason recorded)" }}
You're welcome to fix the issue and submit a new print -- the form is at
{{ site_url }}/submit/. This rejected submission will be removed from our
records in 24 hours.
If you think this rejection was a mistake, reply to this email or reach
out to the operators at hamlab.lt.
— hamprint
{{ site_url }}

View File

@@ -0,0 +1 @@
Your hamprint submission {{ submission.slug }} was rejected

View File

@@ -0,0 +1,37 @@
{% extends "emails/_base.html" %}
{% block body %}
<h1 style="margin:0 0 12px 0; font-size:22px; font-weight:700; color:#0f172a; letter-spacing:-0.01em;">Cleared auto-checks</h1>
<p style="margin:0 0 16px 0; color:#334155; font-size:15px; line-height:1.55;">
Your submission <strong style="font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; color:#92400e; font-weight:700;">{{ submission.slug }}</strong> passed our automated checks (size, format, host allow-list) and is now waiting for an operator to take a manual look.
</p>
{# Pill colours come from `Submission.STATUS_EMAIL_COLORS` -- same per-status palette as the website badge. #}
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 20px 0;">
<tr>
<td style="padding:6px 14px; background-color:{{ submission.status_email_style.bg }}; border-radius:9999px;">
<span style="color:{{ submission.status_email_style.fg }}; font-weight:600; font-size:14px; letter-spacing:0.01em;">
{{ submission.get_status_display }}
</span>
</td>
</tr>
</table>
<p style="margin:0 0 24px 0; color:#334155; font-size:15px; line-height:1.55;">
If the operator is happy with the print job, it moves into the print queue shortly after &mdash; and we'll email you again once that happens. You don't need to do anything right now.
</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="background-color:#f59e0b; border-radius:6px;">
<a href="{{ detail_url }}" style="display:inline-block; padding:12px 24px; color:#ffffff; font-weight:600; font-size:15px; text-decoration:none; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
View submission
</a>
</td>
</tr>
</table>
<p style="margin:18px 0 0 0; font-size:13px; color:#64748b;">
Direct link: <span style="color:#475569; word-break:break-all; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;">{{ detail_url }}</span>
</p>
{% endblock %}

View File

@@ -0,0 +1,18 @@
Hi,
Good news -- your hamprint submission "{{ submission.slug }}" passed the
automated checks (size, format, host allow-list) and is now waiting for an
operator to take a manual look.
Codename : {{ submission.slug }}
Status : Verifying
If the operator is happy with the print job, it moves into the print queue
shortly after and we'll email you again. You don't need to do anything
right now.
You can also check on it here:
{{ detail_url }}
— hamprint
{{ site_url }}

View File

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

View File

@@ -0,0 +1,189 @@
{% extends "base.html" %}
{% block title %}Submit a print — hamprint{% endblock %}
{% block extra_head %}
<style>
/* Show/hide source-input panes based on the radio selection. The radios
live inside a grid <div> while the panes are siblings of the grid, so a
plain `~` combinator never matches. `fieldset:has(...)` reaches across
the grid and selects the right pane regardless of nesting depth. */
.src-pane { display: none; }
fieldset:has(#src-upload:checked) .src-pane[data-pane="upload"],
fieldset:has(#src-printables:checked) .src-pane[data-pane="url"],
fieldset:has(#src-makerworld:checked) .src-pane[data-pane="url"],
fieldset:has(#src-thingiverse:checked) .src-pane[data-pane="url"] { display: block; }
</style>
{% endblock %}
{% block content %}
<div class="max-w-3xl mx-auto">
<header class="mb-6">
<h1 class="text-2xl font-bold tracking-tight">Submit a print</h1>
<p class="text-slate-600 mt-1">Anyone can submit a job. We'll send you a codename to follow it on the dashboard.</p>
</header>
{% if user.is_authenticated %}
<div class="rounded-lg border border-emerald-200 bg-emerald-50 p-4 mb-6 flex items-start gap-3 text-sm">
<svg class="w-5 h-5 text-emerald-600 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd"/></svg>
<p class="text-emerald-900">Signed in as <span class="font-medium">{{ user.email|default:user.get_username }}</span>. No email confirmation needed.</p>
</div>
{% else %}
<div class="rounded-lg border border-slate-200 bg-white p-4 mb-6 flex items-start gap-3 text-sm">
<span class="px-2 py-0.5 rounded-md bg-slate-100 text-slate-700 text-xs font-medium uppercase tracking-wide">Guest</span>
<p class="text-slate-700">You're submitting anonymously. We'll email you a confirmation link &mdash; click it within <span class="font-medium">24 hours</span> to put your print in the queue. <a href="{% url 'account_login' %}" class="text-amber-700 font-medium hover:underline">Sign in with Google</a> to skip this step.</p>
</div>
{% endif %}
<form method="post" enctype="multipart/form-data" class="space-y-6 bg-white border border-slate-200 rounded-lg p-6">
{% csrf_token %}
{% if form.non_field_errors %}
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800">
{{ form.non_field_errors|join:" " }}
</div>
{% endif %}
{% comment %}
Source-type radio cards. Hardcoded (not looped) so Tailwind's scanner
sees the literal `peer/<name>` and `peer-checked/<name>:` class strings
and emits the matching CSS rules.
{% endcomment %}
<fieldset>
<legend class="block text-sm font-medium text-slate-900 mb-2">Where is the model coming from? <span class="text-red-500">*</span></legend>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2 mb-4">
<input type="radio" name="{{ form.source_type.html_name }}" id="src-upload" value="upload" class="hidden peer/upload" {% if form.source_type.value == "upload" or not form.source_type.value %}checked{% endif %}>
<label for="src-upload" class="cursor-pointer border border-slate-300 rounded-md px-3 py-2 text-center text-sm hover:border-amber-500 peer-checked/upload:border-amber-500 peer-checked/upload:bg-amber-50 peer-checked/upload:text-amber-900 peer-checked/upload:font-medium">.stl file</label>
<input type="radio" name="{{ form.source_type.html_name }}" id="src-printables" value="printables" class="hidden peer/printables" {% if form.source_type.value == "printables" %}checked{% endif %}>
<label for="src-printables" class="cursor-pointer border border-slate-300 rounded-md px-3 py-2 text-center text-sm hover:border-amber-500 peer-checked/printables:border-amber-500 peer-checked/printables:bg-amber-50 peer-checked/printables:text-amber-900 peer-checked/printables:font-medium">Printables</label>
<input type="radio" name="{{ form.source_type.html_name }}" id="src-makerworld" value="makerworld" class="hidden peer/makerworld" {% if form.source_type.value == "makerworld" %}checked{% endif %}>
<label for="src-makerworld" class="cursor-pointer border border-slate-300 rounded-md px-3 py-2 text-center text-sm hover:border-amber-500 peer-checked/makerworld:border-amber-500 peer-checked/makerworld:bg-amber-50 peer-checked/makerworld:text-amber-900 peer-checked/makerworld:font-medium">MakerWorld</label>
<input type="radio" name="{{ form.source_type.html_name }}" id="src-thingiverse" value="thingiverse" class="hidden peer/thingiverse" {% if form.source_type.value == "thingiverse" %}checked{% endif %}>
<label for="src-thingiverse" class="cursor-pointer border border-slate-300 rounded-md px-3 py-2 text-center text-sm hover:border-amber-500 peer-checked/thingiverse:border-amber-500 peer-checked/thingiverse:bg-amber-50 peer-checked/thingiverse:text-amber-900 peer-checked/thingiverse:font-medium">Thingiverse</label>
</div>
{% comment %}
Upload pane. The native file input stays sr-only and is triggered by
the wrapping <label> dropzone. After selection, the prompt text is
swapped for the filename + size via the inline script below.
{% endcomment %}
<div class="src-pane" data-pane="upload">
<label class="block cursor-pointer">
<div class="border-2 border-dashed border-slate-300 rounded-md p-6 text-center hover:border-amber-500 hover:bg-amber-50/40 transition">
<svg class="w-8 h-8 mx-auto text-slate-400" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 16.5V9.75m0 0L9 12.75M12 9.75l3 3M3 17.25V18a2.25 2.25 0 002.25 2.25h13.5A2.25 2.25 0 0021 18v-.75"/></svg>
<p data-stl-prompt class="mt-2 text-sm font-medium text-slate-700">Drop your .stl here or click to browse</p>
<p data-stl-filename class="hidden mt-2 text-sm font-medium text-amber-700"></p>
<p class="text-xs text-slate-500 mt-1">Raw <span class="mono">.stl</span> only, max 4 MB. No <span class="mono">.3mf</span>, <span class="mono">.gcode</span>, <span class="mono">.zip</span>, etc.</p>
</div>
{{ form.stl_file }}
</label>
{% if form.stl_file.errors %}<p class="text-xs text-red-600 mt-1">{{ form.stl_file.errors|join:" " }}</p>{% endif %}
</div>
{% comment %}
Shared URL pane. The label, placeholder, and help text are rewritten
per source-type by the inline script below so the user gets host-
specific guidance even though there's only one real <input>.
{% endcomment %}
<div class="src-pane" data-pane="url">
<label for="{{ form.source_url.id_for_label }}" data-url-label class="block text-sm font-medium text-slate-700 mb-1">Model URL</label>
{{ form.source_url }}
<p data-url-help class="text-xs text-slate-500 mt-1">Must be a link on <span class="mono">printables.com</span>, <span class="mono">makerworld.com</span>, or <span class="mono">thingiverse.com</span> matching your selection above.</p>
{% if form.source_url.errors %}<p class="text-xs text-red-600 mt-1">{{ form.source_url.errors|join:" " }}</p>{% endif %}
</div>
<p class="text-xs text-slate-500 mt-3">Got something from elsewhere? Download the <span class="mono">.stl</span> and upload it as a file.</p>
{% if form.source_type.errors %}<p class="text-xs text-red-600 mt-1">{{ form.source_type.errors|join:" " }}</p>{% endif %}
</fieldset>
{# Filament #}
<div>
<label for="{{ form.requested_filament.id_for_label }}" class="block text-sm font-medium text-slate-900 mb-1">Filament</label>
{{ form.requested_filament }}
<p class="text-xs text-slate-500 mt-1">Only filaments currently loaded at hamlab.lt are listed. The list is curated by operators &mdash; out-of-stock options are hidden.</p>
{% if form.requested_filament.errors %}<p class="text-xs text-red-600 mt-1">{{ form.requested_filament.errors|join:" " }}</p>{% endif %}
</div>
{# Notes (private) #}
<div>
<label for="{{ form.notes_for_op.id_for_label }}" class="block text-sm font-medium text-slate-900 mb-1">
Notes for the operator
<span class="ml-1 inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-slate-100 text-slate-600 text-[10px] font-medium uppercase tracking-wide align-middle">
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"/></svg>
Private
</span>
</label>
{{ form.notes_for_op }}
<p class="text-xs text-slate-500 mt-1">Only the operator sees these &mdash; they're not shown on your public submission page.</p>
{% if form.notes_for_op.errors %}<p class="text-xs text-red-600 mt-1">{{ form.notes_for_op.errors|join:" " }}</p>{% endif %}
</div>
{% if form.guest_email %}
<div class="border-t border-slate-200 pt-6">
<label for="{{ form.guest_email.id_for_label }}" class="block text-sm font-medium text-slate-900 mb-1">Your email <span class="text-red-500">*</span></label>
{{ form.guest_email }}
<p class="text-xs text-slate-500 mt-1">We'll send a confirmation link. The submission disappears in 24 hours if not confirmed. Never shown publicly.</p>
{% if form.guest_email.errors %}<p class="text-xs text-red-600 mt-1">{{ form.guest_email.errors|join:" " }}</p>{% endif %}
</div>
{% endif %}
<div class="flex items-center justify-end gap-3 border-t border-slate-200 pt-6">
<a href="{% url 'dashboard:index' %}" class="px-4 py-2 text-sm rounded-md text-slate-700 hover:bg-slate-100">Cancel</a>
<button type="submit" class="px-4 py-2 text-sm rounded-md bg-amber-500 text-white hover:bg-amber-600 font-medium">Submit print</button>
</div>
</form>
</div>
<script>
// Filename feedback: swap "Drop your .stl here" for the selected file's name
// + size as soon as the user picks something via the hidden file input.
(function() {
var input = document.getElementById("{{ form.stl_file.id_for_label }}");
var prompt = document.querySelector("[data-stl-prompt]");
var filename = document.querySelector("[data-stl-filename]");
if (!input || !prompt || !filename) return;
input.addEventListener("change", function(e) {
var file = e.target.files && e.target.files[0];
if (file) {
prompt.classList.add("hidden");
filename.classList.remove("hidden");
filename.textContent = "✓ " + file.name + " (" + Math.round(file.size / 1024) + " KB)";
} else {
prompt.classList.remove("hidden");
filename.classList.add("hidden");
filename.textContent = "";
}
});
})();
// URL-pane copy: rewrite the label, the input's placeholder, and the help
// text per source-type so the user sees host-specific guidance.
(function() {
var COPY = {
printables: { label: "Printables.com URL", ph: "https://www.printables.com/model/…", help: "Must be a printables.com link." },
makerworld: { label: "MakerWorld URL", ph: "https://makerworld.com/en/models/…", help: "Must be a makerworld.com link." },
thingiverse: { label: "Thingiverse URL", ph: "https://www.thingiverse.com/thing:…", help: "Must be a thingiverse.com link." }
};
var labelEl = document.querySelector("[data-url-label]");
var inputEl = document.getElementById("{{ form.source_url.id_for_label }}");
var helpEl = document.querySelector("[data-url-help]");
function apply(sourceType) {
var c = COPY[sourceType];
if (!c) return;
if (labelEl) labelEl.textContent = c.label;
if (inputEl) inputEl.placeholder = c.ph;
if (helpEl) helpEl.textContent = c.help;
}
document.querySelectorAll('input[name="{{ form.source_type.html_name }}"]').forEach(function(radio) {
radio.addEventListener("change", function(e) { apply(e.target.value); });
if (radio.checked) apply(radio.value);
});
})();
</script>
{% endblock %}