Creating (or re-teaming) a home game for a club-managed team now auto- invites every eligible referee (teams.RefereeProfile) via a new RefereeSignup model, notifying them the same way news/events already do. They see it as its own distinct row on the mobile Calendar (own accent colour) and can accept or decline right there -- accepting routes through the existing capacity-checked assign_referee (assigned_by=None marks it self-service), so it lands as a real EventReferee row with no separate sync step. The desktop referee-management screen and event detail page both surface pending invites and flag self-signed-up referees distinctly from admin assignments. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
204 lines
9.1 KiB
Python
204 lines
9.1 KiB
Python
"""Assigning referees to home games.
|
|
|
|
Eligibility comes from teams.RefereeProfile -- a per-member level + validity
|
|
(unrelated to members.Group), with the *level* (teams.RefereeLevel) owning
|
|
which teams it qualifies for. A profile with no level, or one whose validity
|
|
has lapsed, is not eligible for anything -- see RefereeProfile.eligible_teams,
|
|
the single definition this module reads through. Availability conflicts (the
|
|
member is expected elsewhere at an overlapping time) are surfaced as a
|
|
warning only -- never a hard block, since a human may know the two
|
|
commitments don't actually clash (enough travel time, one is optional).
|
|
Capacity (Event.max_referees) *is* a hard ceiling, enforced here for both
|
|
staff-assignment and any future self-service sign-up.
|
|
|
|
A team can opt out of all of this entirely (Team.referee_management =
|
|
FEDERATION): its home games never need club-arranged referees, so they're
|
|
excluded from eligibility, assignment, and every referee-facing screen --
|
|
see needs_referee_management().
|
|
|
|
Not every referee is a club member -- add_external_referee logs one by name
|
|
only (still counts against Event.max_referees, still capacity-checked), for
|
|
e.g. a federation-appointed referee the club still needs to pay.
|
|
"""
|
|
|
|
from decimal import Decimal
|
|
|
|
from django.db import transaction
|
|
from django.utils import timezone
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
from events.models import ASSUMED_EVENT_DURATION, Event, EventReferee, RefereeSignup
|
|
from events.services.attendance import effective_members
|
|
from members.models import Member
|
|
from notifications.services import notify_members
|
|
from teams.models import RefereeLevel, Team
|
|
|
|
|
|
class RefereeAssignmentError(Exception):
|
|
"""A referee could not be assigned to a game."""
|
|
|
|
|
|
def event_window(event):
|
|
"""(start, end) for overlap purposes -- events.end may be blank."""
|
|
return event.start, event.end or (event.start + ASSUMED_EVENT_DURATION)
|
|
|
|
|
|
def needs_referee_management(event) -> bool:
|
|
"""Whether this event is one the club should be arranging referees for at
|
|
all: a home game with at least one club-managed team. A federation-managed
|
|
team's home games are entirely out of scope for the referee tools -- the
|
|
federation appoints referees for those, not the club."""
|
|
return event.is_home_game and event.teams.filter(referee_management=Team.RefereeManagement.CLUB).exists()
|
|
|
|
|
|
def eligible_referees(event):
|
|
"""Members who could referee `event`: their RefereeProfile has a level
|
|
qualifying for one of its club-managed teams (directly, or via whatever
|
|
that level inherits from), and is currently valid, minus whoever is
|
|
already assigned. Empty unless needs_referee_management.
|
|
|
|
Levels resolved to ids first (RefereeLevel.eligible_team_ids can't be
|
|
expressed as a single ORM lookup once inheritance is transitive) rather
|
|
than the old flat `level__teams__id__in` filter -- a club has a handful
|
|
of levels, so this stays cheap."""
|
|
if not needs_referee_management(event):
|
|
return Member.objects.none()
|
|
|
|
team_ids = set(event.teams.filter(referee_management=Team.RefereeManagement.CLUB).values_list("id", flat=True))
|
|
qualifying_level_ids = [level.pk for level in RefereeLevel.objects.filter(club=event.club) if level.eligible_team_ids() & team_ids]
|
|
assigned_ids = event.referees.values_list("member_id", flat=True)
|
|
today = timezone.localdate()
|
|
|
|
return (
|
|
Member.objects.filter(referee_profile__level_id__in=qualifying_level_ids, referee_profile__valid_until__gte=today)
|
|
.exclude(pk__in=assigned_ids)
|
|
.distinct()
|
|
)
|
|
|
|
|
|
def conflicting_events(member, event):
|
|
"""Other events in this club overlapping `event`'s time window where
|
|
`member` is part of the expected audience -- informational only, never
|
|
blocks an assignment."""
|
|
start, end = event_window(event)
|
|
candidates = Event.objects.filter(club=event.club).exclude(pk=event.pk).filter(start__lt=end)
|
|
|
|
conflicts = []
|
|
for candidate in candidates:
|
|
_candidate_start, candidate_end = event_window(candidate)
|
|
if candidate_end > start and effective_members(candidate).filter(pk=member.pk).exists():
|
|
conflicts.append(candidate)
|
|
return conflicts
|
|
|
|
|
|
def _lock_and_check_capacity(event):
|
|
"""Row-locks `event` and raises RefereeAssignmentError if it's not a
|
|
club-managed home game or is already at Event.max_referees. Shared by
|
|
assign_referee/add_external_referee so two admins acting at the same
|
|
moment can't both squeeze past the ceiling."""
|
|
event = Event.objects.select_for_update().get(pk=event.pk)
|
|
|
|
if not needs_referee_management(event):
|
|
raise RefereeAssignmentError(_("Referees can only be assigned to home games for club-managed teams."))
|
|
|
|
if event.referees.count() >= event.max_referees:
|
|
raise RefereeAssignmentError(_("This game already has its maximum of %(max)s referee(s).") % {"max": event.max_referees})
|
|
|
|
return event
|
|
|
|
|
|
@transaction.atomic
|
|
def assign_referee(event, member, *, assigned_by):
|
|
"""Assign `member` to referee `event`. Raises RefereeAssignmentError if
|
|
it's not a club-managed home game, the game is already at
|
|
Event.max_referees, or `member` is already assigned."""
|
|
event = _lock_and_check_capacity(event)
|
|
|
|
if event.referees.filter(member=member).exists():
|
|
raise RefereeAssignmentError(_("%(member)s is already assigned to this game.") % {"member": member})
|
|
|
|
return EventReferee.objects.create(event=event, member=member, assigned_by=assigned_by)
|
|
|
|
|
|
@transaction.atomic
|
|
def add_external_referee(event, name, *, assigned_by):
|
|
"""Log a non-member referee (e.g. federation-appointed) by name only.
|
|
Same capacity/home-game rules as assign_referee -- an external slot still
|
|
counts against Event.max_referees."""
|
|
name = name.strip()
|
|
if not name:
|
|
raise RefereeAssignmentError(_("A name is required for an external referee."))
|
|
|
|
event = _lock_and_check_capacity(event)
|
|
|
|
return EventReferee.objects.create(event=event, external_name=name, assigned_by=assigned_by)
|
|
|
|
|
|
def remove_referee(referee):
|
|
referee.delete()
|
|
|
|
|
|
def set_referee_fee(referee, *, fee=None, km=None, km_rate=None):
|
|
"""Update one referee assignment's payment details -- what the club owes
|
|
for this game, split into a flat fee and a mileage component. Any
|
|
argument left as None clears that field rather than leaving it
|
|
unchanged, matching how the edit form always submits all three."""
|
|
referee.fee = fee if fee is not None else Decimal("0.00")
|
|
referee.km = km
|
|
referee.km_rate = km_rate
|
|
referee.save(update_fields=["fee", "km", "km_rate"])
|
|
return referee
|
|
|
|
|
|
def sync_referee_invites(event):
|
|
"""Invites every currently-eligible, not-yet-invited referee to `event` --
|
|
the referee-facing counterpart to events.sync_event_attendances, wired
|
|
from the same signal points (events/signals.py's post_save(Event) and
|
|
the Event.teams m2m). Idempotent: only creates rows for members with no
|
|
RefereeSignup at all yet for this event, so an unrelated edit (or a
|
|
second team added later) never re-notifies someone who already
|
|
responded -- or re-invites someone who already said no."""
|
|
if not needs_referee_management(event):
|
|
return []
|
|
|
|
already_invited_ids = set(RefereeSignup.objects.filter(event=event).values_list("member_id", flat=True))
|
|
new_members = [member for member in eligible_referees(event) if member.pk not in already_invited_ids]
|
|
if not new_members:
|
|
return []
|
|
|
|
RefereeSignup.objects.bulk_create([RefereeSignup(event=event, member=member) for member in new_members])
|
|
body = _("%(event)s needs a referee -- can you take it?") % {"event": event.title}
|
|
notify_members(new_members, club=event.club, title=_("Referee needed"), body=body, source=event)
|
|
return new_members
|
|
|
|
|
|
@transaction.atomic
|
|
def accept_referee_signup(signup):
|
|
"""Confirms `signup`'s member as an actual referee for the game --
|
|
routed through the exact same assign_referee every admin assignment
|
|
uses (assigned_by=None marks it self-service), so capacity is enforced
|
|
once, in one place, and the desktop referee-management screen sees the
|
|
new assignment with no separate sync step. Raises RefereeAssignmentError
|
|
unchanged if the game's already full -- `signup` is left INVITED so the
|
|
referee can retry if a slot frees up."""
|
|
assign_referee(signup.event, signup.member, assigned_by=None)
|
|
signup.status = RefereeSignup.Status.ACCEPTED
|
|
signup.responded_at = timezone.now()
|
|
signup.save(update_fields=["status", "responded_at"])
|
|
return signup
|
|
|
|
|
|
@transaction.atomic
|
|
def decline_referee_signup(signup):
|
|
"""Declines `signup`. If they'd already accepted, also removes their
|
|
self-service EventReferee row (assigned_by is None -- never touches an
|
|
admin-made assignment for the same member/event, though assign_referee's
|
|
own "already assigned" guard makes that pairing impossible anyway) so
|
|
the desktop screen stops counting someone who's since said they can't
|
|
make it after all."""
|
|
EventReferee.objects.filter(event=signup.event, member=signup.member, assigned_by__isnull=True).delete()
|
|
signup.status = RefereeSignup.Status.DECLINED
|
|
signup.responded_at = timezone.now()
|
|
signup.save(update_fields=["status", "responded_at"])
|
|
return signup
|