"""SubmissionForm -- the public submit form (plan.md §5 + demo/submit.html). Mixes crispy-forms (FormHelper for layout consistency) with manually rendered radio cards in the template, because the four source-type tabs need `peer-checked/:` Tailwind classes that crispy can't emit by itself. """ from __future__ import annotations from urllib.parse import urlparse from crispy_forms.helper import FormHelper from crispy_forms.layout import Field, Layout, Submit from django import forms from .models import Filament, Submission # Per plan.md §7.5.3 + the form's host allow-list. Subdomains (www. and bare) # are both accepted. URL_HOST_ALLOW_LIST: dict[str, set[str]] = { Submission.SourceType.PRINTABLES: {"printables.com", "www.printables.com"}, Submission.SourceType.MAKERWORLD: {"makerworld.com", "www.makerworld.com"}, Submission.SourceType.THINGIVERSE: {"thingiverse.com", "www.thingiverse.com"}, } # Shared Tailwind class string for text-ish inputs, matched against demo/submit.html. INPUT_CSS = ( "w-full px-3 py-2 rounded-md border border-slate-300 bg-white " "focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-amber-500" ) class SubmissionForm(forms.ModelForm): """Single-page submit form. The view passes `user=request.user` so the form can decide whether `guest_email` is required (anonymous) or hidden (Google-authenticated). """ source_type = forms.ChoiceField( choices=Submission.SourceType.choices, initial=Submission.SourceType.UPLOAD, widget=forms.RadioSelect, ) stl_file = forms.FileField( required=False, widget=forms.ClearableFileInput(attrs={"accept": ".stl", "class": "sr-only"}), ) source_url = forms.URLField( required=False, widget=forms.URLInput( attrs={"placeholder": "https://...", "class": INPUT_CSS} ), ) class Meta: model = Submission fields = ( "source_type", "stl_file", "source_url", "requested_filament", "notes_for_op", "guest_email", ) widgets = { "notes_for_op": forms.Textarea( attrs={ "rows": 3, "class": INPUT_CSS, "placeholder": ( "e.g. 0.2 mm layer height, 20% infill, pickup Saturday afternoon." ), } ), "guest_email": forms.EmailInput( attrs={"class": INPUT_CSS, "placeholder": "you@example.com"} ), "requested_filament": forms.Select(attrs={"class": INPUT_CSS}), } def __init__(self, *args, user=None, **kwargs): super().__init__(*args, **kwargs) # Stash for `clean()` -- the per-email cap needs to know whether the # incoming submission is OAuth (user.email) or guest (guest_email). self.user = user # Filament dropdown shows only operator-curated, currently-loaded rows. self.fields["requested_filament"].queryset = Filament.objects.filter( is_available=True ) self.fields["requested_filament"].label = "Filament" self.fields["requested_filament"].empty_label = ( "No preference — operator's choice" ) self.fields["requested_filament"].required = False self.fields["notes_for_op"].required = False if user is not None and user.is_authenticated: # OAuth user: email is already verified through Google, no need to # ask again. Drop the field so it doesn't render or accept input. self.fields.pop("guest_email") else: self.fields["guest_email"].required = True self.fields["guest_email"].label = "Your email" # Crispy helper -- the template still owns the
tag (CSRF + # enctype + custom radio-card markup live there), so we keep form_tag # off and lean on crispy only for individual {{ field|as_crispy_field }} # renders of the boring fields. self.helper = FormHelper() self.helper.form_tag = False self.helper.disable_csrf = True layout_fields = ["source_type", "stl_file", "source_url", "requested_filament", "notes_for_op"] if "guest_email" in self.fields: layout_fields.append("guest_email") self.helper.layout = Layout(*[Field(f) for f in layout_fields]) self.helper.add_input(Submit("submit", "Submit print")) # --- validation --------------------------------------------------------- def clean(self): cleaned = super().clean() # Per-email cap (plan.md §6). Run this BEFORE the source-type checks # so a user already at quota doesn't get a misleading "pick a source" # error message; the cap is the real reason their submission failed. owner_email = ( self.user.email if (self.user and self.user.is_authenticated) else cleaned.get("guest_email") ) if owner_email: active = Submission.active_count_for_email(owner_email) cap = Submission.MAX_ACTIVE_SUBMISSIONS_PER_EMAIL if active >= cap: raise forms.ValidationError( f"You already have {active} active submission(s) -- " f"that's the per-email cap of {cap}. Wait for some to " f"finish printing (or be cleaned up after rejection) " f"before submitting another." ) source_type = cleaned.get("source_type") stl_file = cleaned.get("stl_file") source_url = cleaned.get("source_url") if source_type == Submission.SourceType.UPLOAD: if not stl_file: self.add_error( "stl_file", "Pick an .stl file to upload (max 4 MB) or switch to a URL source.", ) # The user might have left text in source_url from a prior selection; # silently drop it so the saved row stays consistent. cleaned["source_url"] = "" else: if not source_url: self.add_error( "source_url", "Paste the model URL or switch to a raw .stl upload.", ) else: allowed = URL_HOST_ALLOW_LIST.get(source_type, set()) host = urlparse(source_url).hostname or "" if host not in allowed: pretty_hosts = ", ".join(sorted(allowed)) self.add_error( "source_url", f"URL host must be one of: {pretty_hosts}. Got '{host}'.", ) # Conversely, drop any uploaded file that doesn't apply. cleaned["stl_file"] = None return cleaned