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

@@ -253,6 +253,10 @@ class Season(ClubScopedModel):
class ClubMembership(ClubScopedModel):
class Kind(models.TextChoices):
MEMBER = "member", _("member")
GUARDIAN = "guardian", _("guardian")
class StatusChoices(models.TextChoices):
ACTIVE = "active", _("active")
PENDING = "pending", _("pending")
@@ -268,6 +272,14 @@ class ClubMembership(ClubScopedModel):
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="member_of", verbose_name=_("member"))
season = models.ForeignKey(Season, on_delete=models.PROTECT, related_name="memberships", verbose_name=_("season"))
kind = models.CharField(
_("kind"),
max_length=20,
choices=Kind.choices,
default=Kind.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."),
)
license = models.CharField(_("license"), max_length=250, blank=True)
status = models.CharField(_("status"), max_length=250, choices=StatusChoices.choices, default=StatusChoices.PENDING)
fee_status = models.CharField(_("fee status"), max_length=250, choices=FeeStatus.choices, default=FeeStatus.UNPAID)
@@ -289,8 +301,25 @@ class ClubMembership(ClubScopedModel):
def __str__(self):
return f"{self.club} - {self.member}"
@property
def is_guardian(self) -> bool:
"""Attached to the club as a parent of a member, not as one themselves.
Guardians are deliberately kept as ClubMembership rows rather than given
their own model: everything that answers "is this person attached to this
club" (tenancy scoping, group membership, the event audience) already
reads through this table, and a second kind of link would need a parallel
path through all of it. What changes is only who *counts* -- the member
list, the fee list and every member KPI filter on ``kind``.
"""
return self.kind == self.Kind.GUARDIAN
def clean(self):
validate_club_scope(self, self.club_id, same_club_fields=("season",))
# A guardian owes nothing -- they're not a member. Caught here rather than
# silently zeroed on save so a mistaken import row says so out loud.
if self.is_guardian and self.fee_amount:
raise ValidationError({"fee_amount": _("A guardian doesn't hold a membership, so they can't owe a fee.")})
class FeePayment(UUIDModel):