Compare commits
13 Commits
3233650c07
...
email_noti
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d2cf46f32 | |||
| 24624cc4d1 | |||
| 526fec314d | |||
| 05ac0057a6 | |||
| 15dc6147dd | |||
| 219f0a5259 | |||
| 46fc07a1ae | |||
| 569d57e144 | |||
| 9e16b78793 | |||
| fe62575790 | |||
| 6f19d10426 | |||
| 0fdb8b8a02 | |||
| c451a106a1 |
50
.dockerignore
Normal file
50
.dockerignore
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Keep the image lean and reproducible: don't ship anything we'd regenerate
|
||||||
|
# inside the container anyway, and never ship host-only / secret state.
|
||||||
|
|
||||||
|
# Host venv -- container has its own Python install.
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# VCS metadata.
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
|
||||||
|
# Local databases / dev artefacts.
|
||||||
|
*.sqlite3
|
||||||
|
db.sqlite3*
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.swp
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
|
||||||
|
# Secrets / env files: container reads .env via --env-file or compose env_file:.
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Tailwind binary cache: re-downloaded by `manage.py tailwind build` during
|
||||||
|
# image build, then removed in the same layer. Host copy might be a different
|
||||||
|
# arch / version, so always skip it.
|
||||||
|
.django_tailwind_cli/
|
||||||
|
|
||||||
|
# Output of `collectstatic` -- the Containerfile runs this fresh at build time.
|
||||||
|
staticfiles/
|
||||||
|
|
||||||
|
# Documentation, design assets, and prototype HTML that operators don't need
|
||||||
|
# at runtime.
|
||||||
|
demo/
|
||||||
|
plan.md
|
||||||
|
plan.pdf
|
||||||
|
plan.html
|
||||||
|
CLAUDE.md
|
||||||
|
CONTRIBUTING.md
|
||||||
|
README.md
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# IDE / cache directories.
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
node_modules/
|
||||||
24
.env.example
24
.env.example
@@ -9,6 +9,11 @@ SECRET_KEY=replace-me-with-a-long-random-string
|
|||||||
DEBUG=true
|
DEBUG=true
|
||||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||||
|
|
||||||
|
# Public base URL used in outgoing emails (confirmation link, detail-page
|
||||||
|
# link). Defaults to http://localhost:8000 if unset. In production set to
|
||||||
|
# e.g. https://print.hamlab.lt -- no trailing slash.
|
||||||
|
SITE_URL=http://localhost:8000
|
||||||
|
|
||||||
# --- PostgreSQL ---
|
# --- PostgreSQL ---
|
||||||
# Used by the `db` container and interpolated into DATABASE_URL inside
|
# Used by the `db` container and interpolated into DATABASE_URL inside
|
||||||
# compose.yaml; the `web` container reads DATABASE_URL via dj-database-url.
|
# compose.yaml; the `web` container reads DATABASE_URL via dj-database-url.
|
||||||
@@ -16,15 +21,20 @@ POSTGRES_DB=hamprint
|
|||||||
POSTGRES_USER=hamprint
|
POSTGRES_USER=hamprint
|
||||||
POSTGRES_PASSWORD=changeme
|
POSTGRES_PASSWORD=changeme
|
||||||
|
|
||||||
# --- Mailjet (transactional email) ---
|
# --- Mailtrap (transactional email) ---
|
||||||
# Only used when running with `hamprint.settings.prod`; `hamprint.settings.dev`
|
# When MAILTRAP_API_TOKEN is set, Django sends through Mailtrap's HTTPS API
|
||||||
# overrides EMAIL_BACKEND to the console backend so emails are printed to
|
# via django-anymail. When blank, settings/base.py falls back to the console
|
||||||
# the `web` container logs.
|
# email backend -- handy for local development without burning real quota or
|
||||||
# Get keys at https://app.mailjet.com/account/apikeys
|
# spamming real addresses.
|
||||||
MAILJET_API_KEY=
|
# Get a token at https://mailtrap.io/api-tokens
|
||||||
MAILJET_API_SECRET=
|
MAILTRAP_API_TOKEN=
|
||||||
DEFAULT_FROM_EMAIL=hamprint <noreply@hamlab.lt>
|
DEFAULT_FROM_EMAIL=hamprint <noreply@hamlab.lt>
|
||||||
|
|
||||||
|
# Optional: set to a Mailtrap testing-inbox ID to capture outgoing mail in a
|
||||||
|
# sandbox inbox instead of actually delivering it. Find the ID in the URL
|
||||||
|
# of the inbox at https://mailtrap.io/inboxes. Leave blank for real sending.
|
||||||
|
MAILTRAP_TEST_INBOX_ID=
|
||||||
|
|
||||||
# --- Google OAuth (django-allauth Google provider) ---
|
# --- Google OAuth (django-allauth Google provider) ---
|
||||||
# Configure at https://console.cloud.google.com/apis/credentials and add a
|
# Configure at https://console.cloud.google.com/apis/credentials and add a
|
||||||
# SocialApp via Django admin (/admin/socialaccount/socialapp/add/).
|
# SocialApp via Django admin (/admin/socialaccount/socialapp/add/).
|
||||||
|
|||||||
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal 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
62
Containerfile
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# hamprint -- single-container image (plan.md §10).
|
||||||
|
#
|
||||||
|
# Runs Gunicorn plus the two periodic jobs (process_submissions every 30 s,
|
||||||
|
# cleanup_stale every 5 min) in the same process group. No sidecars.
|
||||||
|
#
|
||||||
|
# Build: podman build -t hamprint:latest .
|
||||||
|
# Run: podman run --rm -p 8000:8000 --env-file .env hamprint:latest
|
||||||
|
#
|
||||||
|
# In production the host typically mounts a volume at /app/media so uploaded
|
||||||
|
# STLs survive container restarts; the database connection comes from
|
||||||
|
# DATABASE_URL (Postgres in prod, SQLite if you really want).
|
||||||
|
|
||||||
|
FROM docker.io/library/python:3.14-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
DJANGO_SETTINGS_MODULE=hamprint.settings.prod
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# System packages python:3.14-slim doesn't ship:
|
||||||
|
# tini -- PID 1 for clean signal forwarding to gunicorn + the loops
|
||||||
|
# libgomp1 -- numpy/numpy-stl runtime on some kernels
|
||||||
|
# curl -- handy for healthchecks if compose.yaml grows one
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends tini curl libgomp1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Python deps as their own layer so app-code edits don't invalidate the wheel
|
||||||
|
# cache. psycopg[binary] is added explicitly because requirements.txt is
|
||||||
|
# kept Postgres-driver-agnostic for the host-venv (SQLite) path.
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install -r requirements.txt 'psycopg[binary]'
|
||||||
|
|
||||||
|
# Application code (.dockerignore excludes .venv, .git, db.sqlite3, demo/, etc).
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build Tailwind CSS, gather everything under STATIC_ROOT for WhiteNoise, then
|
||||||
|
# drop the ~120 MB Tailwind CLI download so it doesn't ride along. The source
|
||||||
|
# .css lives at assets/tailwind.source.css (per TAILWIND_CLI_SRC_CSS in
|
||||||
|
# settings/base.py); only the CLI binary cache is purged.
|
||||||
|
RUN python manage.py tailwind build --force \
|
||||||
|
&& python manage.py collectstatic --noinput \
|
||||||
|
&& rm -rf /app/.django_tailwind_cli
|
||||||
|
|
||||||
|
# Default writable dirs. Mount a volume at /app/media in prod for persistence.
|
||||||
|
RUN mkdir -p /app/media /app/staticfiles
|
||||||
|
|
||||||
|
# Drop privileges. uid 1000 maps cleanly to the typical host user in rootless
|
||||||
|
# podman, so a bind-mounted media volume stays writable without extra fuss.
|
||||||
|
RUN useradd -m -u 1000 app \
|
||||||
|
&& chown -R app:app /app
|
||||||
|
USER app
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# tini reaps zombies + forwards SIGTERM to all children, so when the orchestrator
|
||||||
|
# stops the container both Gunicorn AND the two background loops get the signal.
|
||||||
|
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||||
|
CMD ["bash", "entrypoint.sh"]
|
||||||
14
apps/accounts/urls.py
Normal file
14
apps/accounts/urls.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
"""Local additions under `/accounts/`. Mounted in `hamprint/urls.py` BEFORE
|
||||||
|
`include("allauth.urls")` so any path defined here wins; everything we
|
||||||
|
don't claim falls through to allauth.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = "accounts"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("close/", views.close_account, name="close"),
|
||||||
|
]
|
||||||
@@ -1,3 +1,68 @@
|
|||||||
from django.shortcuts import render
|
"""Local account views -- the ones allauth doesn't ship.
|
||||||
|
|
||||||
# Create your views here.
|
Today: just `close_account` ("permanently delete this user"). Logout is
|
||||||
|
allauth's; we override its template, not its view.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth import logout
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.db import transaction
|
||||||
|
from django.http import HttpResponseForbidden
|
||||||
|
from django.shortcuts import redirect, render
|
||||||
|
from django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
|
from apps.submissions.models import Submission
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def close_account(request):
|
||||||
|
"""Permanently delete the signed-in user and all of their submissions.
|
||||||
|
|
||||||
|
GET -> render a "are you sure?" confirmation page, showing how many of
|
||||||
|
their submissions will go with the user.
|
||||||
|
POST -> delete the rows, log the user out, redirect to the dashboard.
|
||||||
|
|
||||||
|
Refuses staff users (`is_staff=True`): they'd be locking themselves
|
||||||
|
(and possibly the only operator) out of /admin/ with no recourse, so
|
||||||
|
that path requires another operator to remove the row via
|
||||||
|
/admin/auth/user/ instead.
|
||||||
|
|
||||||
|
Why delete the submissions too: `Submission.submitted_by` uses
|
||||||
|
`on_delete=SET_NULL`, but the `CheckConstraint` on the model requires
|
||||||
|
that EITHER `submitted_by` OR `guest_email` is non-null. OAuth-created
|
||||||
|
submissions have `guest_email=NULL`, so SET_NULL would violate the
|
||||||
|
constraint at delete time. Simpler + matches user expectation of
|
||||||
|
"delete my account": wipe the rows wholesale. The `post_delete` signal
|
||||||
|
in `apps/submissions/signals.py` unlinks the uploaded STLs at the
|
||||||
|
same time.
|
||||||
|
"""
|
||||||
|
if request.user.is_staff:
|
||||||
|
return HttpResponseForbidden(
|
||||||
|
"Staff users cannot close their own account from here. "
|
||||||
|
"Ask another operator to remove the row via /admin/auth/user/."
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
user = request.user
|
||||||
|
username = user.get_username()
|
||||||
|
with transaction.atomic():
|
||||||
|
Submission.objects.filter(submitted_by=user).delete()
|
||||||
|
logout(request)
|
||||||
|
user.delete()
|
||||||
|
messages.info(
|
||||||
|
request,
|
||||||
|
f"Account {username} and all of your prints have been "
|
||||||
|
f"permanently deleted.",
|
||||||
|
)
|
||||||
|
return redirect("dashboard:index")
|
||||||
|
|
||||||
|
submission_count = Submission.objects.filter(submitted_by=request.user).count()
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"account/close.html",
|
||||||
|
{"submission_count": submission_count},
|
||||||
|
)
|
||||||
|
|||||||
15
apps/dashboard/urls.py
Normal file
15
apps/dashboard/urls.py
Normal 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"),
|
||||||
|
]
|
||||||
@@ -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> — "
|
||||||
|
"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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
177
apps/submissions/emails.py
Normal file
177
apps/submissions/emails.py
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
"""Outgoing email -- plan.md §7 (state-transition side effects).
|
||||||
|
|
||||||
|
Five 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.*`.
|
||||||
|
|
||||||
|
send_printing_email(submission)
|
||||||
|
Fired on any transition into `status = printing` (operator clicks
|
||||||
|
"Start printing" in admin). Excited tone: "your print is on the
|
||||||
|
bed right now". Template: `emails/printing.*`.
|
||||||
|
|
||||||
|
send_completed_email(submission)
|
||||||
|
Fired on any transition into `status = completed` (operator clicks
|
||||||
|
"Mark completed"). Pickup-ready announcement; renders
|
||||||
|
`submission.operator_notes` as a "note from the operator" callout
|
||||||
|
when present (typically pickup instructions). Template:
|
||||||
|
`emails/completed.*`.
|
||||||
|
|
||||||
|
All five 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})
|
||||||
|
|
||||||
|
|
||||||
|
def send_printing_email(sub: Submission) -> bool:
|
||||||
|
"""Notify the submitter that the print has just started (plan.md §7.3
|
||||||
|
`queued -> printing` transition). Excited tone -- the operator just
|
||||||
|
clicked "Start printing" in admin and the first layer is going down."""
|
||||||
|
detail_url = f"{settings.SITE_URL}/p/{sub.slug}/"
|
||||||
|
return _send("printing", sub, {"detail_url": detail_url})
|
||||||
|
|
||||||
|
|
||||||
|
def send_completed_email(sub: Submission) -> bool:
|
||||||
|
"""Notify the submitter that the print finished successfully and is
|
||||||
|
ready for pickup (plan.md §7.3 `printing -> completed` transition).
|
||||||
|
`submission.operator_notes` is rendered when present so any
|
||||||
|
pickup-instruction the operator typed in admin reaches the user."""
|
||||||
|
detail_url = f"{settings.SITE_URL}/p/{sub.slug}/"
|
||||||
|
return _send("completed", sub, {"detail_url": detail_url})
|
||||||
175
apps/submissions/forms.py
Normal file
175
apps/submissions/forms.py
Normal 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
|
||||||
0
apps/submissions/management/__init__.py
Normal file
0
apps/submissions/management/__init__.py
Normal file
0
apps/submissions/management/commands/__init__.py
Normal file
0
apps/submissions/management/commands/__init__.py
Normal file
74
apps/submissions/management/commands/cleanup_stale.py
Normal file
74
apps/submissions/management/commands/cleanup_stale.py
Normal 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."
|
||||||
|
)
|
||||||
|
)
|
||||||
82
apps/submissions/management/commands/process_submissions.py
Normal file
82
apps/submissions/management/commands/process_submissions.py
Normal 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}")
|
||||||
193
apps/submissions/migrations/0001_initial.py
Normal file
193
apps/submissions/migrations/0001_initial.py
Normal 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",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
36
apps/submissions/migrations/0002_verifiedemail.py
Normal file
36
apps/submissions/migrations/0002_verifiedemail.py
Normal 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",),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
39
apps/submissions/migrations/0003_backfill_verified_emails.py
Normal file
39
apps/submissions/migrations/0003_backfill_verified_emails.py
Normal 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),
|
||||||
|
]
|
||||||
51
apps/submissions/migrations/0004_email_normalization.py
Normal file
51
apps/submissions/migrations/0004_email_normalization.py
Normal 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",)},
|
||||||
|
),
|
||||||
|
]
|
||||||
92
apps/submissions/migrations/0005_normalize_existing_data.py
Normal file
92
apps/submissions/migrations/0005_normalize_existing_data.py
Normal 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),
|
||||||
|
]
|
||||||
@@ -1,3 +1,438 @@
|
|||||||
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 a state with a
|
||||||
|
dedicated email (`rejected`, `printing`, `completed`), this method
|
||||||
|
queues the matching `send_*_email()` via `transaction.on_commit`.
|
||||||
|
Centralising the dispatch 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 into one of
|
||||||
|
# the email-bearing target states. Don't fire on inserts that start
|
||||||
|
# out in those states -- by plan.md §7.3 no submit-time edge lands
|
||||||
|
# in rejected/printing/completed, and even if some weird path did,
|
||||||
|
# we'd rather stay silent than send "your print is ready" to a fresh
|
||||||
|
# victim of a fixture/data-migration import.
|
||||||
|
if not is_new and old_status != new_status:
|
||||||
|
# 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_completed_email,
|
||||||
|
send_printing_email,
|
||||||
|
send_rejection_email,
|
||||||
|
)
|
||||||
|
|
||||||
|
if new_status == self.Status.REJECTED:
|
||||||
|
transaction.on_commit(
|
||||||
|
lambda sub=self, prev=old_status: send_rejection_email(
|
||||||
|
sub, previous_status=prev
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif new_status == self.Status.PRINTING:
|
||||||
|
transaction.on_commit(
|
||||||
|
lambda sub=self: send_printing_email(sub)
|
||||||
|
)
|
||||||
|
elif new_status == self.Status.COMPLETED:
|
||||||
|
transaction.on_commit(
|
||||||
|
lambda sub=self: send_completed_email(sub)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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"}
|
||||||
|
)
|
||||||
|
|||||||
29
apps/submissions/signals.py
Normal file
29
apps/submissions/signals.py
Normal 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
9
apps/submissions/urls.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = "submissions"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", views.SubmitView.as_view(), name="create"),
|
||||||
|
]
|
||||||
190
apps/submissions/validation.py
Normal file
190
apps/submissions/validation.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
"""STL + URL validation for the `processing` worker (plan.md §7.5.2 / §7.5.3).
|
||||||
|
|
||||||
|
Two public callables:
|
||||||
|
|
||||||
|
validate_stl_file(path)
|
||||||
|
Magic-byte probe + numpy-stl mesh load + bounding-box vs build plate +
|
||||||
|
triangle-count sanity. Raises `ValidationError` with the user-visible
|
||||||
|
reason on any rejection.
|
||||||
|
|
||||||
|
validate_external_url(url, source_type)
|
||||||
|
Host allow-list re-check + HEAD reachability (fallback to GET with a
|
||||||
|
Range header on 405). Raises `ValidationError` likewise.
|
||||||
|
|
||||||
|
`numpy-stl` is imported lazily so this module can be imported on a host venv
|
||||||
|
that hasn't run `pip install -r requirements.txt` yet -- the failure only
|
||||||
|
fires when an STL is actually validated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import struct
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from .models import Submission
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationError(Exception):
|
||||||
|
"""Raised when a Submission fails its validation pass. The message
|
||||||
|
becomes the operator-visible (and user-visible) rejection reason."""
|
||||||
|
|
||||||
|
|
||||||
|
# Host allow-list per source_type. Mirrors `apps.submissions.forms`
|
||||||
|
# deliberately rather than DRYing through a shared import: the
|
||||||
|
# `processing` worker shouldn't be coupled to the form layer.
|
||||||
|
_URL_HOSTS: dict[str, set[str]] = {
|
||||||
|
Submission.SourceType.PRINTABLES: {"printables.com", "www.printables.com"},
|
||||||
|
Submission.SourceType.MAKERWORLD: {"makerworld.com", "www.makerworld.com"},
|
||||||
|
Submission.SourceType.THINGIVERSE: {"thingiverse.com", "www.thingiverse.com"},
|
||||||
|
}
|
||||||
|
|
||||||
|
_MIN_AXIS_MM = 5.0
|
||||||
|
_MIN_TRIANGLES = 4
|
||||||
|
_MAX_TRIANGLES = 5_000_000
|
||||||
|
|
||||||
|
# Be a polite citizen so anti-bot defenses on the model platforms don't 403 us.
|
||||||
|
_HTTP_HEADERS = {
|
||||||
|
"User-Agent": "hamprint-validator/1.0 (+https://hamlab.lt)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_volume() -> tuple[float, float, float]:
|
||||||
|
"""Read `PRINTER_BUILD_VOLUME_MM` (env-driven, default 235,235,250)."""
|
||||||
|
raw = getattr(settings, "PRINTER_BUILD_VOLUME_MM", "235,235,250")
|
||||||
|
parts = [float(x) for x in raw.split(",")]
|
||||||
|
if len(parts) != 3:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"PRINTER_BUILD_VOLUME_MM must be 'x,y,z'; got: {raw!r}"
|
||||||
|
)
|
||||||
|
return parts[0], parts[1], parts[2]
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(value: float) -> str:
|
||||||
|
"""Render `235.0` as `235`, `235.5` as `235.5`. Just for clean error text."""
|
||||||
|
return str(int(value)) if value.is_integer() else f"{value:g}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- STL ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def validate_stl_file(path: str) -> None:
|
||||||
|
"""Four-pass check; raises `ValidationError` on first failure."""
|
||||||
|
p = Path(path)
|
||||||
|
if not p.is_file():
|
||||||
|
raise ValidationError("STL file is missing on disk")
|
||||||
|
|
||||||
|
size = p.stat().st_size
|
||||||
|
|
||||||
|
# 1. Magic-byte / format probe.
|
||||||
|
with p.open("rb") as f:
|
||||||
|
head_80 = f.read(80)
|
||||||
|
if head_80.startswith(b"solid "):
|
||||||
|
# Could be ASCII -- but some binary STLs also start with "solid ".
|
||||||
|
# Confirm by looking for an ASCII facet marker in the first 4 KB.
|
||||||
|
with p.open("rb") as f:
|
||||||
|
first_4k = f.read(4096)
|
||||||
|
if b"facet normal" not in first_4k:
|
||||||
|
raise ValidationError(
|
||||||
|
"file is not a valid STL: header / size mismatch"
|
||||||
|
)
|
||||||
|
# ASCII STL accepted; the mesh load below catches anything subtler.
|
||||||
|
else:
|
||||||
|
# Binary STL: 80-byte header + uint32 triangle count + 50 B per tri.
|
||||||
|
with p.open("rb") as f:
|
||||||
|
f.seek(80)
|
||||||
|
tri_bytes = f.read(4)
|
||||||
|
if len(tri_bytes) != 4:
|
||||||
|
raise ValidationError(
|
||||||
|
"file is not a valid STL: header / size mismatch"
|
||||||
|
)
|
||||||
|
triangle_count = struct.unpack("<I", tri_bytes)[0]
|
||||||
|
if size != 84 + 50 * triangle_count:
|
||||||
|
raise ValidationError(
|
||||||
|
"file is not a valid STL: header / size mismatch"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Mesh load. Imported lazily so the module imports without numpy-stl.
|
||||||
|
try:
|
||||||
|
from stl import mesh # type: ignore[import-not-found]
|
||||||
|
except ImportError as exc: # pragma: no cover -- only hit on host venv
|
||||||
|
raise ValidationError(
|
||||||
|
"numpy-stl is not installed in this environment"
|
||||||
|
) from exc
|
||||||
|
try:
|
||||||
|
m = mesh.Mesh.from_file(str(p))
|
||||||
|
except Exception: # numpy-stl raises a variety of types; broad on purpose
|
||||||
|
raise ValidationError("STL could not be parsed")
|
||||||
|
|
||||||
|
# 3. Bounding-box check. mesh.vectors has shape (N, 3, 3).
|
||||||
|
vertices = m.vectors.reshape(-1, 3)
|
||||||
|
mins = vertices.min(axis=0)
|
||||||
|
maxs = vertices.max(axis=0)
|
||||||
|
extents = [float(maxs[i] - mins[i]) for i in range(3)]
|
||||||
|
bv_x, bv_y, bv_z = _build_volume()
|
||||||
|
|
||||||
|
if extents[0] > bv_x or extents[1] > bv_y or extents[2] > bv_z:
|
||||||
|
raise ValidationError(
|
||||||
|
f"part is {_fmt(extents[0])}x{_fmt(extents[1])}x{_fmt(extents[2])} mm; "
|
||||||
|
f"doesn't fit on our {_fmt(bv_x)}x{_fmt(bv_y)}x{_fmt(bv_z)} build plate"
|
||||||
|
)
|
||||||
|
if max(extents) < _MIN_AXIS_MM:
|
||||||
|
raise ValidationError(
|
||||||
|
f"part is too small to print reliably "
|
||||||
|
f"(under {_fmt(_MIN_AXIS_MM)} mm on every axis)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Triangle-count sanity.
|
||||||
|
tri_count = int(len(m.vectors))
|
||||||
|
if tri_count < _MIN_TRIANGLES:
|
||||||
|
raise ValidationError(
|
||||||
|
f"mesh is degenerate (fewer than {_MIN_TRIANGLES} triangles)"
|
||||||
|
)
|
||||||
|
if tri_count > _MAX_TRIANGLES:
|
||||||
|
raise ValidationError(f"mesh is too dense ({tri_count:,} triangles)")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- URL ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def validate_external_url(source_url: str, source_type: str) -> None:
|
||||||
|
"""Host check + HEAD reachability; raises `ValidationError` on rejection."""
|
||||||
|
if not source_url:
|
||||||
|
raise ValidationError("URL is empty")
|
||||||
|
|
||||||
|
# 1. Host re-check. We already did this at form time but the row may have
|
||||||
|
# been edited via the admin since.
|
||||||
|
host = (urlparse(source_url).hostname or "").lower()
|
||||||
|
allowed = _URL_HOSTS.get(source_type, set())
|
||||||
|
if host not in allowed:
|
||||||
|
raise ValidationError("URL host doesn't match source type")
|
||||||
|
|
||||||
|
# 2. HEAD reachability.
|
||||||
|
try:
|
||||||
|
r = requests.head(
|
||||||
|
source_url,
|
||||||
|
timeout=5,
|
||||||
|
allow_redirects=True,
|
||||||
|
headers=_HTTP_HEADERS,
|
||||||
|
)
|
||||||
|
# Some CDNs refuse HEAD with 405; fall back to a tiny ranged GET.
|
||||||
|
if r.status_code == 405:
|
||||||
|
r = requests.get(
|
||||||
|
source_url,
|
||||||
|
timeout=5,
|
||||||
|
allow_redirects=True,
|
||||||
|
headers={**_HTTP_HEADERS, "Range": "bytes=0-1023"},
|
||||||
|
)
|
||||||
|
except requests.exceptions.RequestException as exc:
|
||||||
|
raise ValidationError(
|
||||||
|
f"URL unreachable ({exc.__class__.__name__})"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if r.status_code >= 400:
|
||||||
|
raise ValidationError(
|
||||||
|
f"URL returned HTTP {r.status_code}; the model may have been "
|
||||||
|
f"removed or set to private"
|
||||||
|
)
|
||||||
@@ -1,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 — 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 — it skips email "
|
||||||
|
"confirmation entirely.",
|
||||||
|
slug=submission.slug,
|
||||||
|
email=submission.guest_email,
|
||||||
|
login_url=reverse("account_login"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
9
assets/tailwind.source.css
Normal file
9
assets/tailwind.source.css
Normal 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
55
compose.dev.yaml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Podman / Docker Compose stack for local hamprint development.
|
||||||
|
#
|
||||||
|
# Bring it up: podman-compose up -d (or: docker compose up -d)
|
||||||
|
# Logs: podman-compose logs -f web
|
||||||
|
# Tear down: podman-compose down (keeps the pgdata volume)
|
||||||
|
# podman-compose down -v (drops the pgdata volume too)
|
||||||
|
#
|
||||||
|
# The `web` service uses a stock python:3.14-slim image and bind-mounts this
|
||||||
|
# repo at /app. On every start it installs from requirements.txt plus
|
||||||
|
# psycopg[binary] (the pip-cache volume makes subsequent starts fast) and
|
||||||
|
# runs Django's dev server. There is intentionally no Containerfile yet --
|
||||||
|
# swap `image:` for `build: .` once Section 10's image lands.
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: docker.io/library/postgres:16
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
env_file: .env
|
||||||
|
|
||||||
|
web:
|
||||||
|
image: docker.io/library/python:3.14-slim
|
||||||
|
working_dir: /app
|
||||||
|
command: >
|
||||||
|
bash -c "
|
||||||
|
pip install --quiet --no-input -r requirements.txt 'psycopg[binary]' &&
|
||||||
|
python manage.py migrate --noinput &&
|
||||||
|
python manage.py runserver 0.0.0.0:8000
|
||||||
|
"
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
DJANGO_SETTINGS_MODULE: hamprint.settings.dev
|
||||||
|
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- .:/app:Z
|
||||||
|
- pipcache:/root/.cache/pip
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
pipcache:
|
||||||
44
compose.yaml
44
compose.yaml
@@ -1,20 +1,11 @@
|
|||||||
# Podman / Docker Compose stack for local hamprint development.
|
# Production compose stack for hamprint.
|
||||||
#
|
#
|
||||||
# Bring it up: podman-compose up -d (or: docker compose up -d)
|
|
||||||
# Logs: podman-compose logs -f web
|
|
||||||
# Tear down: podman-compose down (keeps the pgdata volume)
|
|
||||||
# podman-compose down -v (drops the pgdata volume too)
|
|
||||||
#
|
|
||||||
# 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:
|
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 +17,35 @@ 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
|
labels:
|
||||||
|
traefik.enable: "true"
|
||||||
|
traefik.http.routers.hamprint.rule: "Host(`print.hamlab.lt`)"
|
||||||
|
traefik.http.middlewares.hamprint-https-redirect.redirectscheme.scheme: "https"
|
||||||
|
traefik.http.routers.hamprint.middlewares: "hamprint-https-redirect"
|
||||||
|
traefik.http.routers.hamprint-secure.entrypoints: "https"
|
||||||
|
traefik.http.routers.hamprint-secure.rule: "Host(`print.hamlab.lt`)"
|
||||||
|
traefik.http.routers.hamprint-secure.tls: "true"
|
||||||
|
traefik.http.routers.hamprint-secure.tls.certresolver: "lets-encrypt"
|
||||||
|
traefik.http.services.hamprint.loadbalancer.server.port: "8000"
|
||||||
|
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
pipcache:
|
media:
|
||||||
|
|||||||
@@ -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
38
entrypoint.sh
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# hamprint container entrypoint.
|
||||||
|
#
|
||||||
|
# Runs everything inside the single `web` container per plan.md §10:
|
||||||
|
# 1. apply DB migrations (idempotent; runs on every start)
|
||||||
|
# 2. background `process_submissions` (plan.md §7.5, 30 s loop)
|
||||||
|
# 3. background `cleanup_stale` (plan.md §7.6, 5 min loop)
|
||||||
|
# 4. exec Gunicorn so it becomes the foreground process for signal handling
|
||||||
|
#
|
||||||
|
# tini (set as ENTRYPOINT in the Containerfile) reaps zombies and forwards
|
||||||
|
# SIGTERM to all three child processes on container stop.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
python manage.py migrate --noinput
|
||||||
|
|
||||||
|
# Validation worker. `|| true` keeps the loop alive across a transient
|
||||||
|
# failure (e.g. a flaky external HEAD request); the next tick retries.
|
||||||
|
(
|
||||||
|
while true; do
|
||||||
|
python manage.py process_submissions || true
|
||||||
|
sleep 30
|
||||||
|
done
|
||||||
|
) &
|
||||||
|
|
||||||
|
# Stale-row reaper. Same pattern, different cadence.
|
||||||
|
(
|
||||||
|
while true; do
|
||||||
|
python manage.py cleanup_stale || true
|
||||||
|
sleep 300
|
||||||
|
done
|
||||||
|
) &
|
||||||
|
|
||||||
|
exec gunicorn hamprint.wsgi:application \
|
||||||
|
--bind 0.0.0.0:8000 \
|
||||||
|
--workers "${GUNICORN_WORKERS:-3}" \
|
||||||
|
--access-logfile - \
|
||||||
|
--error-logfile -
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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")),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
2
static/css/tailwind.css
Normal file
File diff suppressed because one or more lines are too long
44
templates/account/close.html
Normal file
44
templates/account/close.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Close your hamprint account{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-md mx-auto py-8">
|
||||||
|
<div class="bg-white border border-red-200 rounded-lg p-8 shadow-sm">
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight text-red-700">Close your account</h1>
|
||||||
|
<p class="text-slate-700 mt-2 text-sm">
|
||||||
|
Signed in as
|
||||||
|
<span class="font-medium">{{ user.email|default:user.get_username }}</span>.
|
||||||
|
This action permanently deletes your account.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-4 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-900">
|
||||||
|
<p class="font-semibold">There is no undo.</p>
|
||||||
|
<ul class="mt-2 list-disc pl-5 space-y-1">
|
||||||
|
<li>Your user row is deleted from the database.</li>
|
||||||
|
<li>
|
||||||
|
{% if submission_count == 0 %}
|
||||||
|
You don't have any prints in the system right now -- nothing else to remove.
|
||||||
|
{% elif submission_count == 1 %}
|
||||||
|
Your <strong>1</strong> print is deleted along with your account, including any uploaded STL.
|
||||||
|
{% else %}
|
||||||
|
All <strong>{{ submission_count }}</strong> of your prints are deleted along with your account, including any uploaded STLs.
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
<li>Any active queue items are cancelled. Operators won't be able to contact you afterwards.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" class="mt-6">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="w-full px-4 py-3 rounded-md bg-red-600 text-white hover:bg-red-700 font-medium">
|
||||||
|
Yes, delete my account permanently
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-4 text-center">
|
||||||
|
<a href="{% url 'dashboard:my_prints' %}" class="text-sm text-slate-600 hover:underline">← Cancel, take me back</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
44
templates/account/login.html
Normal file
44
templates/account/login.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{% 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 — you can also
|
||||||
|
<a href="{% url 'submissions:create' %}" class="text-amber-700 font-medium hover:underline">submit as a guest</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# POST (not GET) so allauth's LoginView skips its intermediate confirm page and 302s straight to Google. #}
|
||||||
|
<form method="post" action="{% url 'google_login' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% if request.GET.next %}<input type="hidden" name="next" value="{{ request.GET.next }}">{% endif %}
|
||||||
|
<button type="submit"
|
||||||
|
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
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<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 %}
|
||||||
27
templates/account/logout.html
Normal file
27
templates/account/logout.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Sign out — hamprint{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-md mx-auto py-8">
|
||||||
|
<div class="bg-white border border-slate-200 rounded-lg p-8 shadow-sm">
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight">Sign out?</h1>
|
||||||
|
<p class="text-slate-600 mt-2 text-sm">
|
||||||
|
You're signed in as
|
||||||
|
<span class="font-medium">{{ user.email|default:user.get_username }}</span>.
|
||||||
|
Your prints stay on the dashboard either way -- you can come back any time.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="post" action="{% url 'account_logout' %}" class="mt-6">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="w-full px-4 py-3 rounded-md bg-amber-500 text-white hover:bg-amber-600 font-medium">
|
||||||
|
Sign me out
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-4 text-center">
|
||||||
|
<a href="{% url 'dashboard:my_prints' %}" class="text-sm text-slate-600 hover:underline">← Back to my prints</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
78
templates/base.html
Normal file
78
templates/base.html
Normal 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>
|
||||||
210
templates/dashboard/detail.html
Normal file
210
templates/dashboard/detail.html
Normal 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 — 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 }} · 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">← Back to dashboard</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
105
templates/dashboard/index.html
Normal file
105
templates/dashboard/index.html
Normal 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 }}
|
||||||
|
· {{ 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 —{% 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 %}
|
||||||
90
templates/dashboard/my_prints.html
Normal file
90
templates/dashboard/my_prints.html
Normal 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 }}
|
||||||
|
· {{ 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 %}
|
||||||
70
templates/emails/_base.html
Normal file
70
templates/emails/_base.html
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
{# Base email shell. Child templates fill {% block body %}{% endblock %}. #}
|
||||||
|
{# Inline styles only -- most clients strip <style> tags or block CDNs. #}
|
||||||
|
{# Table-based layout -- Outlook/Windows Mail still rely on it for spacing. #}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="light">
|
||||||
|
<meta name="supported-color-schemes" content="light">
|
||||||
|
<title>hamprint</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0; padding:0; background-color:#f8fafc; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif; color:#0f172a; -webkit-font-smoothing:antialiased;">
|
||||||
|
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f8fafc;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:32px 16px;">
|
||||||
|
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="560" style="max-width:560px; width:100%; background-color:#ffffff; border:1px solid #e2e8f0; border-radius:8px; box-shadow:0 1px 2px rgba(15,23,42,0.04);">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px 28px; border-bottom:1px solid #e2e8f0;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td style="padding-right:10px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="32" height="32" style="background-color:#f59e0b; border-radius:6px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="middle" height="32" style="color:#ffffff; font-weight:700; font-size:18px; line-height:1; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">h</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
<td style="vertical-align:middle;">
|
||||||
|
<span style="font-weight:700; font-size:18px; color:#0f172a; letter-spacing:-0.01em;">hamprint</span>
|
||||||
|
<span style="font-size:12px; color:#64748b; margin-left:4px;">· hamlab.lt</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:28px;">
|
||||||
|
{% block body %}{% endblock %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:16px 28px; border-top:1px solid #e2e8f0; background-color:#f8fafc; border-bottom-left-radius:8px; border-bottom-right-radius:8px;">
|
||||||
|
<p style="margin:0; font-size:12px; color:#64748b; text-align:center;">
|
||||||
|
A community service of <a href="https://hamlab.lt" style="color:#475569; font-weight:500; text-decoration:none;">hamlab.lt</a>.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Plain-text footnote that still degrades nicely in dark-mode clients -->
|
||||||
|
<p style="margin:16px 0 0 0; font-size:12px; color:#94a3b8; text-align:center; max-width:560px;">
|
||||||
|
You're receiving this because you submitted a print at hamprint.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
51
templates/emails/completed.body.html
Normal file
51
templates/emails/completed.body.html
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{% extends "emails/_base.html" %}
|
||||||
|
{% block body %}
|
||||||
|
<h1 style="margin:0 0 12px 0; font-size:22px; font-weight:700; color:#065f46; letter-spacing:-0.01em;">Your print is ready</h1>
|
||||||
|
|
||||||
|
<p style="margin:0 0 16px 0; color:#334155; font-size:15px; line-height:1.55;">
|
||||||
|
Done! <strong style="font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; color:#92400e; font-weight:700;">{{ submission.slug }}</strong> came off the printer successfully and is waiting for you at hamlab.lt.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{# Pill colour comes from `Submission.STATUS_EMAIL_COLORS[COMPLETED]` -- the emerald palette used everywhere else for the "success" terminal state. #}
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 20px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 14px; background-color:{{ submission.status_email_style.bg }}; border-radius:9999px;">
|
||||||
|
<span style="color:{{ submission.status_email_style.fg }}; font-weight:600; font-size:14px; letter-spacing:0.01em;">
|
||||||
|
{{ submission.get_status_display }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if submission.operator_notes %}
|
||||||
|
{# Pickup-instructions callout -- only rendered when the operator left a note (e.g. "in the green bin by the lasers"). #}
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:0 0 24px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:16px; background-color:#ecfdf5; border-left:3px solid #10b981; border-radius:0 4px 4px 0;">
|
||||||
|
<p style="margin:0 0 6px 0; font-size:11px; font-weight:600; color:#065f46; text-transform:uppercase; letter-spacing:0.06em;">
|
||||||
|
Note from the operator
|
||||||
|
</p>
|
||||||
|
<p style="margin:0; color:#334155; font-size:14px; line-height:1.55; white-space:pre-line;">{{ submission.operator_notes }}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p style="margin:0 0 24px 0; color:#334155; font-size:15px; line-height:1.55;">
|
||||||
|
Come grab it whenever the lab is open. Thanks for printing with us!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:#10b981; 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 pickup details
|
||||||
|
</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 %}
|
||||||
19
templates/emails/completed.body.txt
Normal file
19
templates/emails/completed.body.txt
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
Hi,
|
||||||
|
|
||||||
|
Your hamprint is done! "{{ submission.slug }}" came off the printer
|
||||||
|
successfully and is waiting for you at hamlab.lt.
|
||||||
|
|
||||||
|
Codename : {{ submission.slug }}
|
||||||
|
Status : Completed
|
||||||
|
{% if submission.operator_notes %}
|
||||||
|
A note from the operator:
|
||||||
|
|
||||||
|
{{ submission.operator_notes }}
|
||||||
|
{% endif %}
|
||||||
|
Come grab it whenever the lab is open. Thanks for printing with us!
|
||||||
|
|
||||||
|
Pickup details and a photo (if the operator left one) are here:
|
||||||
|
{{ detail_url }}
|
||||||
|
|
||||||
|
— hamprint
|
||||||
|
{{ site_url }}
|
||||||
1
templates/emails/completed.subject.txt
Normal file
1
templates/emails/completed.subject.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
hamprint: {{ submission.slug }} is ready for pickup
|
||||||
39
templates/emails/confirmation.body.html
Normal file
39
templates/emails/confirmation.body.html
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{% extends "emails/_base.html" %}
|
||||||
|
{% block body %}
|
||||||
|
<h1 style="margin:0 0 12px 0; font-size:22px; font-weight:700; color:#0f172a; letter-spacing:-0.01em;">Confirm your submission</h1>
|
||||||
|
|
||||||
|
<p style="margin:0 0 18px 0; color:#334155; font-size:15px; line-height:1.55;">
|
||||||
|
You submitted a 3D print to hamlab.lt. Your codename is:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:0 0 22px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:12px 16px; background-color:#fef3c7; border-radius:6px; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:18px; font-weight:700; color:#92400e;">
|
||||||
|
{{ submission.slug }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin:0 0 24px 0; color:#334155; font-size:15px; line-height:1.55;">
|
||||||
|
To put it in the queue, click the button below within <strong style="color:#0f172a;">24 hours</strong>. Otherwise the submission (and any uploaded <span style="font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;">.stl</span>) is deleted automatically.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 24px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:#f59e0b; border-radius:6px;">
|
||||||
|
<a href="{{ confirm_url }}" style="display:inline-block; padding:12px 24px; color:#ffffff; font-weight:600; font-size:15px; text-decoration:none; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||||
|
Confirm my submission
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin:0 0 4px 0; font-size:13px; color:#64748b;">Or copy this link into your browser:</p>
|
||||||
|
<p style="margin:0 0 24px 0; font-size:13px; color:#475569; word-break:break-all; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;">{{ confirm_url }}</p>
|
||||||
|
|
||||||
|
<hr style="margin:0 0 18px 0; border:none; border-top:1px solid #e2e8f0;">
|
||||||
|
|
||||||
|
<p style="margin:0; font-size:13px; color:#64748b; line-height:1.55;">
|
||||||
|
If you didn't submit this, you can safely ignore the email — nothing happens until you click the link.
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
||||||
16
templates/emails/confirmation.body.txt
Normal file
16
templates/emails/confirmation.body.txt
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
Hi,
|
||||||
|
|
||||||
|
You submitted a 3D print to hamlab.lt. Your codename is:
|
||||||
|
|
||||||
|
{{ submission.slug }}
|
||||||
|
|
||||||
|
To put it in the queue, click the link below within 24 hours. If you don't,
|
||||||
|
the submission (and any uploaded .stl) will be deleted automatically.
|
||||||
|
|
||||||
|
{{ confirm_url }}
|
||||||
|
|
||||||
|
If you didn't submit this, you can safely ignore this email -- nothing
|
||||||
|
happens until you click the link.
|
||||||
|
|
||||||
|
— hamprint
|
||||||
|
{{ site_url }}
|
||||||
1
templates/emails/confirmation.subject.txt
Normal file
1
templates/emails/confirmation.subject.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Confirm your hamprint submission: {{ submission.slug }}
|
||||||
37
templates/emails/printing.body.html
Normal file
37
templates/emails/printing.body.html
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{% extends "emails/_base.html" %}
|
||||||
|
{% block body %}
|
||||||
|
<h1 style="margin:0 0 12px 0; font-size:22px; font-weight:700; color:#9a3412; letter-spacing:-0.01em;">Your print is starting</h1>
|
||||||
|
|
||||||
|
<p style="margin:0 0 16px 0; color:#334155; font-size:15px; line-height:1.55;">
|
||||||
|
Great news — <strong style="font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; color:#92400e; font-weight:700;">{{ submission.slug }}</strong> is on the printer right now. The operator has started the job and the first layer is going down.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{# Pill colour comes from `Submission.STATUS_EMAIL_COLORS[PRINTING]` -- warm orange to mirror the live-printing chip on the dashboard. #}
|
||||||
|
<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;">
|
||||||
|
We'll email you again the moment it finishes so you know when to come pick it up. Nothing for you to do right now — feel free to track progress at the link below.
|
||||||
|
</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;">
|
||||||
|
Follow the print
|
||||||
|
</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 %}
|
||||||
17
templates/emails/printing.body.txt
Normal file
17
templates/emails/printing.body.txt
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
Hi,
|
||||||
|
|
||||||
|
Great news -- your hamprint submission "{{ submission.slug }}" is on the
|
||||||
|
printer right now. The operator has started the job and the first layer
|
||||||
|
is going down.
|
||||||
|
|
||||||
|
Codename : {{ submission.slug }}
|
||||||
|
Status : Printing
|
||||||
|
|
||||||
|
We'll send one more email when it finishes, so you know when to come
|
||||||
|
pick it up. No action needed in the meantime.
|
||||||
|
|
||||||
|
You can also follow along here:
|
||||||
|
{{ detail_url }}
|
||||||
|
|
||||||
|
— hamprint
|
||||||
|
{{ site_url }}
|
||||||
1
templates/emails/printing.subject.txt
Normal file
1
templates/emails/printing.subject.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
hamprint: {{ submission.slug }} is on the printer
|
||||||
53
templates/emails/rejected.body.html
Normal file
53
templates/emails/rejected.body.html
Normal 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 %}
|
||||||
17
templates/emails/rejected.body.txt
Normal file
17
templates/emails/rejected.body.txt
Normal 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 }}
|
||||||
1
templates/emails/rejected.subject.txt
Normal file
1
templates/emails/rejected.subject.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Your hamprint submission {{ submission.slug }} was rejected
|
||||||
37
templates/emails/verifying.body.html
Normal file
37
templates/emails/verifying.body.html
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{% extends "emails/_base.html" %}
|
||||||
|
{% block body %}
|
||||||
|
<h1 style="margin:0 0 12px 0; font-size:22px; font-weight:700; color:#0f172a; letter-spacing:-0.01em;">Cleared auto-checks</h1>
|
||||||
|
|
||||||
|
<p style="margin:0 0 16px 0; color:#334155; font-size:15px; line-height:1.55;">
|
||||||
|
Your submission <strong style="font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; color:#92400e; font-weight:700;">{{ submission.slug }}</strong> passed our automated checks (size, format, host allow-list) and is now waiting for an operator to take a manual look.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{# Pill colours come from `Submission.STATUS_EMAIL_COLORS` -- same per-status palette as the website badge. #}
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 20px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 14px; background-color:{{ submission.status_email_style.bg }}; border-radius:9999px;">
|
||||||
|
<span style="color:{{ submission.status_email_style.fg }}; font-weight:600; font-size:14px; letter-spacing:0.01em;">
|
||||||
|
{{ submission.get_status_display }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin:0 0 24px 0; color:#334155; font-size:15px; line-height:1.55;">
|
||||||
|
If the operator is happy with the print job, it moves into the print queue shortly after — and we'll email you again once that happens. You don't need to do anything right now.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:#f59e0b; border-radius:6px;">
|
||||||
|
<a href="{{ detail_url }}" style="display:inline-block; padding:12px 24px; color:#ffffff; font-weight:600; font-size:15px; text-decoration:none; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||||
|
View submission
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin:18px 0 0 0; font-size:13px; color:#64748b;">
|
||||||
|
Direct link: <span style="color:#475569; word-break:break-all; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;">{{ detail_url }}</span>
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
||||||
18
templates/emails/verifying.body.txt
Normal file
18
templates/emails/verifying.body.txt
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
Hi,
|
||||||
|
|
||||||
|
Good news -- your hamprint submission "{{ submission.slug }}" passed the
|
||||||
|
automated checks (size, format, host allow-list) and is now waiting for an
|
||||||
|
operator to take a manual look.
|
||||||
|
|
||||||
|
Codename : {{ submission.slug }}
|
||||||
|
Status : Verifying
|
||||||
|
|
||||||
|
If the operator is happy with the print job, it moves into the print queue
|
||||||
|
shortly after and we'll email you again. You don't need to do anything
|
||||||
|
right now.
|
||||||
|
|
||||||
|
You can also check on it here:
|
||||||
|
{{ detail_url }}
|
||||||
|
|
||||||
|
— hamprint
|
||||||
|
{{ site_url }}
|
||||||
1
templates/emails/verifying.subject.txt
Normal file
1
templates/emails/verifying.subject.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
hamprint: {{ submission.slug }} cleared auto-checks, awaiting operator review
|
||||||
189
templates/submissions/submit.html
Normal file
189
templates/submissions/submit.html
Normal 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 — 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 — 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 — 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 %}
|
||||||
Reference in New Issue
Block a user