Separate guardians from members: a parent is not automatically a member

members/services/family.py enrolled a parent exactly like the child they were
registering, so every parent held a full membership: counted in the member
list, in the club and platform KPIs, and in the fee roll, with a fee record of
their own. ClubMembership.kind (member | guardian) separates the two.

A guardian is attached to the club only through their child. They hold the
login, can be contacted and can sit in a Group -- the stated exception -- but
they are not a member: no fee (clean() refuses one), absent from the member
list, the fee list and every member count, and not eligible for a roster or a
staff spot. A parent who also plays or coaches is a member who happens to be a
parent; the two facts are independent, which is why this is its own field
rather than inferred from FamilyMembership.role.

A field on ClubMembership rather than a separate model because everything that
answers "is this person attached to this club" already reads through that table
-- tenancy, groups, the club-wide event audience -- and a second kind of link
would need a parallel path through all of it. What changes is only who counts.

Two things that weren't obvious going in:

Excluding guardians had to be a subtraction, not a narrower filter. The obvious
move -- match only member-kind rows and drop the MEMBER-role branch, since an
active membership of any kind grants that role -- also hides someone the club
knows but hasn't signed up for a season yet, which is a real state the member
edit page supports. Two existing tests caught it. _guardians_only() subtracts
instead, so anyone who also plays, is on staff or runs the club stays visible.

Their tie to the club isn't seasonal but rides on a per-season row, so it has
to be carried forward or a parent silently drops off at the season boundary
while their child stays enrolled. Copied from the immediately preceding season
only, so a deliberate removal isn't resurrected from an older row.

The data migration reclassifies existing parents, deliberately skipping anyone
who plays, is on a team's staff or holds an elevated ClubRole -- demoting them
would strip them from their own team's roster eligibility. Anything ambiguous
stays a member, which an admin can flip; noticing someone quietly vanished is
much harder.

The import template gains a membership_kind column next to family_role (a
child marked guardian is refused), and the review screen shows what each row
will join as.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 16:11:41 +02:00
parent ab1c703baf
commit 744b623403
17 changed files with 581 additions and 53 deletions

View File

@@ -19,17 +19,23 @@ from members.models import FamilyMembership, Member
from .forms import MemberForm
TEMPLATE_COLUMNS = ["first_name", "last_name", "date_of_birth", "email", "phone", "emergency_phone", "license", "status", "fee_status", "family_group", "family_role"]
TEMPLATE_COLUMNS = ["first_name", "last_name", "date_of_birth", "email", "phone", "emergency_phone", "license", "status", "fee_status", "family_group", "family_role", "membership_kind"]
REQUIRED_HEADER_COLUMNS = {"first_name", "last_name"}
TEMPLATE_EXAMPLE_ROWS = [
# A standalone member -- no family link.
["Alex", "Morgan", date(2012, 5, 14), "alex.morgan@example.com", "+32470123456", "+32470654321", "", ClubMembership.StatusChoices.ACTIVE, ClubMembership.FeeStatus.UNPAID, "", ""],
["Alex", "Morgan", date(2012, 5, 14), "alex.morgan@example.com", "+32470123456", "+32470654321", "", ClubMembership.StatusChoices.ACTIVE, ClubMembership.FeeStatus.UNPAID, "", "", ClubMembership.Kind.MEMBER],
# A parent and child linked together: same family_group value, one row each.
# The child has no email of its own -- it gets a login only if given one via
# the "Grant login" action later, same as adding a family by hand.
["Taylor", "Doe", "", "taylor.doe@example.com", "+32470654322", "", "", ClubMembership.StatusChoices.ACTIVE, ClubMembership.FeeStatus.UNPAID, "Doe family", FamilyMembership.FamilyRole.PARENT],
["Jamie", "Doe", date(2014, 3, 2), "", "", "+32470654322", "", ClubMembership.StatusChoices.ACTIVE, ClubMembership.FeeStatus.UNPAID, "Doe family", FamilyMembership.FamilyRole.CHILD],
#
# membership_kind sits next to family_role because it answers the question
# family_role raises: this parent is a `guardian`, so they hold the login and
# can be contacted but don't count as a member and owe no fee. Put `member`
# there instead for a parent who also plays -- the two are independent, which
# is why it is its own column rather than inferred from family_role.
["Taylor", "Doe", "", "taylor.doe@example.com", "+32470654322", "", "", ClubMembership.StatusChoices.ACTIVE, "", "Doe family", FamilyMembership.FamilyRole.PARENT, ClubMembership.Kind.GUARDIAN],
["Jamie", "Doe", date(2014, 3, 2), "", "", "+32470654322", "", ClubMembership.StatusChoices.ACTIVE, ClubMembership.FeeStatus.UNPAID, "Doe family", FamilyMembership.FamilyRole.CHILD, ClubMembership.Kind.MEMBER],
]
@@ -48,7 +54,7 @@ def build_member_import_template():
for example_row in TEMPLATE_EXAMPLE_ROWS:
sheet.append(example_row)
for column_name, choices in (("status", ClubMembership.StatusChoices), ("fee_status", ClubMembership.FeeStatus), ("family_role", FamilyMembership.FamilyRole)):
for column_name, choices in (("membership_kind", ClubMembership.Kind), ("status", ClubMembership.StatusChoices), ("fee_status", ClubMembership.FeeStatus), ("family_role", FamilyMembership.FamilyRole)):
column_index = TEMPLATE_COLUMNS.index(column_name) + 1
column_letter = sheet.cell(row=1, column=column_index).column_letter
options = ",".join(choices.values)
@@ -140,6 +146,12 @@ def parse_member_import_rows(rows, club):
family_group, family_role, family_errors = _parse_family_fields(raw)
errors.extend(family_errors)
# The two columns are otherwise independent -- a parent may or may not also
# be a member -- but a child is the member the guardian is attached *to*,
# so that particular combination is always a mistake.
if family_role == FamilyMembership.FamilyRole.CHILD and membership_kwargs["kind"] == ClubMembership.Kind.GUARDIAN:
errors.append(_("A child is always a member, so membership_kind cannot be 'guardian'."))
results.append(
{
"line_number": line_number,
@@ -159,6 +171,16 @@ def _parse_membership_fields(raw):
errors = []
license_number = raw.get("license", "").strip()
kind = raw.get("membership_kind", "").strip()
if kind:
kind_value = _match_choice(kind, ClubMembership.Kind)
if kind_value is None:
errors.append(_("Invalid membership_kind '%(value)s'.") % {"value": kind})
else:
# Blank means member: the overwhelmingly common row, and the value every
# file written before this column existed effectively carried.
kind_value = ClubMembership.Kind.MEMBER
status = raw.get("status", "").strip()
if status:
status_value = _match_choice(status, ClubMembership.StatusChoices)
@@ -175,7 +197,7 @@ def _parse_membership_fields(raw):
else:
fee_status_value = ClubMembership.FeeStatus.UNPAID
return {"license": license_number, "status": status_value, "fee_status": fee_status_value}, errors
return {"kind": kind_value, "license": license_number, "status": status_value, "fee_status": fee_status_value}, errors
def _parse_family_fields(raw):

View File

@@ -707,6 +707,12 @@ class FamilyCreateForm(forms.Form):
parent_last_name = forms.CharField(label=_("Parent last name"))
parent_email = forms.EmailField(label=_("Parent email"), help_text=_("If this email has no account yet, one is created and they set a password via the reset link."))
parent_is_member = forms.BooleanField(
label=_("Parent is also a member"),
required=False,
help_text=_("Tick only if this parent belongs to the club in their own right (they play, or hold a membership). Left unticked they are a guardian: they hold the login and can be contacted, but owe no fee and are not counted as a member."),
)
child_first_name = forms.CharField(label=_("Child first name"))
child_last_name = forms.CharField(label=_("Child last name"))
child_date_of_birth = forms.DateField(label=_("Child date of birth"), required=False, widget=forms.DateInput(attrs={"type": "date"}))
@@ -728,6 +734,11 @@ class AddParentForm(forms.Form):
email = forms.EmailField(label=_("Email address"), help_text=_("If this email has no account yet, one is created and they set a password via the reset link."))
first_name = forms.CharField(label=_("First name"), required=False)
last_name = forms.CharField(label=_("Last name"), required=False)
parent_is_member = forms.BooleanField(
label=_("Also a member"),
required=False,
help_text=_("Tick only if this parent belongs to the club in their own right (they play, or hold a membership). Left unticked they are a guardian: they hold the login and can be contacted, but owe no fee and are not counted as a member."),
)
def clean(self):
cleaned = super().clean()
@@ -790,7 +801,7 @@ class ClubMembershipForm(forms.ModelForm):
class Meta:
model = ClubMembership
fields = ["license", "status", "fee_status", "fee_amount"]
fields = ["kind", "license", "status", "fee_status", "fee_amount"]
class NewsForm(forms.ModelForm):

View File

@@ -21,6 +21,7 @@
{% form_field form.parent_first_name %}
{% form_field form.parent_last_name %}
{% form_field form.parent_email %}
{% form_field form.parent_is_member %}
</div>
<div class="divider"></div>

View File

@@ -79,7 +79,23 @@
<div class="card-body">
<h2 class="card-title text-base">{% lucide "id-card" size=18 %} {% trans "Club membership" %}</h2>
{% if current_membership %}
{% if current_membership.is_guardian %}
{% comment %}
Stated plainly rather than left to be inferred from an empty
fee: a guardian is deliberately missing from the member list
and every member count, and someone looking at this page needs
to know that is on purpose. See club.models.ClubMembership.Kind.
{% endcomment %}
<div class="alert alert-warning py-2 my-1">
{% lucide "users" size=16 %}
<span class="text-sm">{% trans "Guardian: attached to the club as a parent of a member. Holds the login, owes no fee, and is not counted as a member." %}</span>
</div>
{% endif %}
<dl class="divide-y divide-base-200">
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Joined as" %}</dt>
<dd class="font-semibold">{{ current_membership.get_kind_display|capfirst }}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "License" %}</dt>
<dd class="font-semibold">{{ current_membership.license|default:"-" }}</dd>
@@ -88,10 +104,12 @@
<dt class="text-sm opacity-70">{% trans "Status" %}</dt>
<dd class="font-semibold">{{ current_membership.get_status_display }}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Fee status" %}</dt>
<dd class="font-semibold">{{ current_membership.get_fee_status_display }}</dd>
</div>
{% if not current_membership.is_guardian %}
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Fee status" %}</dt>
<dd class="font-semibold">{{ current_membership.get_fee_status_display }}</dd>
</div>
{% endif %}
</dl>
{% else %}
<p class="text-sm opacity-60">{% trans "Not rostered for the current season." %}</p>

View File

@@ -27,6 +27,7 @@
<th>{% trans "First name" %}</th>
<th>{% trans "Email" %}</th>
<th>{% trans "Family" %}</th>
<th>{% trans "Joining as" %}</th>
<th>{% trans "Outcome" %}</th>
</tr>
</thead>
@@ -45,6 +46,18 @@
<span class="opacity-40">-</span>
{% endif %}
</td>
<td>
{% comment %}
A guardian holds the login for a child but is not a member:
no fee, and not counted in any member total. See
club.models.ClubMembership.Kind.
{% endcomment %}
{% if result.membership_kwargs.kind == "guardian" %}
<span class="badge badge-warning badge-sm">{% trans "Guardian" %}</span>
{% else %}
<span class="badge badge-neutral badge-sm">{% trans "Member" %}</span>
{% endif %}
</td>
<td>
{% if result.member %}
<span class="badge badge-success badge-sm">{% trans "Will create" %}</span>
@@ -56,7 +69,7 @@
</tr>
{% empty %}
<tr>
<td colspan="6" class="text-center opacity-60">{% trans "No rows found in the uploaded file." %}</td>
<td colspan="7" class="text-center opacity-60">{% trans "No rows found in the uploaded file." %}</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -246,7 +246,7 @@ class MemberManagementTests(ManagementTestBase):
# one combined submit, so its (required) fields must come along.
self.club_post(
"member_update",
{"first_name": "New", "last_name": "Name", "status": ClubMembership.StatusChoices.ACTIVE, "fee_status": ClubMembership.FeeStatus.UNPAID},
{"first_name": "New", "last_name": "Name", "kind": ClubMembership.Kind.MEMBER, "status": ClubMembership.StatusChoices.ACTIVE, "fee_status": ClubMembership.FeeStatus.UNPAID},
member.pk,
)
@@ -1363,6 +1363,95 @@ class FamilyManagementTests(ManagementTestBase):
self.assertEqual(family.guardians.count(), 1)
class GuardianViewTests(ManagementTestBase):
"""How a guardian -- a parent attached to the club only through their child --
behaves across the management UI. See club.models.ClubMembership.Kind; the
model-level guarantees live in club.tests.GuardianMembershipTests."""
def family_payload(self, **overrides):
payload = {
"parent_first_name": "Pat",
"parent_last_name": "Parent",
"parent_email": "pat.parent@example.com",
"child_first_name": "Cody",
"child_last_name": "Child",
"child_date_of_birth": "2015-04-01",
}
payload.update(overrides)
return payload
def test_registering_a_family_makes_the_parent_a_guardian_and_the_child_a_member(self):
self.client.force_login(self.admin_user)
self.club_post("family_create", self.family_payload())
parent = Member.objects.get(first_name="Pat", last_name="Parent")
child = Member.objects.get(first_name="Cody", last_name="Child")
self.assertEqual(ClubMembership.objects.get(club=self.club, member=parent).kind, ClubMembership.Kind.GUARDIAN)
self.assertEqual(ClubMembership.objects.get(club=self.club, member=child).kind, ClubMembership.Kind.MEMBER)
def test_ticking_also_a_member_enrols_the_parent_as_one(self):
self.client.force_login(self.admin_user)
self.club_post("family_create", self.family_payload(parent_is_member="on"))
parent = Member.objects.get(first_name="Pat", last_name="Parent")
self.assertEqual(ClubMembership.objects.get(club=self.club, member=parent).kind, ClubMembership.Kind.MEMBER)
def test_a_guardian_is_absent_from_the_member_list(self):
self.client.force_login(self.admin_user)
self.club_post("family_create", self.family_payload())
response = self.club_get("member_list")
self.assertContains(response, "Cody Child")
self.assertNotContains(response, "Pat Parent")
def test_a_guardian_is_not_counted_in_the_membership_kpis(self):
# Measured as a delta, not an absolute: the base fixture's admin is a
# member too, and this is about what registering a family *adds*.
self.client.force_login(self.admin_user)
before = self.club_get("membership_list").context["kpi_total"]
self.club_post("family_create", self.family_payload())
after = self.club_get("membership_list").context["kpi_total"]
# The child only. A guardian owes nothing, so counting them would
# overstate the roll and everything derived from it.
self.assertEqual(after - before, 1)
def test_a_guardians_own_page_is_still_reachable(self):
# Excluded from the member *list*, not from the club: an admin has to be
# able to open them, edit them, and switch them to a member.
self.client.force_login(self.admin_user)
self.club_post("family_create", self.family_payload())
parent = Member.objects.get(first_name="Pat", last_name="Parent")
self.assertEqual(self.club_get("member_detail", parent.pk).status_code, 200)
self.assertEqual(self.club_get("member_update", parent.pk).status_code, 200)
def test_a_guardian_can_still_be_put_in_a_group(self):
# The stated exception: a parent may well sit on a committee.
self.client.force_login(self.admin_user)
self.club_post("family_create", self.family_payload())
parent = Member.objects.get(first_name="Pat", last_name="Parent")
group = Group.objects.create(club=self.club, name="Committee")
response = self.club_get("group_bulk_add", group.pk)
self.assertContains(response, str(parent.pk))
def test_a_guardian_is_not_offered_for_a_team_roster(self):
self.client.force_login(self.admin_user)
self.club_post("family_create", self.family_payload())
parent = Member.objects.get(first_name="Pat", last_name="Parent")
team = Team.objects.create(club=self.club, name="First Team", short_name="1st")
response = self.club_get("team_bulk_add", team.pk, self.season.pk)
self.assertNotContains(response, str(parent.pk))
class MemberListFamilyColumnTests(ManagementTestBase):
"""The member list is one flat table -- family is a column (each member's family/
role attached in Python, management.views.MemberListView), not a grouping."""
@@ -1878,6 +1967,7 @@ class MemberClubMembershipFormTests(ManagementTestBase):
{
"first_name": "Fee",
"last_name": "Payer",
"kind": ClubMembership.Kind.MEMBER,
"license": "BE-9999",
"status": ClubMembership.StatusChoices.ACTIVE,
"fee_status": ClubMembership.FeeStatus.PAID,
@@ -1912,7 +2002,7 @@ class MemberClubMembershipFormTests(ManagementTestBase):
response = self.club_post(
"member_update",
{"first_name": "Unrostered", "last_name": "Member", "status": ClubMembership.StatusChoices.ACTIVE, "fee_status": ClubMembership.FeeStatus.PAID},
{"first_name": "Unrostered", "last_name": "Member", "kind": ClubMembership.Kind.MEMBER, "status": ClubMembership.StatusChoices.ACTIVE, "fee_status": ClubMembership.FeeStatus.PAID},
member.pk,
)
@@ -2893,6 +2983,52 @@ class MemberBulkImportTests(ManagementTestBase):
self.assertEqual(FamilyMembership.objects.get(family=family, member=parent).role, FamilyMembership.FamilyRole.PARENT)
self.assertEqual(FamilyMembership.objects.get(family=family, member=child).role, FamilyMembership.FamilyRole.CHILD)
def test_membership_kind_guardian_creates_a_guardian_not_a_member(self):
# Columns are positional; membership_kind is the last one.
upload = make_import_workbook(
[
["Taylor", "Doe", "", "taylor.guardian@example.com", "", "", "", "", "", "Doe family", "parent", "guardian"],
["Jamie", "Doe", "2014-03-02", "", "", "", "", "", "", "Doe family", "child", "member"],
]
)
self.club_post("member_import", {"file": upload})
self.club_post("member_import_confirm", {})
parent = Member.objects.get(email="taylor.guardian@example.com")
child = Member.objects.get(first_name="Jamie", last_name="Doe")
self.assertEqual(ClubMembership.objects.get(club=self.club, member=parent).kind, ClubMembership.Kind.GUARDIAN)
self.assertEqual(ClubMembership.objects.get(club=self.club, member=child).kind, ClubMembership.Kind.MEMBER)
def test_a_blank_membership_kind_still_means_member(self):
# Every file written before the column existed carried this implicitly.
upload = make_import_workbook([["Solo", "Blankkind", "", "solo.blank@example.com", "", "", "", "", "", "", "", ""]])
self.club_post("member_import", {"file": upload})
self.club_post("member_import_confirm", {})
member = Member.objects.get(email="solo.blank@example.com")
self.assertEqual(ClubMembership.objects.get(club=self.club, member=member).kind, ClubMembership.Kind.MEMBER)
def test_a_child_marked_as_a_guardian_is_an_error(self):
# A child is the member the guardian is attached *to*.
upload = make_import_workbook([["Jamie", "Doe", "2014-03-02", "", "", "", "", "", "", "Doe family", "child", "guardian"]])
response = self.club_post("member_import", {"file": upload})
result = response.context["results"][0]
self.assertIsNone(result["member"])
self.assertTrue(any("child is always a member" in error.lower() for error in result["errors"]))
def test_an_invalid_membership_kind_is_reported(self):
upload = make_import_workbook([["Odd", "Kind", "", "odd.kind@example.com", "", "", "", "", "", "", "", "sponsor"]])
response = self.club_post("member_import", {"file": upload})
result = response.context["results"][0]
self.assertIsNone(result["member"])
self.assertTrue(any("membership_kind" in error for error in result["errors"]))
def test_family_role_without_a_group_is_an_error(self):
upload = make_import_workbook([["Odd", "Row", "", "odd.row@example.com", "", "", "", "", "", "", "parent"]])

View File

@@ -239,7 +239,9 @@ class MembershipListView(ClubAdminRequiredMixin, ListView):
if season is None:
return ClubMembership.objects.none()
memberships = ClubMembership.objects.filter(club=self.request.club, season=season).select_related("member").order_by("member__last_name", "member__first_name")
# kind=MEMBER: a guardian holds no membership and owes no fee, so they
# belong in neither this list nor the KPIs below it.
memberships = ClubMembership.objects.filter(club=self.request.club, season=season, kind=ClubMembership.Kind.MEMBER).select_related("member").order_by("member__last_name", "member__first_name")
fee_status = self.request.GET.get("fee_status", "not_paid")
if fee_status == "not_paid":
@@ -279,7 +281,7 @@ class MembershipListView(ClubAdminRequiredMixin, ListView):
counts = {}
if current is not None:
counts = {row["fee_status"]: row["count"] for row in ClubMembership.objects.filter(club=club, season=current).values("fee_status").annotate(count=Count("id"))}
counts = {row["fee_status"]: row["count"] for row in ClubMembership.objects.filter(club=club, season=current, kind=ClubMembership.Kind.MEMBER).values("fee_status").annotate(count=Count("id"))}
paid = counts.get(ClubMembership.FeeStatus.PAID, 0)
partial = counts.get(ClubMembership.FeeStatus.PARTIALLY_PAID, 0)
unpaid = counts.get(ClubMembership.FeeStatus.UNPAID, 0)
@@ -569,7 +571,7 @@ class MemberUpdateView(ClubAdminRequiredMixin, View):
template_name = "management/member_form.html"
def get_member(self):
return get_object_or_404(members_visible_to(self.request.user, self.request.club), pk=self.kwargs["pk"])
return get_object_or_404(members_visible_to(self.request.user, self.request.club, include_guardians=True), pk=self.kwargs["pk"])
def get_membership(self, member):
season = current_season(self.request.club)
@@ -607,10 +609,10 @@ class MemberDetailView(ClubStaffRequiredMixin, DetailView):
context_object_name = "member"
def get_queryset(self):
return members_visible_to(self.request.user, self.request.club)
return members_visible_to(self.request.user, self.request.club, include_guardians=True)
def get_context_data(self, **kwargs):
visible = members_visible_to(self.request.user, self.request.club)
visible = members_visible_to(self.request.user, self.request.club, include_guardians=True)
my_family_ids = FamilyMembership.objects.filter(member=self.object).values_list("family_id", flat=True)
family_scoped_members = visible.filter(family_memberships__family_id__in=my_family_ids).distinct()
family_groups, _ = group_by_family(family_scoped_members)
@@ -647,11 +649,11 @@ class MemberAttachToFamilyView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, F
return {"pk": self.kwargs["pk"]}
def get_form_kwargs(self):
member = get_object_or_404(members_visible_to(self.request.user, self.request.club), pk=self.kwargs["pk"])
member = get_object_or_404(members_visible_to(self.request.user, self.request.club, include_guardians=True), pk=self.kwargs["pk"])
return super().get_form_kwargs() | {"club": self.request.club, "member": member}
def form_valid(self, form):
member = get_object_or_404(members_visible_to(self.request.user, self.request.club), pk=self.kwargs["pk"])
member = get_object_or_404(members_visible_to(self.request.user, self.request.club, include_guardians=True), pk=self.kwargs["pk"])
family = attach_to_family(member, role=form.cleaned_data["role"], family=form.cleaned_data["family"])
body = _("%(member)s” is now part of %(family)s.") % {"member": member, "family": family}
notify(self.request, f"s|{_('Added to family')}|{body}")
@@ -671,7 +673,7 @@ class MemberRefereeEligibilityUpdateView(ClubAdminRequiredMixin, RedirectOnInval
return {"pk": self.kwargs["pk"]}
def get_member(self):
return get_object_or_404(members_visible_to(self.request.user, self.request.club), pk=self.kwargs["pk"])
return get_object_or_404(members_visible_to(self.request.user, self.request.club, include_guardians=True), pk=self.kwargs["pk"])
def get_form_kwargs(self):
return super().get_form_kwargs() | {"club": self.request.club, "member": self.get_member()}
@@ -697,7 +699,7 @@ class MemberGrantLoginView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormV
return {"pk": self.kwargs["pk"]}
def form_valid(self, form):
member = get_object_or_404(members_visible_to(self.request.user, self.request.club), pk=self.kwargs["pk"])
member = get_object_or_404(members_visible_to(self.request.user, self.request.club, include_guardians=True), pk=self.kwargs["pk"])
if member.user_id is not None:
# Already has one -- the row's button shouldn't have been there at all;
# a direct POST replay (e.g. a resubmitted form) is the only way here.
@@ -710,7 +712,7 @@ class MemberGrantLoginView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormV
class MemberDetachFromFamilyView(ClubAdminRequiredMixin, View):
def post(self, request, pk, family_pk):
member = get_object_or_404(members_visible_to(request.user, request.club), pk=pk)
member = get_object_or_404(members_visible_to(request.user, request.club, include_guardians=True), pk=pk)
family = get_object_or_404(Family, pk=family_pk, memberships__member=member)
# detach_from_family may delete `family` itself (left empty) -- str() it first,
# since Family.__str__ queries self.memberships, which needs a pk to still exist.
@@ -752,7 +754,7 @@ class FamilyMembershipRoleUpdateView(ClubAdminRequiredMixin, View):
class MemberDeleteView(ClubAdminRequiredMixin, View):
def post(self, request, pk):
member = get_object_or_404(members_visible_to(request.user, request.club), pk=pk)
member = get_object_or_404(members_visible_to(request.user, request.club, include_guardians=True), pk=pk)
name = str(member)
# FamilyMembership cascades away with the member -- note which families
# they were in before that happens, so an emptied one can be cleaned up
@@ -1344,6 +1346,7 @@ class FamilyCreateView(ClubAdminRequiredMixin, FormView):
child_first_name=cd["child_first_name"],
child_last_name=cd["child_last_name"],
child_date_of_birth=cd["child_date_of_birth"],
parent_is_member=cd["parent_is_member"],
)
if season is not None:
@@ -1372,7 +1375,7 @@ class FamilyDetailView(ClubStaffRequiredMixin, DetailView):
return families_of_club(self.request.club)
def get_context_data(self, **kwargs):
visible = members_visible_to(self.request.user, self.request.club)
visible = members_visible_to(self.request.user, self.request.club, include_guardians=True)
members = visible.filter(family_memberships__family=self.object).distinct()
# group_by_family scopes by member, not family -- a member visible here
# because they're in *this* family can also belong to another one, in
@@ -1541,7 +1544,7 @@ class RefereeListView(ClubStaffRequiredMixin, ListView):
context_object_name = "referees"
def get_queryset(self):
members = members_visible_to(self.request.user, self.request.club).filter(referee_profile__isnull=False)
members = members_visible_to(self.request.user, self.request.club, include_guardians=True).filter(referee_profile__isnull=False)
return members.select_related("referee_profile", "referee_profile__level").prefetch_related("referee_profile__level__teams").order_by("last_name", "first_name")
@@ -1629,7 +1632,7 @@ class GroupBulkAddView(ClubAdminRequiredMixin, View):
def get_form_kwargs(self, group):
existing_ids = set(GroupMembership.objects.filter(group=group).values_list("member_id", flat=True))
members = members_visible_to(self.request.user, self.request.club).order_by("last_name", "first_name")
members = members_visible_to(self.request.user, self.request.club, include_guardians=True).order_by("last_name", "first_name")
# One member query for the whole formset -- see TeamBulkAddView.get_form_kwargs.
member_choices = [("", "---------")] + [(member.pk, _("%(member)s — already in this group") % {"member": member} if member.pk in existing_ids else str(member)) for member in members]