Files
hamprint/apps/submissions/models.py

255 lines
9.0 KiB
Python

"""Submission + Filament models -- the data layer for plan.md §5.
The state machine lives in `Submission.Status`; transitions and side-effects
are documented in plan.md §7.
"""
from __future__ import annotations
import uuid
import namesgenerator
from django.conf import settings
from django.core.validators import FileExtensionValidator
from django.db import models
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")
def save(self, *args, **kwargs):
"""Auto-generate `slug` on first save so any creation path -- admin,
`SubmitView`, fixtures, `objects.create()` -- gets a Docker-style
codename without callers having to remember to set one."""
if not self.slug:
self.slug = self._generate_unique_slug()
super().save(*args, **kwargs)
@classmethod
def _generate_unique_slug(cls, max_attempts: int = 16) -> str:
"""`namesgenerator.get_random_name` + collision retries in Python
(rather than a DB-side loop) so the rare collision path stays
observable. If the surrounding transaction still loses a race with
a concurrent insert, the DB's `unique=True` constraint fires an
`IntegrityError` and the caller can retry."""
for _ in range(max_attempts):
candidate = namesgenerator.get_random_name()
if not cls.objects.filter(slug=candidate).exists():
return candidate
# Fall through: extremely unlikely with the size of namesgenerator's
# adjective/surname space.
return namesgenerator.get_random_name()
# --- presentation helpers (consumed by dashboard templates) -------------
SOURCE_LABEL = {
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"}
)