App v0.1
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user