Add proper email notifications

This commit is contained in:
2026-05-14 23:19:50 +03:00
parent fe62575790
commit 9e16b78793
34 changed files with 1313 additions and 83 deletions

View File

@@ -7,9 +7,9 @@ app_name = "dashboard"
urlpatterns = [
path("", views.IndexView.as_view(), name="index"),
path("my-prints/", views.MyPrintsView.as_view(), name="my_prints"),
path("p/<slug:slug>/confirm/<str:token>/", views.ConfirmEmailView.as_view(), name="confirm"),
# Routes to be added as features land (see plan.md Section 7):
# path("p/<slug:slug>/", views.SubmissionDetailView.as_view(), name="detail"),
# path("p/<slug:slug>/status/", views.SubmissionStatusFragment.as_view(), name="status"),
# path("p/<slug:slug>/confirm/<str:token>/", views.ConfirmEmailView.as_view(), name="confirm"),
# path("p/<slug:slug>/resend/", views.ResendConfirmationView.as_view(), name="resend"),
]

View File

@@ -1,5 +1,11 @@
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
@@ -54,6 +60,76 @@ class IndexView(ListView):
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.