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:
23
club/migrations/0022_clubmembership_kind.py
Normal file
23
club/migrations/0022_clubmembership_kind.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
58
club/migrations/0023_backfill_guardian_memberships.py
Normal file
58
club/migrations/0023_backfill_guardian_memberships.py
Normal 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)]
|
||||
Reference in New Issue
Block a user