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

@@ -0,0 +1,23 @@
# Generated by Django 6.0.6 on 2026-08-11 13:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0021_club_legal_name'),
]
operations = [
migrations.AddField(
model_name='clubmembership',
name='kind',
field=models.CharField(choices=[('member', 'member'), ('guardian', 'guardian')], default='member', help_text="A guardian is attached to the club only as a parent of a member -- they hold the login, but don't count as a member themselves and owe no fee. A parent who also plays is a member.", max_length=20, verbose_name='kind'),
),
migrations.AlterField(
model_name='club',
name='legal_name',
field=models.CharField(blank=True, help_text='Full registered name (e.g. including a legal form like VZW/ASBL), used on official documents. Falls back to club name if blank.', max_length=255, verbose_name='legal name'),
),
]

View File

@@ -0,0 +1,58 @@
"""Reclassify existing family parents as guardians.
Before ``kind`` existed, members/services/family.py enrolled a parent exactly like
the child they were registering, so every parent already in the database holds a
full membership and is counted as a member.
The guard matters more than the rule: anyone who is *also* on a roster, on a
team's staff, or holds an elevated ClubRole is left as a member. A parent who
plays, coaches or runs the club is a member who happens to have children there,
and silently demoting them would strip them out of the member list and their own
team's roster eligibility. Anything ambiguous stays as it is -- an admin can flip
a membership to guardian by hand, which is cheap; noticing that someone quietly
vanished is not.
"""
from django.db import migrations
def backfill_guardians(apps, schema_editor):
ClubMembership = apps.get_model("club", "ClubMembership")
FamilyMembership = apps.get_model("members", "FamilyMembership")
TeamMembership = apps.get_model("teams", "TeamMembership")
StaffAssignment = apps.get_model("teams", "StaffAssignment")
ClubRole = apps.get_model("club", "ClubRole")
parent_ids = set(FamilyMembership.objects.filter(role__in=["parent", "guardian"]).values_list("member_id", flat=True))
if not parent_ids:
return
for membership in ClubMembership.objects.filter(member_id__in=parent_ids).iterator():
club_id, member_id = membership.club_id, membership.member_id
plays = TeamMembership.objects.filter(member_id=member_id, team__club_id=club_id).exists()
on_staff = StaffAssignment.objects.filter(member_id=member_id, team__club_id=club_id).exists()
runs_the_club = ClubRole.objects.filter(member_id=member_id, club_id=club_id, role__in=["admin", "editor"]).exists()
if plays or on_staff or runs_the_club:
continue
membership.kind = "guardian"
# Guardians owe nothing; clear any fee the old parent-as-member flow left behind.
membership.fee_amount = 0
membership.save(update_fields=["kind", "fee_amount"])
def restore_members(apps, schema_editor):
"""Everything was a member before this migration ran."""
ClubMembership = apps.get_model("club", "ClubMembership")
ClubMembership.objects.filter(kind="guardian").update(kind="member")
class Migration(migrations.Migration):
dependencies = [
("club", "0022_clubmembership_kind"),
("members", "0004_group_groupmembership_and_more"),
("teams", "0009_team_referee_management"),
]
operations = [migrations.RunPython(backfill_guardians, restore_members)]