"""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 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")