Files
RosterChief/club/services/access.py
Bernard Siebens 744b623403 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>
2026-08-11 16:11:41 +02:00

208 lines
9.0 KiB
Python

"""Per-club access decisions.
All authorisation goes through this module (ARCHITECTURE §3): stored
``ClubRole`` rows plus object-scoped facts — coach/manager is *derived* from
``StaffAssignment`` (never a ClubRole), and parent/guardian from the family
graph. ADMIN is the club-wide override.
Two axes, deliberately separate:
* **Authority** (``teams_managed_by``, ``can_edit_event``) requires a
*management* position, and only for the **current season** — a StaffAssignment
is per-season, so a former coach's authority expires with it. (A ``ClubRole``,
by contrast, is permanent and survives a lapsed membership.)
* **Visibility** (``teams_staffed_by`` → ``members_visible_to``) covers *any*
staff position, so support staff (physio, kit manager) can see the roster they
work with without gaining any authority over it.
"""
from django.db.models import Q, QuerySet
from django.utils import timezone
from authentication.models import User
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
#: Derived (never stored) roles.
COACH = "coach"
MANAGER = "manager"
COACH_MANAGER = "coach_manager"
def current_season(club: Club) -> Season | None:
"""The club's season covering today. Staff authority is scoped to it."""
return Season.covering(club, timezone.localdate())
def event_season(event: Event) -> Season | None:
"""The season an event belongs to (explicit, else derived from its start)."""
return event.season or Season.covering(event.club, event.start.date())
def has_club_role(user: User, club: Club, role: ClubRole.Roles) -> bool:
return ClubRole.objects.filter(member__user=user, club=club, role=role).exists()
def is_club_admin(user: User, club: Club) -> bool:
return has_club_role(user, club, ClubRole.Roles.ADMIN)
def has_management_access(user: User, club: Club) -> bool:
"""Anyone with real authority in the club: ADMIN/EDITOR, or *any* current-season
staff assignment (coach, team manager, physio, ...).
Deliberately excludes the plain MEMBER role -- every signed-up player (or club
member generally) holds that automatically the moment their ClubMembership goes
active (club/signals.py), so it says nothing about whether someone is staff.
"""
elevated = ClubRole.objects.filter(member__user=user, club=club, role__in=(ClubRole.Roles.ADMIN, ClubRole.Roles.EDITOR)).exists()
return elevated or teams_staffed_by(user, club).exists()
def is_coach_manager(user: User, club: Club) -> bool:
"""Derived from a current-season StaffAssignment in a *management* position."""
return StaffAssignment.objects.filter(
member__user=user,
team__club=club,
position__management_position=True,
season=current_season(club),
).exists()
def roles_in_club(user: User, club: Club) -> set[str]:
"""The user's roles in ``club``, including the derived COACH_MANAGER role."""
roles = set(ClubRole.objects.filter(member__user=user, club=club).values_list("role", flat=True))
if is_coach_manager(user, club):
roles.add(COACH_MANAGER)
return roles
def teams_managed_by(user: User, club: Club) -> QuerySet[Team]:
"""Teams the user has authority over: all for an ADMIN, else the ones they
manage *this season* (management position only)."""
if is_club_admin(user, club):
return Team.objects.filter(club=club)
return Team.objects.filter(
club=club,
staff_assignments__member__user=user,
staff_assignments__position__management_position=True,
staff_assignments__season=current_season(club),
).distinct()
def groups_manageable_by(user: User, club: Club) -> QuerySet[Group]:
"""Groups the user may schedule an event for: all for an ADMIN, else only
the ones they're themselves a member of -- unlike Team, Group has no
manager/owner concept, so membership is the only claim there is to check."""
if is_club_admin(user, club):
return Group.objects.filter(club=club)
return Group.objects.filter(club=club, memberships__member__user=user).distinct()
def teams_staffed_by(user: User, club: Club) -> QuerySet[Team]:
"""Teams the user is on the staff of this season, management or not.
Visibility only — being a team's physio grants sight of the roster, never
authority over it.
"""
return Team.objects.filter(
club=club,
staff_assignments__member__user=user,
staff_assignments__season=current_season(club),
).distinct()
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):
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:
return Member.objects.none()
children = Member.objects.filter(
family_memberships__role=FamilyMembership.FamilyRole.CHILD,
family_memberships__family__memberships__member=me,
family_memberships__family__memberships__role__in=[FamilyMembership.FamilyRole.PARENT, FamilyMembership.FamilyRole.GUARDIAN],
)
season = current_season(club)
teams = teams_staffed_by(user, club)
roster = Member.objects.filter(Q(team_memberships__team__in=teams, team_memberships__season=season) | Q(staff_assignments__team__in=teams, staff_assignments__season=season))
visible = {me.pk} | set(children.values_list("pk", flat=True)) | set(roster.values_list("pk", flat=True))
return Member.objects.filter(pk__in=visible)
def can_edit_event(user: User, event: Event) -> bool:
"""ADMIN/EDITOR in the club, the event's owner, or a manager of one of its
teams *for that event's season*."""
club = event.club
if is_club_admin(user, club) or has_club_role(user, club, ClubRole.Roles.EDITOR):
return True
if event.created_by_id is not None and event.created_by.user_id == user.pk:
return True
return StaffAssignment.objects.filter(
member__user=user,
team__in=event.teams.all(),
position__management_position=True,
season=event_season(event),
).exists()
def can_manage_shop(user: User, club: Club) -> bool:
return is_club_admin(user, club)
def can_add_news(user: User, club: Club) -> bool:
"""ADMIN, EDITOR, or a current-season coach_manager -- who's trusted to
author club content, not just anyone on staff (a physio shouldn't post news)."""
return is_club_admin(user, club) or has_club_role(user, club, ClubRole.Roles.EDITOR) or is_coach_manager(user, club)
def can_publish_news(user: User, club: Club) -> bool:
"""Only ADMIN/EDITOR may push a news item live -- the release-flow gate."""
return is_club_admin(user, club) or has_club_role(user, club, ClubRole.Roles.EDITOR)
def can_edit_news(user: User, news_item) -> bool:
"""Broad while it's a draft (anyone who could create one); editor/admin-only
once published -- an editor is accountable for what's actually live."""
if news_item.status == news_item.Status.PUBLISHED:
return can_publish_news(user, news_item.club)
return can_add_news(user, news_item.club)