App v0.1
This commit is contained in:
152
apps/submissions/forms.py
Normal file
152
apps/submissions/forms.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""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/<name>:` 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)
|
||||
|
||||
# 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 <form> 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()
|
||||
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
|
||||
Reference in New Issue
Block a user