439 lines
17 KiB
Python
439 lines
17 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 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 <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"}
|
|
)
|