164 lines
6.7 KiB
Python
164 lines
6.7 KiB
Python
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=<value>`; 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/<slug>/confirm/<token>/` 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 <strong class=\"font-mono\">{slug}</strong> is "
|
|
"already past the confirmation step (current state: "
|
|
"<em>{status}</em>). 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 <strong class=\"font-mono\">{slug}</strong> — "
|
|
"the link is invalid or has expired. If you submitted "
|
|
"this print, please <a href=\"{submit_url}\" class=\"underline font-medium\">"
|
|
"submit again</a>.",
|
|
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 <strong class=\"font-mono\">{slug}</strong> 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
|