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:
@@ -41,7 +41,10 @@ def clubs_with_totals(queryset=None):
|
||||
"""Clubs annotated with headline counts (one query, no N+1)."""
|
||||
clubs = Club.objects.all() if queryset is None else queryset
|
||||
return clubs.annotate(
|
||||
member_count=Count("clubmemberships__member", distinct=True),
|
||||
# Members only: a guardian is attached to the club as a parent of a member,
|
||||
# not as one, so counting them would overstate every club's size (and the
|
||||
# onboarding funnel's "with members" step).
|
||||
member_count=Count("clubmemberships__member", filter=Q(clubmemberships__kind=ClubMembership.Kind.MEMBER), distinct=True),
|
||||
team_count=Count("teams", distinct=True),
|
||||
event_count=Count("events", distinct=True),
|
||||
admin_count=Count("clubroles", filter=Q(clubroles__role=ClubRole.Roles.ADMIN), distinct=True),
|
||||
@@ -77,8 +80,8 @@ def clubs_with_health(queryset=None, today=None, now=None):
|
||||
return (
|
||||
clubs.annotate(
|
||||
has_season=Exists(Season.objects.filter(club=OuterRef("pk"), start_date__lte=today, end_date__gte=today)),
|
||||
active_members=_subquery(ClubMembership.objects.filter(in_season, status=ClubMembership.StatusChoices.ACTIVE), Count("pk"), IntegerField()),
|
||||
unpaid_members=_subquery(ClubMembership.objects.filter(in_season, fee_status=ClubMembership.FeeStatus.UNPAID), Count("pk"), IntegerField()),
|
||||
active_members=_subquery(ClubMembership.objects.filter(in_season, kind=ClubMembership.Kind.MEMBER, status=ClubMembership.StatusChoices.ACTIVE), Count("pk"), IntegerField()),
|
||||
unpaid_members=_subquery(ClubMembership.objects.filter(in_season, kind=ClubMembership.Kind.MEMBER, fee_status=ClubMembership.FeeStatus.UNPAID), Count("pk"), IntegerField()),
|
||||
outstanding=_subquery(Order.objects.filter(status__in=OWED_STATUSES), Sum("total"), DecimalField(max_digits=10, decimal_places=2)),
|
||||
upcoming_events=_subquery(Event.objects.filter(start__gte=now, start__lte=now + timedelta(days=DORMANT_DAYS)), Count("pk"), IntegerField()),
|
||||
team_count=_subquery(Team.objects.all(), Count("pk"), IntegerField()),
|
||||
@@ -105,7 +108,7 @@ def platform_totals():
|
||||
return {
|
||||
"clubs": Club.objects.active().count(),
|
||||
"archived_clubs": Club.objects.archived().count(),
|
||||
"members": Member.objects.count(),
|
||||
"members": Member.objects.filter(member_of__kind=ClubMembership.Kind.MEMBER).distinct().count(),
|
||||
"admins": ClubRole.objects.filter(role=ClubRole.Roles.ADMIN).count(),
|
||||
}
|
||||
|
||||
@@ -264,12 +267,12 @@ def renewal_rate(club, season):
|
||||
if previous is None:
|
||||
return None
|
||||
|
||||
was_active = ClubMembership.objects.filter(club=club, season=previous, status=ClubMembership.StatusChoices.ACTIVE)
|
||||
was_active = ClubMembership.objects.filter(club=club, season=previous, kind=ClubMembership.Kind.MEMBER, status=ClubMembership.StatusChoices.ACTIVE)
|
||||
total = was_active.count()
|
||||
if not total:
|
||||
return None
|
||||
|
||||
returned = ClubMembership.objects.filter(club=club, season=season, member__in=was_active.values("member")).count()
|
||||
returned = ClubMembership.objects.filter(club=club, season=season, kind=ClubMembership.Kind.MEMBER, member__in=was_active.values("member")).count()
|
||||
|
||||
return round(100 * returned / total)
|
||||
|
||||
@@ -284,7 +287,7 @@ def new_members(club, season):
|
||||
if season is None:
|
||||
return Member.objects.none()
|
||||
|
||||
seen_before = ClubMembership.objects.filter(club=club, season__start_date__lt=season.start_date).values("member")
|
||||
seen_before = ClubMembership.objects.filter(club=club, season__start_date__lt=season.start_date, kind=ClubMembership.Kind.MEMBER).values("member")
|
||||
|
||||
return Member.objects.filter(member_of__club=club, member_of__season=season).exclude(pk__in=seen_before).distinct()
|
||||
|
||||
@@ -301,7 +304,7 @@ def signup_split(club=None, months=MONTHS_OF_HISTORY):
|
||||
"""
|
||||
start = (timezone.now() - relativedelta(months=months)).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
memberships = ClubMembership.objects.all() if club is None else ClubMembership.objects.filter(club=club)
|
||||
memberships = ClubMembership.objects.filter(kind=ClubMembership.Kind.MEMBER) if club is None else ClubMembership.objects.filter(club=club, kind=ClubMembership.Kind.MEMBER)
|
||||
|
||||
first_season = {}
|
||||
for club_id, member_id, season_start in memberships.values_list("club_id", "member_id", "season__start_date"):
|
||||
@@ -342,7 +345,7 @@ def unrostered_members(club, season):
|
||||
|
||||
rostered = TeamMembership.objects.filter(team__club=club, season=season).values("member")
|
||||
|
||||
return Member.objects.filter(member_of__club=club, member_of__season=season, member_of__status=ClubMembership.StatusChoices.ACTIVE).exclude(pk__in=rostered).distinct()
|
||||
return Member.objects.filter(member_of__club=club, member_of__season=season, member_of__kind=ClubMembership.Kind.MEMBER, member_of__status=ClubMembership.StatusChoices.ACTIVE).exclude(pk__in=rostered).distinct()
|
||||
|
||||
|
||||
def fee_aging(club):
|
||||
@@ -389,7 +392,7 @@ def attendance_rates(club, season):
|
||||
def club_attention(club):
|
||||
"""A club's own numbers that are supposed to be zero."""
|
||||
season = Season.covering(club, timezone.localdate())
|
||||
memberships = ClubMembership.objects.filter(club=club)
|
||||
memberships = ClubMembership.objects.filter(club=club, kind=ClubMembership.Kind.MEMBER)
|
||||
|
||||
return {
|
||||
"season": season,
|
||||
@@ -408,7 +411,7 @@ def club_attention(club):
|
||||
|
||||
def club_charts(club):
|
||||
season = Season.covering(club, timezone.localdate())
|
||||
memberships = ClubMembership.objects.filter(club=club, season=season) if season else ClubMembership.objects.none()
|
||||
memberships = ClubMembership.objects.filter(club=club, season=season, kind=ClubMembership.Kind.MEMBER) if season else ClubMembership.objects.none()
|
||||
|
||||
return {
|
||||
"signups": signup_split(club),
|
||||
@@ -430,7 +433,7 @@ def club_statistics(club):
|
||||
season = Season.covering(club, timezone.localdate())
|
||||
now = timezone.now()
|
||||
|
||||
memberships = ClubMembership.objects.filter(club=club)
|
||||
memberships = ClubMembership.objects.filter(club=club, kind=ClubMembership.Kind.MEMBER)
|
||||
events = Event.objects.filter(club=club)
|
||||
orders = Order.objects.filter(club=club)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user