Add ClubRole (ADMIN / MEMBER / EDITOR, one per member per club) and complete club/services/access.py — the single module all authorisation routes through: - teams_managed_by / can_edit_event -> authority: a *management* StaffAssignment in the *current season*; ADMIN overrides club-wide. A StaffAssignment is per-season, so a former coach's authority expires with it. - teams_staffed_by -> visibility: *any* staff position, so support staff (physio, kit manager) can see the roster they work with without gaining authority. - members_visible_to -> ADMIN sees everyone linked to the club; otherwise self + children (family graph) + the current-season players and staff of the teams they're staffed on. - can_edit_event -> ADMIN/EDITOR, the event's owner, or a manager of one of its teams for that event's season. - can_manage_shop -> ADMIN. Fix roles_in_club, which called .unique() — not a QuerySet method, so it would have raised AttributeError on first use. Keep ClubRole in sync with membership status: an active ClubMembership grants the MEMBER role and losing it withdraws that role — but an elevated role (ADMIN/EDITOR) is never downgraded or removed, so a lapsed membership or a season rollover can never lock an admin out. Validate ClubMembership.season against the membership's club. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
146 lines
5.4 KiB
Python
146 lines
5.4 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, ClubRole, Season
|
|
from events.models import Event
|
|
from members.models import FamilyMembership, 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 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 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 members_visible_to(user: User, club: Club) -> 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.
|
|
"""
|
|
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()
|
|
|
|
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)
|