"""Submit form view (plan.md §7.4). The view persists a `Submission` and, depending on whether the request is authenticated, sets the initial state machine state per plan.md §7.3: OAuth user -> processing (email already verified) guest with email -> identifying (waiting for confirmation link click) For the guest path, the confirmation email is sent **synchronously** after the DB commit so we know the Mailtrap API outcome before redirecting. The dashboard then shows a green "check your inbox" notice on success or a red "couldn't send email -- try Google sign-in" notice on failure. """ from __future__ import annotations import secrets from django.contrib import messages from django.db import IntegrityError, transaction from django.http import FileResponse, Http404 from django.urls import reverse, reverse_lazy from django.utils import timezone from django.utils.html import format_html from django.views.decorators.http import require_safe from django.views.generic import CreateView from .emails import send_confirmation_email from .forms import SubmissionForm from .models import Submission, VerifiedEmail @require_safe def serve_stl(request, path: str): """Auth-checked passthrough to a Submission's uploaded `.stl`. URL is `/media/` so it matches `MEDIA_URL` -- which means `{{ submission.stl_file.url }}` in templates Just Works. `path` is the relative name Django stored (e.g. `stl/foo.stl`); we look the row up by that string rather than reading the filesystem directly so an attacker can't escape `MEDIA_ROOT` with `..` segments. Access tiers match `SubmissionDetailView` (plan.md §7.4 step 7): - staff: yes (operators need the file to drive the printer). - authenticated owner (`submitted_by == request.user`): yes. - everyone else: 404 -- we deliberately don't 403 so the existence of a row isn't leaked to non-owners by status code. Guests can't authenticate by design, so they can't pull their own STL via this path. That's consistent with the detail page's two-tier access; the submitter has the file on their own machine in any case. """ sub = Submission.objects.filter(stl_file=path).first() if sub is None: raise Http404 user = request.user is_owner = ( user.is_authenticated and sub.submitted_by_id is not None and sub.submitted_by_id == user.id ) if not (user.is_staff or is_owner): raise Http404 # `FieldFile.open("rb")` is rooted in MEDIA_ROOT; FileResponse handles # streaming, range requests, and closing the handle for us. filename = sub.stl_file.name.rsplit("/", 1)[-1] return FileResponse( sub.stl_file.open("rb"), as_attachment=True, filename=filename, ) class SubmitView(CreateView): """Public submit form. GET renders, POST creates a Submission.""" form_class = SubmissionForm template_name = "submissions/submit.html" success_url = reverse_lazy("dashboard:index") def get_form_kwargs(self): kwargs = super().get_form_kwargs() kwargs["user"] = self.request.user return kwargs def form_valid(self, form): submission: Submission = form.save(commit=False) # The slug is auto-generated in `Submission.save()` if blank, so we # don't need to set one here. The `except IntegrityError` below # handles the rare race where the just-generated slug collides with # a concurrent insert. if self.request.user.is_authenticated: submission.submitted_by = self.request.user submission.guest_email = None submission.email_confirmed = True submission.status = Submission.Status.PROCESSING elif submission.guest_email and VerifiedEmail.is_trusted(submission.guest_email): # Returning guest: their email is on the trust list AND the # 30-day TTL hasn't lapsed (plan.md §6). Skip `identifying` and # the welcome email entirely -- straight to `processing` like # an OAuth submitter. Email normalisation happens inside # `is_trusted`, so `user+a@gmail.com` and `user@gmail.com` # collapse to the same lookup. submission.submitted_by = None submission.email_confirmed = True submission.status = Submission.Status.PROCESSING else: submission.submitted_by = None # guest_email is already on the form's cleaned_data, ModelForm # populated it onto the instance. submission.email_confirmed = False submission.status = Submission.Status.IDENTIFYING submission.confirmation_token = secrets.token_urlsafe(32) submission.confirmation_sent_at = timezone.now() # Persist inside a tight atomic block so the row is committed BEFORE # we hit the email transport. That way a slow / failing Mailtrap API # call can never roll back a saved submission, and we get to look at # the result and surface it as a user-visible notice. with transaction.atomic(): try: submission.save() except IntegrityError: # Extremely rare slug collision on the unique index; clearing # `slug` makes `Submission.save()` regenerate it on retry. submission.slug = "" submission.save() self.object = submission if submission.status == Submission.Status.IDENTIFYING: self._notify_guest(submission) else: messages.success( self.request, format_html( "Submission {} accepted. " "We'll start validating it shortly.", submission.slug, ), ) return super().form_valid(form) # ---- guest-path notice --------------------------------------------------- def _notify_guest(self, submission: Submission) -> None: """Send the confirmation email and surface success / failure to the user via the messages framework. Green on 2xx from Mailtrap, red on any transport failure (with a "sign in with Google instead" hint).""" sent = send_confirmation_email(submission) if sent: messages.success( self.request, format_html( "Submission {slug} created. " "We've sent you a confirmation link — check your inbox and " "click it within 24 hours to add your print to the queue.", slug=submission.slug, ), ) else: messages.error( self.request, format_html( "Submission {slug} was saved, " "but we couldn't send the confirmation email to {email}. " "Try " "signing in with Google instead — it skips email " "confirmation entirely.", slug=submission.slug, email=submission.guest_email, login_url=reverse("account_login"), ), )