Compare commits
5 Commits
05ac0057a6
...
stl_downlo
| Author | SHA1 | Date | |
|---|---|---|---|
| 553ac9abf1 | |||
| 8ffac1b8a7 | |||
| 2d2cf46f32 | |||
| 24624cc4d1 | |||
| 526fec314d |
@@ -1,6 +1,6 @@
|
|||||||
"""Outgoing email -- plan.md §7 (state-transition side effects).
|
"""Outgoing email -- plan.md §7 (state-transition side effects).
|
||||||
|
|
||||||
Three public functions, one per dedicated template:
|
Five public functions, one per dedicated template:
|
||||||
|
|
||||||
send_confirmation_email(submission)
|
send_confirmation_email(submission)
|
||||||
Guest path: token-link emailed immediately after `SubmitView` creates
|
Guest path: token-link emailed immediately after `SubmitView` creates
|
||||||
@@ -20,7 +20,19 @@ Three public functions, one per dedicated template:
|
|||||||
rejection reason in the email as on the public detail page.
|
rejection reason in the email as on the public detail page.
|
||||||
Template: `emails/rejected.*`.
|
Template: `emails/rejected.*`.
|
||||||
|
|
||||||
All three delegate to Django's email machinery. The backend is wired in
|
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
|
`hamprint/settings/base.py`: Mailtrap via `django-anymail` when
|
||||||
`MAILTRAP_API_TOKEN` is present, console when not. Send failures are caught
|
`MAILTRAP_API_TOKEN` is present, console when not. Send failures are caught
|
||||||
+ logged so a flaky transport never blocks the submission flow.
|
+ logged so a flaky transport never blocks the submission flow.
|
||||||
@@ -146,3 +158,20 @@ def send_verifying_email(sub: Submission) -> bool:
|
|||||||
will be the queued / printing one."""
|
will be the queued / printing one."""
|
||||||
detail_url = f"{settings.SITE_URL}/p/{sub.slug}/"
|
detail_url = f"{settings.SITE_URL}/p/{sub.slug}/"
|
||||||
return _send("verifying", sub, {"detail_url": detail_url})
|
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})
|
||||||
|
|||||||
@@ -294,11 +294,12 @@ class Submission(models.Model):
|
|||||||
currently owns the row, so the per-email cap and trust list don't
|
currently owns the row, so the per-email cap and trust list don't
|
||||||
depend on the caller remembering to set it.
|
depend on the caller remembering to set it.
|
||||||
|
|
||||||
Additionally: when an UPDATE flips `status` to `rejected` from any
|
Additionally: when an UPDATE flips `status` to a state with a
|
||||||
other state, this method queues `send_rejection_email()` via
|
dedicated email (`rejected`, `printing`, `completed`), this method
|
||||||
`transaction.on_commit`. Centralising the email here means **every**
|
queues the matching `send_*_email()` via `transaction.on_commit`.
|
||||||
save path -- admin, the validation worker, ad-hoc shell, any future
|
Centralising the dispatch here means **every** save path -- admin,
|
||||||
view -- fires the email through a single hook. Plan.md §7.3.
|
the validation worker, ad-hoc shell, any future view -- fires the
|
||||||
|
email through a single hook. Plan.md §7.3.
|
||||||
"""
|
"""
|
||||||
if not self.slug:
|
if not self.slug:
|
||||||
self.slug = self._generate_unique_slug()
|
self.slug = self._generate_unique_slug()
|
||||||
@@ -319,27 +320,37 @@ class Submission(models.Model):
|
|||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
# Fire on TRANSITIONS only: an UPDATE that flips status to rejected.
|
# Fire on TRANSITIONS only: an UPDATE that flips status into one of
|
||||||
# Don't fire on inserts that start out as rejected -- those should
|
# the email-bearing target states. Don't fire on inserts that start
|
||||||
# be impossible by design (plan.md §7.3 doesn't define a (none) ->
|
# out in those states -- by plan.md §7.3 no submit-time edge lands
|
||||||
# rejected edge), and even if some weird path creates one we'd
|
# in rejected/printing/completed, and even if some weird path did,
|
||||||
# rather stay silent than spam a fresh victim.
|
# we'd rather stay silent than send "your print is ready" to a fresh
|
||||||
if (
|
# victim of a fixture/data-migration import.
|
||||||
not is_new
|
if not is_new and old_status != new_status:
|
||||||
and old_status != new_status
|
|
||||||
and new_status == self.Status.REJECTED
|
|
||||||
):
|
|
||||||
# Local imports keep this module out of the apps/submissions
|
# Local imports keep this module out of the apps/submissions
|
||||||
# import-cycle (emails.py imports from here).
|
# import-cycle (emails.py imports from here).
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from .emails import send_rejection_email
|
from .emails import (
|
||||||
|
send_completed_email,
|
||||||
transaction.on_commit(
|
send_printing_email,
|
||||||
lambda sub=self, prev=old_status: send_rejection_email(
|
send_rejection_email,
|
||||||
sub, previous_status=prev
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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
|
# Refresh the snapshot so a follow-up save on the same instance
|
||||||
# compares against the just-persisted state, not the original load.
|
# compares against the just-persisted state, not the original load.
|
||||||
self._original_status = new_status
|
self._original_status = new_status
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ import secrets
|
|||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.db import IntegrityError, transaction
|
from django.db import IntegrityError, transaction
|
||||||
|
from django.http import FileResponse, Http404
|
||||||
from django.urls import reverse, reverse_lazy
|
from django.urls import reverse, reverse_lazy
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
|
from django.views.decorators.http import require_safe
|
||||||
from django.views.generic import CreateView
|
from django.views.generic import CreateView
|
||||||
|
|
||||||
from .emails import send_confirmation_email
|
from .emails import send_confirmation_email
|
||||||
@@ -28,6 +30,48 @@ from .forms import SubmissionForm
|
|||||||
from .models import Submission, VerifiedEmail
|
from .models import Submission, VerifiedEmail
|
||||||
|
|
||||||
|
|
||||||
|
@require_safe
|
||||||
|
def serve_stl(request, path: str):
|
||||||
|
"""Auth-checked passthrough to a Submission's uploaded `.stl`. URL is
|
||||||
|
`/media/<path>` so it matches `MEDIA_URL` -- which means
|
||||||
|
`{{ submission.stl_file.url }}` in templates Just Works.
|
||||||
|
|
||||||
|
`path` is the relative name Django stored (e.g. `stl/foo.stl`); we
|
||||||
|
look the row up by that string rather than reading the filesystem
|
||||||
|
directly so an attacker can't escape `MEDIA_ROOT` with `..` segments.
|
||||||
|
|
||||||
|
Access tiers match `SubmissionDetailView` (plan.md §7.4 step 7):
|
||||||
|
|
||||||
|
- staff: yes (operators need the file to drive the printer).
|
||||||
|
- authenticated owner (`submitted_by == request.user`): yes.
|
||||||
|
- everyone else: 404 -- we deliberately don't 403 so the existence
|
||||||
|
of a row isn't leaked to non-owners by status code.
|
||||||
|
|
||||||
|
Guests can't authenticate by design, so they can't pull their own STL
|
||||||
|
via this path. That's consistent with the detail page's two-tier
|
||||||
|
access; the submitter has the file on their own machine in any case.
|
||||||
|
"""
|
||||||
|
sub = Submission.objects.filter(stl_file=path).first()
|
||||||
|
if sub is None:
|
||||||
|
raise Http404
|
||||||
|
user = request.user
|
||||||
|
is_owner = (
|
||||||
|
user.is_authenticated
|
||||||
|
and sub.submitted_by_id is not None
|
||||||
|
and sub.submitted_by_id == user.id
|
||||||
|
)
|
||||||
|
if not (user.is_staff or is_owner):
|
||||||
|
raise Http404
|
||||||
|
# `FieldFile.open("rb")` is rooted in MEDIA_ROOT; FileResponse handles
|
||||||
|
# streaming, range requests, and closing the handle for us.
|
||||||
|
filename = sub.stl_file.name.rsplit("/", 1)[-1]
|
||||||
|
return FileResponse(
|
||||||
|
sub.stl_file.open("rb"),
|
||||||
|
as_attachment=True,
|
||||||
|
filename=filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SubmitView(CreateView):
|
class SubmitView(CreateView):
|
||||||
"""Public submit form. GET renders, POST creates a Submission."""
|
"""Public submit form. GET renders, POST creates a Submission."""
|
||||||
|
|
||||||
|
|||||||
39
compose.yaml
39
compose.yaml
@@ -1,33 +1,5 @@
|
|||||||
# Production compose stack for hamprint.
|
# Production compose stack for hamprint.
|
||||||
#
|
#
|
||||||
# What changed from the previous bind-mount / pip-at-runtime version:
|
|
||||||
# - `web` is now BUILT from the Containerfile in this repo. Everything
|
|
||||||
# (Python deps, the Tailwind CLI binary, the built CSS, collectstatic
|
|
||||||
# output) bakes into the image; nothing is installed at container start.
|
|
||||||
# - No host source bind-mount: the container ships its own /app. Code
|
|
||||||
# changes require a `podman-compose up -d --build web`.
|
|
||||||
# - `DJANGO_SETTINGS_MODULE=hamprint.settings.prod` (DEBUG off, secure
|
|
||||||
# cookies, HSTS). DEBUG=True traffic should run from the host venv,
|
|
||||||
# not from this stack.
|
|
||||||
# - Uploaded STLs persist in a named `media` volume so they survive
|
|
||||||
# `podman-compose down` / image rebuilds. Drop with `down -v`.
|
|
||||||
#
|
|
||||||
# Bring it up: podman-compose up -d --build
|
|
||||||
# Rebuild only web: podman-compose up -d --build web
|
|
||||||
# Logs: podman-compose logs -f web
|
|
||||||
# Tear down: podman-compose down # keeps pgdata + media
|
|
||||||
# podman-compose down -v # nukes both volumes too
|
|
||||||
#
|
|
||||||
# `.env` keys you'll want set (see `.env.example` for the full list):
|
|
||||||
# SECRET_KEY - long random string
|
|
||||||
# ALLOWED_HOSTS - e.g. "print.hamlab.lt,localhost"
|
|
||||||
# SITE_URL - e.g. "https://print.hamlab.lt" (for emails)
|
|
||||||
# POSTGRES_DB / _USER / _PASSWORD
|
|
||||||
# MAILTRAP_API_TOKEN (+ MAILTRAP_TEST_INBOX_ID for sandbox)
|
|
||||||
# GOOGLE_CLIENT_ID / _SECRET (optional; only if Google sign-in is wanted)
|
|
||||||
#
|
|
||||||
# TLS termination is the upstream proxy's job -- the `web` container speaks
|
|
||||||
# plain HTTP on its mapped host port (default 8000).
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
@@ -59,6 +31,17 @@ services:
|
|||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- media:/app/media
|
- media:/app/media
|
||||||
|
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
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ STATIC_URL = "static/"
|
|||||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||||
STATICFILES_DIRS = [BASE_DIR / "static"] if (BASE_DIR / "static").exists() else []
|
STATICFILES_DIRS = [BASE_DIR / "static"] if (BASE_DIR / "static").exists() else []
|
||||||
|
|
||||||
MEDIA_URL = "media/"
|
MEDIA_URL = "/media/"
|
||||||
MEDIA_ROOT = BASE_DIR / "media"
|
MEDIA_ROOT = BASE_DIR / "media"
|
||||||
|
|
||||||
STORAGES = {
|
STORAGES = {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ Including another URLconf
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
|
||||||
|
from apps.submissions.views import serve_stl
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
# Our local /accounts/ routes (close-account, etc.) come BEFORE allauth's
|
# Our local /accounts/ routes (close-account, etc.) come BEFORE allauth's
|
||||||
@@ -25,6 +27,10 @@ urlpatterns = [
|
|||||||
# through to allauth.
|
# through to allauth.
|
||||||
path("accounts/", include("apps.accounts.urls")),
|
path("accounts/", include("apps.accounts.urls")),
|
||||||
path("accounts/", include("allauth.urls")),
|
path("accounts/", include("allauth.urls")),
|
||||||
|
# Auth-checked media serve. Lives here (not in apps.submissions.urls)
|
||||||
|
# because the prefix is MEDIA_URL, not /submit/. WhiteNoise serves
|
||||||
|
# /static/ only, so this is the sole handler for /media/.
|
||||||
|
path("media/<path:path>", serve_stl, name="serve_stl"),
|
||||||
path("", include("apps.dashboard.urls")),
|
path("", include("apps.dashboard.urls")),
|
||||||
path("submit/", include("apps.submissions.urls")),
|
path("submit/", include("apps.submissions.urls")),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -14,22 +14,21 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% comment %}
|
{# POST (not GET) so allauth's LoginView skips its intermediate confirm page and 302s straight to Google. #}
|
||||||
The button is a GET to allauth's google_login view -- not a POST form --
|
<form method="post" action="{% url 'google_login' %}">
|
||||||
because the actual OAuth handshake happens via a 302 redirect to Google.
|
{% csrf_token %}
|
||||||
The `?next=` from `/accounts/login/?next=/wherever/` is forwarded so the
|
{% if request.GET.next %}<input type="hidden" name="next" value="{{ request.GET.next }}">{% endif %}
|
||||||
user lands back on the page they were trying to reach.
|
<button type="submit"
|
||||||
{% endcomment %}
|
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">
|
||||||
<a href="{% url 'google_login' %}{% if request.GET.next %}?next={{ request.GET.next|urlencode }}{% endif %}"
|
<svg class="w-5 h-5" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
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">
|
<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"/>
|
||||||
<svg class="w-5 h-5" viewBox="0 0 24 24" aria-hidden="true">
|
<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="#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="#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="#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="#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"/>
|
||||||
<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"/>
|
</svg>
|
||||||
<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"/>
|
Continue with Google
|
||||||
</svg>
|
</button>
|
||||||
Continue with Google
|
</form>
|
||||||
</a>
|
|
||||||
|
|
||||||
<div class="mt-6 text-center">
|
<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>
|
<a href="{% url 'submissions:create' %}" class="text-sm text-slate-600 hover:underline">No thanks, just submit as a guest →</a>
|
||||||
|
|||||||
@@ -136,13 +136,24 @@
|
|||||||
<div class="bg-white border border-slate-200 rounded-lg p-5">
|
<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>
|
<h3 class="font-semibold text-sm uppercase tracking-wide text-slate-500 mb-3">Source</h3>
|
||||||
{% if submission.source_type == 'upload' %}
|
{% if submission.source_type == 'upload' %}
|
||||||
<div class="flex items-center gap-3 p-3 rounded-md bg-slate-50 border border-slate-200">
|
{% if submission.stl_file %}
|
||||||
<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>
|
<a href="{{ submission.stl_file.url }}" 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">
|
||||||
<div class="flex-1 min-w-0">
|
<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>
|
||||||
<p class="font-medium text-sm break-all">{% if submission.stl_file %}{{ submission.stl_file.name }}{% else %}(file not on disk){% endif %}</p>
|
<div class="flex-1 min-w-0">
|
||||||
<p class="text-xs text-slate-500">Raw <span class="mono">.stl</span> upload</p>
|
<p class="font-medium text-sm break-all">{{ submission.stl_file.name }}</p>
|
||||||
|
<p class="text-xs text-slate-500">Raw <span class="mono">.stl</span> upload · click to download</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="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<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">(file not on disk)</p>
|
||||||
|
<p class="text-xs text-slate-500">Raw <span class="mono">.stl</span> upload</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{% endif %}
|
||||||
{% else %}
|
{% 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">
|
<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>
|
<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>
|
||||||
|
|||||||
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
|
||||||
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
|
||||||
Reference in New Issue
Block a user