This commit is contained in:
2026-05-12 19:35:15 +03:00
parent c451a106a1
commit 0fdb8b8a02
17 changed files with 1351 additions and 8 deletions

View File

@@ -1,3 +1,93 @@
from django.contrib import admin
from django.utils.html import format_html
# Register your models here.
from .models import Filament, Submission
@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"

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

@@ -0,0 +1,152 @@
"""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)
# 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()
source_type = cleaned.get("source_type")
stl_file = cleaned.get("stl_file")
source_url = cleaned.get("source_url")
if source_type == Submission.SourceType.UPLOAD:
if not stl_file:
self.add_error(
"stl_file",
"Pick an .stl file to upload (max 4 MB) or switch to a URL source.",
)
# The user might have left text in source_url from a prior selection;
# silently drop it so the saved row stays consistent.
cleaned["source_url"] = ""
else:
if not source_url:
self.add_error(
"source_url",
"Paste the model URL or switch to a raw .stl upload.",
)
else:
allowed = URL_HOST_ALLOW_LIST.get(source_type, set())
host = urlparse(source_url).hostname or ""
if host not in allowed:
pretty_hosts = ", ".join(sorted(allowed))
self.add_error(
"source_url",
f"URL host must be one of: {pretty_hosts}. Got '{host}'.",
)
# Conversely, drop any uploaded file that doesn't apply.
cleaned["stl_file"] = None
return cleaned

View File

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

View File

@@ -1,3 +1,210 @@
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 django.conf import settings
from django.core.validators import FileExtensionValidator
from django.db import models
from django.urls import reverse
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 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)
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)
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")
# --- 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",
}
@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")

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

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

View File

@@ -1,3 +1,97 @@
from django.shortcuts import render
"""Submit form view (plan.md §7.4 + §7).
# 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)
Slug generation, email sending, and validation cron are out of scope here and
deferred to plan.md §7.5 (`processor` sidecar) and §7.5 confirmation flow.
"""
from __future__ import annotations
import secrets
import namesgenerator
from django.contrib import messages
from django.db import IntegrityError, transaction
from django.urls import reverse_lazy
from django.utils import timezone
from django.views.generic import CreateView
from .forms import SubmissionForm
from .models import Submission
def _generate_unique_slug(max_attempts: int = 16) -> str:
"""`namesgenerator.get_random_name` with collision retries.
The slug column is `unique=True`; we retry instead of looping in SQL so
the rare collision path stays observable.
"""
for _ in range(max_attempts):
candidate = namesgenerator.get_random_name()
if not Submission.objects.filter(slug=candidate).exists():
return candidate
# Fall through: extremely unlikely, but caller will see IntegrityError
# if the next save() still collides.
return namesgenerator.get_random_name()
class SubmitView(CreateView):
"""Public submit form. GET renders, POST creates a Submission."""
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
@transaction.atomic
def form_valid(self, form):
submission: Submission = form.save(commit=False)
submission.slug = _generate_unique_slug()
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
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()
# TODO(plan.md §7.4 step 6): send the confirmation email here.
try:
submission.save()
except IntegrityError:
# Extremely rare slug collision on the unique index; one more try.
submission.slug = _generate_unique_slug()
submission.save()
if submission.status == Submission.Status.IDENTIFYING:
messages.info(
self.request,
f"Submission {submission.slug} created. Check your email "
f"({submission.guest_email}) for a confirmation link -- you "
f"have 24 hours.",
)
else:
messages.success(
self.request,
f"Submission {submission.slug} accepted. We'll start "
f"validating it shortly.",
)
self.object = submission
return super().form_valid(form)