Files
hamprint/apps/submissions/models.py

428 lines
16 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
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 `rejected` from any
other state, this method queues `send_rejection_email()` via
`transaction.on_commit`. Centralising the email 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 to rejected.
# Don't fire on inserts that start out as rejected -- those should
# be impossible by design (plan.md §7.3 doesn't define a (none) ->
# rejected edge), and even if some weird path creates one we'd
# rather stay silent than spam a fresh victim.
if (
not is_new
and old_status != new_status
and new_status == self.Status.REJECTED
):
# 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_rejection_email
transaction.on_commit(
lambda sub=self, prev=old_status: send_rejection_email(
sub, previous_status=prev
)
)
# 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 <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"}
)