Files
RosterChief/members/views.py
Bernard Siebens cb7f56709b 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>
2026-08-11 18:33:41 +02:00

76 lines
3.2 KiB
Python

"""Public and member-facing pages: claiming a child, and a parent's own family.
Everything here is outside the management app on purpose -- a parent is not club
staff, and ClubStaffRequiredMixin would (rightly) turn them away.
"""
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import Http404
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
class ClubScopedPublicMixin:
"""A club subdomain resolves this page; the base domain has no club to claim
a child at, so it simply doesn't exist there."""
def dispatch(self, request, *args, **kwargs):
if getattr(request, "club", None) is None:
raise Http404("This page belongs to a club.")
return super().dispatch(request, *args, **kwargs)
class ParentClaimView(ClubScopedPublicMixin, FormView):
"""Public. Submitting is also how a parent registers -- there is no open
signup page, so this form is the only way into an account for someone the
club hasn't already added.
It always reports the same thing back, whether or not the child was found:
the response must not tell an anonymous submitter which children the club
has. What actually happens next is decided by an admin.
"""
template_name = "members/parent_claim.html"
form_class = ParentClaimForm
def form_valid(self, form):
submit_claim(self.request.club, **form.cleaned_data)
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):
"""What a linked parent sees: the children they're responsible for, and
nothing else. The seam a real parent portal would grow from."""
template_name = "members/my_family.html"
def get_context_data(self, **kwargs):
me = Member.objects.filter(user=self.request.user).first()
children = Member.objects.none()
if me is not None:
children = Member.objects.filter(
family_memberships__role=FamilyMembership.FamilyRole.CHILD,
family_memberships__family__memberships__member=me,
family_memberships__family__memberships__role__in=[FamilyMembership.FamilyRole.PARENT, FamilyMembership.FamilyRole.GUARDIAN],
member_of__club=self.request.club,
).distinct()
return super().get_context_data(club=self.request.club, me=me, children=children, **kwargs)