Files
hamprint/apps/dashboard/views.py

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> &mdash; "
"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