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:
@@ -20,7 +20,7 @@ from django.db.models import Q, QuerySet
|
||||
from django.utils import timezone
|
||||
|
||||
from authentication.models import User
|
||||
from club.models import Club, ClubRole, Season
|
||||
from club.models import Club, ClubMembership, ClubRole, Season
|
||||
from events.models import Event
|
||||
from members.models import FamilyMembership, Group, Member
|
||||
from teams.models import StaffAssignment, Team
|
||||
@@ -114,15 +114,41 @@ def teams_staffed_by(user: User, club: Club) -> QuerySet[Team]:
|
||||
).distinct()
|
||||
|
||||
|
||||
def members_visible_to(user: User, club: Club) -> QuerySet[Member]:
|
||||
def _guardians_only(club: Club) -> QuerySet[Member]:
|
||||
"""People whose *only* tie to ``club`` is being a parent of a member.
|
||||
|
||||
Subtracted rather than filtered out at the source, because a bare MEMBER
|
||||
ClubRole with no ClubMembership is a real state -- someone the club knows
|
||||
but hasn't signed up for a season yet -- and narrowing the role branch to
|
||||
weed guardians out would take those people with it. Anyone who also holds a
|
||||
real membership, plays, is on a team's staff or runs the club is a member
|
||||
who happens to be a parent, and stays visible.
|
||||
"""
|
||||
return Member.objects.filter(member_of__club=club, member_of__kind=ClubMembership.Kind.GUARDIAN).exclude(
|
||||
Q(member_of__club=club, member_of__kind=ClubMembership.Kind.MEMBER)
|
||||
| Q(team_memberships__team__club=club)
|
||||
| Q(staff_assignments__team__club=club)
|
||||
| Q(roles__club=club, roles__role__in=[ClubRole.Roles.ADMIN, ClubRole.Roles.EDITOR])
|
||||
)
|
||||
|
||||
|
||||
def members_visible_to(user: User, club: Club, *, include_guardians: bool = False) -> QuerySet[Member]:
|
||||
"""Members the user may see.
|
||||
|
||||
ADMIN: everyone linked to the club (membership, roster, staff or role).
|
||||
Otherwise: themselves, their children, and the current-season players *and*
|
||||
staff of every team they're staffed on.
|
||||
|
||||
Guardians -- parents attached to the club only through a child, see
|
||||
``ClubMembership.Kind`` -- are **excluded by default**: they aren't members,
|
||||
so they don't belong in a member list or any member count. Pass
|
||||
``include_guardians=True`` where the page is about a *person* rather than
|
||||
about members: opening a guardian's own detail page, editing them, putting
|
||||
them in a group, or showing a family (whose parents are the whole point).
|
||||
"""
|
||||
if is_club_admin(user, club):
|
||||
return Member.objects.filter(Q(member_of__club=club) | Q(team_memberships__team__club=club) | Q(staff_assignments__team__club=club) | Q(roles__club=club)).distinct()
|
||||
attached = Member.objects.filter(Q(member_of__club=club) | Q(team_memberships__team__club=club) | Q(staff_assignments__team__club=club) | Q(roles__club=club)).distinct()
|
||||
return attached if include_guardians else attached.exclude(pk__in=_guardians_only(club))
|
||||
|
||||
me = Member.objects.filter(user=user).first()
|
||||
if me is None:
|
||||
|
||||
@@ -46,12 +46,39 @@ def generate_seasons(club, until):
|
||||
end = _season_end(start, club)
|
||||
season, was_created = Season.objects.get_or_create(club=club, start_date=start, end_date=end)
|
||||
if was_created:
|
||||
_carry_guardians_into(club, season)
|
||||
created.append(season)
|
||||
start = end + datetime.timedelta(days=1)
|
||||
|
||||
return created
|
||||
|
||||
|
||||
def _carry_guardians_into(club, season):
|
||||
"""Copy the previous season's guardians into a season that has just been created.
|
||||
|
||||
The other half of members.services.family.carry_guardians_forward, which
|
||||
covers a guardian added *after* the later seasons already existed. Between
|
||||
them a parent keeps their tie to the club across every season boundary --
|
||||
without which they would quietly drop off the club while their child stayed
|
||||
enrolled, which is exactly the state a guardian exists to prevent.
|
||||
|
||||
Copied from the immediately preceding season, not from "any season ever", so
|
||||
a guardian an admin deliberately removed stays removed rather than being
|
||||
resurrected from an older row.
|
||||
"""
|
||||
from club.models import ClubMembership
|
||||
|
||||
previous = Season.objects.filter(club=club, start_date__lt=season.start_date).order_by("-start_date").first()
|
||||
if previous is None:
|
||||
return
|
||||
|
||||
guardians = ClubMembership.objects.filter(club=club, season=previous, kind=ClubMembership.Kind.GUARDIAN)
|
||||
ClubMembership.objects.bulk_create(
|
||||
[ClubMembership(club=club, member_id=guardian.member_id, season=season, kind=ClubMembership.Kind.GUARDIAN, status=guardian.status, signed_up_at=guardian.signed_up_at) for guardian in guardians],
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
|
||||
def _expected_season_dates(club, until):
|
||||
"""The (start_date, end_date) pairs generate_seasons would produce for
|
||||
``club`` from scratch, ignoring whatever already exists -- used by
|
||||
|
||||
Reference in New Issue
Block a user