Add referee assignment/eligibility system, team bulk-add, member Groups, and referee management dashboard with PDF export

Builds the referee workflow end to end: club-defined RefereeLevel/RefereeProfile
eligibility tied to teams, EventReferee assignment (member or external, with
fee/km payment tracking), an admin dashboard with KPI tiles, date-grouped game
tiles and range filters, and a downloadable payment form PDF modeled on the
club's existing paper document (using Club.legal_name when set). Also lands
team roster bulk-add, member mass-upload with family linking, and the
members.Group model, developed alongside this work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 21:39:19 +02:00
parent 86e28c317f
commit 309bd4d83e
54 changed files with 4574 additions and 88 deletions

View File

@@ -1,3 +1,5 @@
from decimal import Decimal
from django.conf import settings
from django.db import models
from django.db.models import Q
@@ -86,6 +88,8 @@ class Event(ClubScopedModel):
score_against = models.PositiveSmallIntegerField(_("score (opponent)"), null=True, blank=True)
is_live = models.BooleanField(_("live"), default=False, help_text=_("The game is currently in progress."))
max_referees = models.PositiveSmallIntegerField(_("max referees"), default=2, help_text=_("How many referees can be assigned to this game. Only meaningful for home games -- ignored otherwise."))
class Meta:
verbose_name = _("event")
verbose_name_plural = _("events")
@@ -172,6 +176,65 @@ class Attendance(UUIDModel):
return f"{self.event} - {self.member}"
class EventReferee(UUIDModel):
"""One referee assigned to one (home) game -- either a club member
(``member`` set) or an external referee logged by name only
(``external_name`` set, e.g. a federation-appointed referee the club
still needs to pay/log) -- never both, never neither. ``assigned_by`` is
required for now -- assignment is admin-only; a future self-service
sign-up would make it nullable to mean "the referee signed themself up"
rather than adding a parallel model. See events.services.referees for the
home-game gate, the ``Event.max_referees`` capacity ceiling, and the
(non-blocking) schedule conflict check.
``fee``/``km``/``km_rate`` back the referee payment form (PDF export):
what the club owes this referee for this game, and the mileage rate used
to compute the travel portion -- snapshotted per assignment (not read
from a live club-wide setting) so a rate change later doesn't rewrite an
already-issued form's numbers.
"""
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="referees", verbose_name=_("event"))
member = models.ForeignKey(Member, on_delete=models.CASCADE, null=True, blank=True, related_name="referee_assignments", verbose_name=_("member"))
external_name = models.CharField(_("external referee name"), max_length=255, blank=True, help_text=_("For a referee who isn't a club member (e.g. federation-appointed) -- logged by name only."))
assigned_by = models.ForeignKey(Member, on_delete=models.SET_NULL, null=True, related_name="+", verbose_name=_("assigned by"))
fee = models.DecimalField(_("fee"), max_digits=8, decimal_places=2, default=Decimal("0.00"), blank=True)
km = models.DecimalField(_("kilometers"), max_digits=6, decimal_places=1, null=True, blank=True)
km_rate = models.DecimalField(_("rate per km"), max_digits=6, decimal_places=4, null=True, blank=True, help_text=_("Reimbursement rate per kilometer, e.g. 0.4230."))
class Meta:
verbose_name = _("event referee")
verbose_name_plural = _("event referees")
ordering = ["event", "member__last_name", "member__first_name"]
constraints = [
models.UniqueConstraint(fields=["event", "member"], name="unique_referee_per_event"),
models.CheckConstraint(
condition=(Q(member__isnull=False) & Q(external_name="")) | (Q(member__isnull=True) & ~Q(external_name="")),
name="event_referee_member_xor_external_name",
),
]
def __str__(self):
return f"{self.event} - {self.display_name}"
@property
def display_name(self) -> str:
return str(self.member) if self.member_id else self.external_name
@property
def is_external(self) -> bool:
return self.member_id is None
@property
def km_total(self) -> Decimal:
return (self.km or Decimal("0")) * (self.km_rate or Decimal("0"))
@property
def total_payable(self) -> Decimal:
return (self.fee or Decimal("0")) + self.km_total
class Competition(models.Model):
"""A competition has a name with a specific URL to fetch data from. These are managed centrally."""