Files
RosterChief/events/models.py
Bernard Siebens 53b3c56594 Add Coach mode C3: game line-up, the last of the six coach screens
New models (events/models.py): Lineup (one per event), LineupUnit (Line 1,
Defence pair 1, ... -- coach-entered labels, no fixed sport structure since
neither Club nor Team carries one), LineupSlot (one position, optionally
filled by a member). events/services/lineup.py's publish_lineup is the
reason Attendance.AttendanceStatus.SELECTED/NOT_SELECTED existed at all --
publishing flips every slotted member to SELECTED and every other available
(non-out, non-silent) roster member to NOT_SELECTED, then notifies only the
selected players.

Placement is a native <select> per slot, batch-saved with one "Save
line-up" submit, not the design mock's drag-and-drop -- this codebase has
no established htmx interaction pattern yet (htmx.js is loaded but nothing
uses it) to build a live per-tap version on, and a reliable plain form beats
a first, unproven real-time interaction for an already-large screen.
place_member handles "swap" semantics for it: placing a member vacates any
other slot of theirs in the same lineup, bumping whoever was already in the
target slot back to the available pool.

Wires the missing pieces from C1's own docstring: the "needs you" line-up-
not-published row and the tonight card's "Line-up" button, both deferred
in that stage specifically because this model didn't exist yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 22:47:42 +02:00

355 lines
18 KiB
Python

import datetime
from decimal import Decimal
from django.conf import settings
from django.db import models
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from django_countries.fields import CountryField
from club.models import Club, Season
from members.models import Group, Member
from rosterchief.base import ClubScopedModel, UUIDModel, validate_club_scope
from teams.models import Team
#: How long a game is assumed to run when no explicit `end` is given -- set on
#: GAME events at save time (Event.save() below), and reused as a read-time-only
#: fallback for other event kinds by events.services.referees.event_window().
ASSUMED_EVENT_DURATION = datetime.timedelta(hours=2)
class Opponent(ClubScopedModel):
name = models.CharField(_("name"), max_length=255)
logo = models.ImageField(_("logo"), upload_to="opponents", blank=True)
class Meta:
verbose_name = _("opponent")
verbose_name_plural = _("opponents")
ordering = ["name"]
def __str__(self):
return self.name
class Location(ClubScopedModel):
name = models.CharField(_("name"), max_length=255)
address = models.CharField(_("address"), max_length=255)
city = models.CharField(_("city"), max_length=255)
zip_code = models.CharField(_("zip code"), max_length=255)
country = CountryField(_("country"))
is_home = models.BooleanField(
_("home location"),
default=False,
help_text=_("The club's own ground, set from the control panel -- lets an event's location tell a home game from an away one."),
)
class Meta:
verbose_name = _("location")
verbose_name_plural = _("locations")
ordering = ["name"]
constraints = [
models.UniqueConstraint(fields=["club"], condition=Q(is_home=True), name="unique_home_location_per_club"),
]
def __str__(self):
return self.name
class Event(ClubScopedModel):
class EventKind(models.TextChoices):
TRAINING = "training", _("Training")
GAME = "game", _("Game")
TOURNAMENT = "tournament", _("Tournament")
MEETING = "meeting", _("Meeting")
SOCIAL = "social", _("Social")
OTHER = "other", _("Other")
series = models.ForeignKey("EventSeries", on_delete=models.CASCADE, related_name="occurrences", null=True, blank=True, verbose_name=_("series"), help_text=_("The recurring series this occurrence belongs to; blank for one-off events."))
detached = models.BooleanField(_("detached"), default=False, help_text=_("Edited independently; excluded from series-wide updates and regeneration."))
cancelled = models.BooleanField(_("cancelled"), default=False)
teams = models.ManyToManyField(Team, related_name="scheduled_events", blank=True, verbose_name=_("teams"))
groups = models.ManyToManyField(Group, related_name="scheduled_events", blank=True, verbose_name=_("groups"), help_text=_("Invite every current member of these groups."))
club_wide = models.BooleanField(_("whole club"), default=False, help_text=_("Invite every active club member for this event's season, instead of specific teams/groups. Can't be combined with teams or groups."))
invited_members = models.ManyToManyField(Member, related_name="invited_to_events", blank=True, verbose_name=_("invited members"))
excluded_members = models.ManyToManyField(Member, related_name="excluded_from_events", blank=True, verbose_name=_("excluded members"))
season = models.ForeignKey(Season, on_delete=models.SET_NULL, related_name="events", null=True, blank=True, verbose_name=_("season"), help_text=_("Season whose team rosters define the audience; derived from the start date when left blank."))
kind = models.CharField(_("kind"), max_length=10, choices=EventKind.choices, default=EventKind.OTHER)
title = models.CharField(_("title"), max_length=255)
start = models.DateTimeField(_("start"))
end = models.DateTimeField(_("end"), blank=True, null=True)
gathering = models.DateTimeField(_("gathering"), blank=True, null=True)
deadline = models.DateTimeField(_("registration deadline"), blank=True, null=True)
location = models.ForeignKey(Location, on_delete=models.SET_NULL, related_name="events", null=True, blank=True, verbose_name=_("location"))
opponent = models.ForeignKey(Opponent, on_delete=models.SET_NULL, related_name="events", null=True, blank=True, verbose_name=_("opponent"))
created_by = models.ForeignKey(Member, on_delete=models.SET_NULL, related_name="created_events", null=True, blank=True, verbose_name=_("created by"))
# Game-specific -- meaningless for other kinds, so all optional. external_game_id
# is this game's id in an external competition/fixture data source, for a later
# automatic score-fetcher to key off; nothing populates it yet.
competition = models.CharField(_("competition"), max_length=255, blank=True, help_text=_("The league, cup or competition this game is part of."))
external_game_id = models.CharField(_("external game ID"), max_length=255, blank=True, help_text=_("This game's id in an external competition data source, for automatic score fetching later."))
score_for = models.PositiveSmallIntegerField(_("score (us)"), null=True, blank=True)
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."))
deadline_reminder_sent_at = models.DateTimeField(
_("deadline reminder sent at"),
null=True,
blank=True,
help_text=_("Set once events.tasks.send_deadline_reminders has nudged whoever still hasn't answered -- keeps that job from reminding the same event twice."),
)
class Meta:
verbose_name = _("event")
verbose_name_plural = _("events")
ordering = ["-start"]
def __str__(self):
return self.title
def clean(self):
validate_club_scope(self, self.club_id, same_club_fields=("season", "location", "opponent"))
def save(self, *args, **kwargs):
if self.kind == self.EventKind.GAME and self.end is None:
self.end = self.start + ASSUMED_EVENT_DURATION
super().save(*args, **kwargs)
@property
def is_home_game(self) -> bool:
"""Whether this game is being played at the club's own ground
(Location.is_home) -- False for anything that isn't a game, or a game
with no location set, or one at an away/neutral location."""
return self.kind == self.EventKind.GAME and self.location_id is not None and self.location.is_home
class EventSeries(ClubScopedModel):
"""A recurring event definition that materialises concrete Event rows."""
rrule = models.CharField(_("recurrence rule"), max_length=255, help_text=_("RFC 5545 RRULE, e.g. FREQ=WEEKLY;BYDAY=MO,WE."))
dtstart = models.DateTimeField(_("first occurrence"))
until = models.DateTimeField(_("until"), null=True, blank=True, help_text=_("Series end: no occurrences are generated after this. Leave blank for open-ended (bounded by the rule's own COUNT/UNTIL, if any)."))
duration = models.DurationField(_("duration"), null=True, blank=True, help_text=_("Length of each occurrence; sets each event's end."))
gathering_offset = models.DurationField(_("gathering offset"), null=True, blank=True, help_text=_("How long before the start each occurrence's gathering time is."))
deadline_offset = models.DurationField(_("registration deadline offset"), null=True, blank=True, help_text=_("How long before the start each occurrence's registration deadline is."))
excluded_dates = models.JSONField(_("excluded dates"), default=list, blank=True, help_text=_("ISO start datetimes of occurrences removed from the series (EXDATEs)."))
generated_until = models.DateTimeField(_("generated until"), null=True, blank=True, help_text=_("Occurrences have been materialised up to this point."))
# Template copied onto each generated occurrence.
kind = models.CharField(_("kind"), max_length=10, choices=Event.EventKind.choices, default=Event.EventKind.OTHER)
title = models.CharField(_("title"), max_length=255)
location = models.ForeignKey(Location, on_delete=models.SET_NULL, related_name="event_series", null=True, blank=True, verbose_name=_("location"))
opponent = models.ForeignKey(Opponent, on_delete=models.SET_NULL, related_name="event_series", null=True, blank=True, verbose_name=_("opponent"))
teams = models.ManyToManyField(Team, related_name="event_series", blank=True, verbose_name=_("teams"))
groups = models.ManyToManyField(Group, related_name="event_series", blank=True, verbose_name=_("groups"), help_text=_("Invite every current member of these groups."))
club_wide = models.BooleanField(_("whole club"), default=False, help_text=_("Invite every active club member for each occurrence's season, instead of specific teams/groups. Can't be combined with teams or groups."))
invited_members = models.ManyToManyField(Member, related_name="invited_to_event_series", blank=True, verbose_name=_("invited members"))
excluded_members = models.ManyToManyField(Member, related_name="excluded_from_event_series", blank=True, verbose_name=_("excluded members"))
class Meta:
verbose_name = _("event series")
verbose_name_plural = _("event series")
ordering = ["title"]
def __str__(self):
return self.title
def clean(self):
validate_club_scope(self, self.club_id, same_club_fields=("location", "opponent"))
class Attendance(UUIDModel):
class AttendanceStatus(models.TextChoices):
PRESENT = "present", _("Present")
ABSENT = "absent", _("Absent")
EXCUSED = "excused", _("Excused")
SELECTED = "selected", _("Selected")
NOT_SELECTED = "not_selected", _("Not selected")
MAYBE = "maybe", _("Maybe")
NO_RESPONSE = "no_response", _("No response")
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="attendances", verbose_name=_("event"))
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="attendances", verbose_name=_("member"))
status = models.CharField(_("status"), max_length=20, choices=AttendanceStatus.choices, default=AttendanceStatus.NO_RESPONSE)
showed_up = models.BooleanField(
_("showed up"),
null=True,
blank=True,
default=None,
help_text=_("Recorded by a check-in, separate from the RSVP status above. Blank means no check-in has been recorded yet."),
)
note = models.TextField(_("note"), blank=True)
class Meta:
verbose_name = _("attendance")
verbose_name_plural = _("attendances")
ordering = ["event", "member__last_name", "member__first_name"]
constraints = [
models.UniqueConstraint(fields=["event", "member"], name="unique_attendance_per_event_per_member"),
]
def __str__(self):
return f"{self.event} - {self.member}"
class Lineup(UUIDModel):
"""A game's line-up -- coach mode's C3 screen (mobile/coach_views.py's
CoachLineupView). One per event; ``units`` (Line 1, Defence pair 1, ...)
hold ordered ``slots``, each optionally filled by a member. Publishing
(events.services.lineup.publish_lineup) flips every slotted member's
Attendance.status to SELECTED and every other available roster member's
to NOT_SELECTED -- the reason those two statuses exist on Attendance in
the first place, previously unused."""
event = models.OneToOneField(Event, on_delete=models.CASCADE, related_name="lineup", verbose_name=_("event"))
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name="lineups", verbose_name=_("team"))
published_at = models.DateTimeField(_("published at"), null=True, blank=True)
created_by = models.ForeignKey(Member, on_delete=models.SET_NULL, null=True, blank=True, related_name="created_lineups", verbose_name=_("created by"))
class Meta:
verbose_name = _("line-up")
verbose_name_plural = _("line-ups")
def __str__(self):
return f"{self.team} - {self.event}"
def clean(self):
validate_club_scope(self, self.event.club_id if self.event_id else None, same_club_fields=("team",))
class LineupUnit(UUIDModel):
"""One group of slots within a Lineup -- "Line 1", "Defence pair 1", ...
Coach-entered labels, not a fixed sport structure: neither Club nor Team
carries a sport field to derive one from."""
lineup = models.ForeignKey(Lineup, on_delete=models.CASCADE, related_name="units", verbose_name=_("line-up"))
label = models.CharField(_("label"), max_length=100)
ordering = models.PositiveSmallIntegerField(_("ordering"), default=0)
class Meta:
verbose_name = _("line-up unit")
verbose_name_plural = _("line-up units")
ordering = ["ordering"]
def __str__(self):
return self.label
class LineupSlot(UUIDModel):
"""One position within a LineupUnit, optionally filled by a member.
Placement/swapping is tap-to-place (mobile/coach_views.py's
CoachLineupPlaceView), not drag-and-drop -- see that view's own
docstring for why. A member is only ever in one slot per lineup at a
time; enforcing that (clearing any prior slot of theirs on placement) is
the placement service's job, not a DB constraint -- "which unit a member
is in" spans this table's own FK, awkward to express as a single
UniqueConstraint."""
unit = models.ForeignKey(LineupUnit, on_delete=models.CASCADE, related_name="slots", verbose_name=_("unit"))
ordering = models.PositiveSmallIntegerField(_("ordering"), default=0)
member = models.ForeignKey(Member, on_delete=models.SET_NULL, null=True, blank=True, related_name="lineup_slots", verbose_name=_("member"))
class Meta:
verbose_name = _("line-up slot")
verbose_name_plural = _("line-up slots")
ordering = ["ordering"]
def __str__(self):
return f"{self.unit} - {self.member or 'empty'}"
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."""
name = models.CharField(max_length=250)
module = models.CharField(max_length=250)
sport_type = models.CharField(
_("sport"),
max_length=20,
choices=Club.SportType.choices,
default=Club.SportType.OTHER,
help_text=_("Which sport this competition is for."),
)
flag = models.ForeignKey(
settings.WAFFLE_FLAG_MODEL,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="competitions",
verbose_name=_("feature flag"),
help_text=_("Which clubs this competition is offered to -- set (or leave blank to hide it everywhere) from the control panel's Features page. A competition with no flag never shows up on the Event admin's competition dropdown."),
)
class Meta:
verbose_name = _("competition")
verbose_name_plural = _("competitions")
ordering = ["name"]
def __str__(self):
return self.name