Email a set-password link on claim approval, flash identically either way

Two additions to the parent-claim flow: Club.contact_email (set from the
control panel, next to legal_name), and an email sent when an admin approves a
claim -- a real one-time set-password link built with allauth's own token
generator, so it lands in the same flow the login page's own reset would send
a parent to rather than a second, parallel one that could drift out of step
with it.

Never allowed to fail the approval: the family link and the guardian row are
real either way, and a mail server being briefly unreachable must not cost a
parent their place in the queue. The admin gets a distinct warning telling
them the email didn't go and to have the parent use "Forgot your password?"
instead.

The public submission flash keeps the enumeration guarantee the claim form
itself was built around: worded and timed identically whether or not a
matching child was found, sent before any lookup happens at all, mentioning
the club's contact email when the club has set one. A test compares the
rendered flash across a matching and a non-matching submission byte for byte.

One test-writing trap worth recording: assertRedirects follows the redirect
itself by default, and its own probe GET consumed the one-shot flash message
before a later explicit GET in the same test could see it --
fetch_redirect_response=False avoids the double-fetch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 18:33:41 +02:00
parent ca2b1a11b5
commit cb7f56709b
12 changed files with 186 additions and 35 deletions

View File

@@ -12,14 +12,23 @@ must never confirm whether a given child exists -- so it takes free text and
matches nothing itself.
"""
from allauth.account.forms import default_token_generator
from allauth.account.utils import user_pk_to_url_str
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.mail import send_mail
from django.db import transaction
from django.db.models import Exists, OuterRef, Q
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils import timezone
from club.models import ClubMembership
from members.models import Family, FamilyMembership, Member, ParentClaim
from members.services.family import add_parent_to_family
User = get_user_model()
#: Suggestions are ranked, never auto-applied -- an exact name-and-birthday match
#: is still only a suggestion, because the whole point of the queue is that a
#: human confirms it.
@@ -139,3 +148,36 @@ def reject_claim(claim, *, reviewed_by=None, note=""):
claim.note = note
claim.save(update_fields=["status", "reviewed_by", "reviewed_at", "note"])
return claim
def send_claim_approved_email(claim, *, child, request=None):
"""Tell the parent their account is ready, with a link that sets their password.
A real one-time link rather than "go to the reset page and type your email":
the account was created for them with no usable password, so being told to
"reset" something they never had reads as an error. Built with allauth's own
token generator so it lands in the same flow the login page would send them
to, rather than a second, parallel one that could drift out of step with it.
Never fatal: an approved claim is a real link in the database whether or not
the mail leaves the building, and losing that link because a mail server was
briefly unreachable would be far worse than a parent needing a nudge.
"""
user = User.objects.filter(email__iexact=claim.parent_email).first()
if user is None:
return False
path = reverse("account_reset_password_from_key", kwargs={"uidb36": user_pk_to_url_str(user), "key": default_token_generator.make_token(user)})
set_password_url = request.build_absolute_uri(path) if request is not None else path
context = {"club": claim.club, "child": child, "parent_first_name": claim.parent_first_name, "set_password_url": set_password_url}
subject = " ".join(render_to_string("members/email/claim_approved_subject.txt", context).split())
body = render_to_string("members/email/claim_approved.txt", context).strip() + "\n"
try:
send_mail(subject, body, settings.DEFAULT_FROM_EMAIL, [claim.parent_email], fail_silently=False)
except OSError:
# Anything the mail backend raises for an unreachable server or a refused
# connection. The link stands; the club can resend from the queue.
return False
return True

View File

@@ -0,0 +1,13 @@
{% load i18n %}{% blocktrans with name=parent_first_name %}Hello {{ name }},{% endblocktrans %}
{% blocktrans with club=club.name child=child %}{{ club }} has confirmed that you're {{ child }}'s parent or guardian, and your account is ready.{% endblocktrans %}
{% trans "Set your password here:" %}
{{ set_password_url }}
{% blocktrans %}That link is for you alone — please don't forward it.{% endblocktrans %}
{% blocktrans %}Once you're signed in you'll see the children linked to you.{% endblocktrans %}
{% if club.contact_email %}
{% blocktrans with email=club.contact_email %}Something not right? Reply to this note or write to {{ email }}.{% endblocktrans %}
{% endif %}
{% blocktrans with club=club.name %}— {{ club }}{% endblocktrans %}

View File

@@ -0,0 +1 @@
{% load i18n %}{% blocktrans with club=club.name %}Your {{ club }} account is ready{% endblocktrans %}

View File

@@ -1,20 +0,0 @@
{% extends "_club_base.html" %}
{% load i18n lucide %}
{% block head_title %}{% trans "Request sent" %}{% endblock head_title %}
{% block main %}
{% comment %}
Says the same thing whether or not the child was found: this page is public,
so confirming a match would let anyone test which children the club has.
{% endcomment %}
<div class="flex justify-center">
<div class="card w-full max-w-xl bg-base-100 shadow">
<div class="card-body">
<h1 class="card-title">{% lucide "circle-check" size=20 %} {% trans "Request sent" %}</h1>
<p>{% blocktrans with club=club.name %}Thanks — {{ club }} will check this against their records.{% endblocktrans %}</p>
<p class="text-sm opacity-70">{% blocktrans %}If it matches, you'll get an email with a link to set your password. If you don't hear anything, get in touch with the club directly.{% endblocktrans %}</p>
</div>
</div>
</div>
{% endblock main %}

View File

@@ -6,9 +6,11 @@ staff, and ClubStaffRequiredMixin would (rightly) turn them away.
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import Http404
from django.shortcuts import render
from django.shortcuts import redirect
from django.utils.translation import gettext_lazy as _
from django.views.generic import FormView, TemplateView
from controlpanel.messages import notify
from members.forms import ParentClaimForm
from members.models import FamilyMembership, Member
from members.services.claims import submit_claim
@@ -39,7 +41,17 @@ class ParentClaimView(ClubScopedPublicMixin, FormView):
def form_valid(self, form):
submit_claim(self.request.club, **form.cleaned_data)
return render(self.request, "members/parent_claim_submitted.html", {"club": self.request.club})
club = self.request.club
title = _("Request received")
body = _("%(club)s will check this against their records. If it matches, you'll get an email with a link to set your password.") % {"club": club.name}
if club.contact_email:
body += " " + _("Any questions, write to %(email)s.") % {"email": club.contact_email}
# Worded identically whether or not that child exists, and sent before any
# lookup happens at all -- a message that differed would tell an anonymous
# submitter which children the club has.
notify(self.request, f"s|{title}|{body}")
return redirect("members:parent_claim")
class MyFamilyView(ClubScopedPublicMixin, LoginRequiredMixin, TemplateView):