"""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 datetime import timedelta import namesgenerator from django.conf import settings from django.core.validators import FileExtensionValidator from django.db import models from django.urls import reverse from django.utils import timezone def normalize_email(email: str) -> str: """Canonicalise an email for per-email accounting (the trust list AND the active-submission cap). - Lowercase the entire address. - Strip Gmail-style `+tag` from the local part: `user+anything@host.com` -> `user@host.com`. Same return value for `User@Gmail.com`, `user+a@gmail.com`, `user+b@gmail.com`. Returns `""` for falsy / malformed input. """ if not email or "@" not in email: return (email or "").lower() local, _, domain = email.lower().rpartition("@") if "+" in local: local = local.split("+", 1)[0] return f"{local}@{domain}" # Email trust lasts 30 days from `validated_at`; after that the user has to # click a fresh confirmation link to re-prove inbox ownership. EMAIL_TRUST_TTL = timedelta(days=30) 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 VerifiedEmail(models.Model): """An email address that has confirmed at least one hamprint submission. Stored **normalised** (lowercased, `+tag` stripped) so `user@gmail.com`, `user+a@gmail.com`, and `user+b@gmail.com` all collapse to a single row. Each entry carries a single `validated_at` timestamp. Trust expires after `EMAIL_TRUST_TTL` (30 days); after that the next guest submission from the same address falls back to `identifying` and gets a fresh confirmation link, which on success bumps `validated_at` back to now via `record_verification()`. Trade-off worth knowing: anyone who knows a verified address can use it to bypass confirmation for the next 30 days. We accept that for hamlab's small-scale, operator-moderated workflow -- the operator still has to manually approve every submission before it prints. Operators can also revoke an entry via the admin to force the next submission back through the welcome-email flow. """ email = models.EmailField(unique=True, db_index=True) validated_at = models.DateTimeField(default=timezone.now) class Meta: ordering = ("-validated_at",) def __str__(self) -> str: return self.email @classmethod def is_trusted(cls, email: str) -> bool: """True if `email` (after normalisation) has a fresh trust entry -- i.e., `validated_at` is within `EMAIL_TRUST_TTL` of now.""" norm = normalize_email(email) if not norm: return False cutoff = timezone.now() - EMAIL_TRUST_TTL return cls.objects.filter(email=norm, validated_at__gte=cutoff).exists() @classmethod def record_verification(cls, email: str) -> "VerifiedEmail | None": """Mark `email` as freshly verified. Normalises first, then `update_or_create`-s with a current `validated_at`. Returns the row (or None if `email` was falsy).""" norm = normalize_email(email) if not norm: return None obj, _ = cls.objects.update_or_create( email=norm, defaults={"validated_at": timezone.now()}, ) return obj 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) # Per-email rate limit. A single email address (after normalisation -- # see `normalize_email`) is allowed at most this many submissions # whose status is NOT in `STATUSES_EXCLUDED_FROM_LIMIT`. The exclusion # is intentional: `printing` jobs are short-lived and operator-driven, # and `rejected` jobs are already cleaned up after 24 h, so neither # should count against the user's quota. MAX_ACTIVE_SUBMISSIONS_PER_EMAIL = 10 STATUSES_EXCLUDED_FROM_LIMIT = (Status.PRINTING, 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) # Normalised email (lowercased, +tag stripped) of whoever owns this row # -- the OAuth user if `submitted_by` is set, otherwise the guest. Used # for the active-submissions cap + the `VerifiedEmail` trust lookup, # both of which need to treat `user+a@gmail.com` and `user@gmail.com` # as the same mailbox. Populated in `save()`. canonical_email = models.EmailField(blank=True, db_index=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") @classmethod def from_db(cls, db, field_names, values): """Capture the `status` value the row had when it was loaded, so `save()` can detect status transitions later. Stored on the instance as `_original_status`; refreshed at the end of every `save()` so successive saves compare against the freshly-persisted state.""" instance = super().from_db(db, field_names, values) instance._original_status = instance.status return instance 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. Also keeps `canonical_email` in sync with whichever side (OAuth user / guest) currently owns the row, so the per-email cap and trust list don't depend on the caller remembering to set it. Additionally: when an UPDATE flips `status` to a state with a dedicated email (`rejected`, `printing`, `completed`), this method queues the matching `send_*_email()` via `transaction.on_commit`. Centralising the dispatch here means **every** save path -- admin, the validation worker, ad-hoc shell, any future view -- fires the email through a single hook. Plan.md §7.3. """ if not self.slug: self.slug = self._generate_unique_slug() # Re-derive canonical_email every save: cheap, and survives an # operator flipping `submitted_by` / `guest_email` in admin. owner_email = "" if self.submitted_by_id and self.submitted_by and self.submitted_by.email: owner_email = self.submitted_by.email elif self.guest_email: owner_email = self.guest_email self.canonical_email = normalize_email(owner_email) # Snapshot for the transition check. `_state.adding` is the canonical # Django way to distinguish "first save" from "subsequent update". is_new = self._state.adding new_status = self.status old_status = getattr(self, "_original_status", None) super().save(*args, **kwargs) # Fire on TRANSITIONS only: an UPDATE that flips status into one of # the email-bearing target states. Don't fire on inserts that start # out in those states -- by plan.md §7.3 no submit-time edge lands # in rejected/printing/completed, and even if some weird path did, # we'd rather stay silent than send "your print is ready" to a fresh # victim of a fixture/data-migration import. if not is_new and old_status != new_status: # Local imports keep this module out of the apps/submissions # import-cycle (emails.py imports from here). from django.db import transaction from .emails import ( send_completed_email, send_printing_email, send_rejection_email, ) 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 # compares against the just-persisted state, not the original load. self._original_status = new_status @classmethod def active_count_for_email(cls, email: str) -> int: """Return how many of this email's submissions count against the `MAX_ACTIVE_SUBMISSIONS_PER_EMAIL` cap. Submissions in `STATUSES_EXCLUDED_FROM_LIMIT` (`printing`, `rejected`) are excluded -- they're transient or already-cleaned states that shouldn't pin the user's quota. """ norm = normalize_email(email) if not norm: return 0 return cls.objects.filter(canonical_email=norm).exclude( status__in=cls.STATUSES_EXCLUDED_FROM_LIMIT ).count() @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