import secrets from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin from django.db.models import Count, Q from django.shortcuts import get_object_or_404, redirect from django.utils.html import format_html from django.views import View from django.views.generic import ListView from apps.submissions.models import Submission class IndexView(ListView): """Public dashboard (plan.md §8). Lists every submission whose status is one of the four dashboard-visible states -- `verifying`, `queued`, `printing`, `completed`. Anything in `identifying`, `processing`, `rejected`, or `failed` is excluded from the listing (still reachable by direct slug URL by the submitter). Status-chip filtering via `?status=`; only the four dashboard- visible values are honoured. Anything else falls back to the unfiltered list, so the chips stay safe even if someone hand-edits the URL. """ model = Submission template_name = "dashboard/index.html" context_object_name = "submissions" paginate_by = 20 def _requested_status(self) -> str: raw = self.request.GET.get("status", "") allowed = {str(s) for s in Submission.DASHBOARD_VISIBLE_STATUSES} return raw if raw in allowed else "" def get_queryset(self): qs = Submission.objects.filter( status__in=Submission.DASHBOARD_VISIBLE_STATUSES ).select_related("requested_filament") status = self._requested_status() if status: qs = qs.filter(status=status) return qs def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) # One conditional-aggregate for the chip counts, scoped to the same # dashboard-visible filter so `total` matches what "All" would list. ctx["counts"] = Submission.objects.filter( status__in=Submission.DASHBOARD_VISIBLE_STATUSES ).aggregate( total=Count("id"), verifying=Count("id", filter=Q(status=Submission.Status.VERIFYING)), queued=Count("id", filter=Q(status=Submission.Status.QUEUED)), printing=Count("id", filter=Q(status=Submission.Status.PRINTING)), completed=Count("id", filter=Q(status=Submission.Status.COMPLETED)), ) ctx["active_status"] = self._requested_status() return ctx class ConfirmEmailView(View): """Email-confirmation landing page (plan.md §7.4 step 8). The URL `/p//confirm//` is what the welcome email's button points at. Hitting it does plan.md §7.3's `identifying -> processing` transition (`email_confirmed = True`, `confirmation_token` cleared), then bounces the user back to the dashboard with a green notice. The `processing` worker (plan.md §7.5) picks the row up within ~30 s and moves it to `verifying`, where it becomes visible on the public list. Idempotent: hitting the link twice (or after expiry / approval / rejection) doesn't crash -- it just surfaces a neutral "already past the confirmation step" notice and redirects. Constant-time string compare on the token, so a stranger guessing tokens can't sniff prefix matches via response-time differences. """ def get(self, request, slug, token): # 404 if the row doesn't exist -- including the case where # cleanup_stale has already nuked an unconfirmed submission. sub = get_object_or_404(Submission, slug=slug) if sub.status != Submission.Status.IDENTIFYING: # Already past the confirmation step -- treat as a no-op rather # than an error so refreshing / re-clicking is harmless. messages.info( request, format_html( "Submission {slug} is " "already past the confirmation step (current state: " "{status}). Nothing more to do.", slug=sub.slug, status=sub.get_status_display(), ), ) return redirect("dashboard:index") # Constant-time compare on the token to make timing-side-channel # attacks against the 32-byte secret impractical. stored = sub.confirmation_token or "" if not stored or not secrets.compare_digest(stored, token): messages.error( request, format_html( "Couldn't confirm submission {slug} — " "the link is invalid or has expired. If you submitted " "this print, please " "submit again.", slug=sub.slug, submit_url="/submit/", ), ) return redirect("dashboard:index") sub.status = Submission.Status.PROCESSING sub.email_confirmed = True sub.confirmation_token = "" sub.save() messages.success( request, format_html( "Submission {slug} confirmed! " "It'll appear on the dashboard shortly, after validation.", slug=sub.slug, ), ) return redirect("dashboard:index") class MyPrintsView(LoginRequiredMixin, ListView): """Private listing -- every submission the signed-in user has ever made. Anonymous users are bounced to allauth's `account_login` (the default `LoginRequiredMixin.login_url`, configured via `LOGIN_URL`). Guests don't have a `submitted_by`, so they have nothing to list here anyway. """ model = Submission template_name = "dashboard/my_prints.html" context_object_name = "submissions" paginate_by = 50 def get_queryset(self): # Ordering inherited from `Submission.Meta` (-created_at). return Submission.objects.filter( submitted_by=self.request.user ).select_related("requested_filament") def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) # Counts for the summary line in the page header. A single # conditional-aggregate query beats N separate `.count()` calls. agg = Submission.objects.filter(submitted_by=self.request.user).aggregate( total=Count("id"), queued=Count("id", filter=Q(status=Submission.Status.QUEUED)), printing=Count("id", filter=Q(status=Submission.Status.PRINTING)), completed=Count("id", filter=Q(status=Submission.Status.COMPLETED)), ) ctx["counts"] = agg return ctx