Files
RosterChief/events/services/lineup.py
Bernard Siebens 1d0c2ef299 Let a coach schedule a line-up to publish itself later
Publish now still works exactly as before (default action), but a coach
can also pick a future date/time -- events.services.lineup.
schedule_lineup_publish sets Lineup.scheduled_publish_at, and a new
periodic task (events.tasks.publish_scheduled_lineups, every 15 minutes)
publishes it once that time arrives via the same publish_lineup used for a
manual publish, so selection/Attendance/notifications all work identically
either way. A pending schedule can be cancelled or published early from the
same screen.

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

122 lines
6.4 KiB
Python

"""Picking players for a Lineup (plain yes/no, no lines/slots) and publishing
it -- coach mode's C3 screen (mobile/coach_views.py's CoachLineupView).
Mirrors events.services.attendance's shape: small, focused functions the
view writes through rather than touching the models directly.
"""
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from club.services.access import event_season
from events.models import Attendance, LineupSelection
from members.models import Member
from notifications.services import notify_members
from teams.models import StaffAssignment, TeamMembership
#: Attendance statuses that mean "not actually available" -- these members
#: stay exactly as they are on publish, never flipped to NOT_SELECTED, and
#: aren't offered as pickable for the line-up.
UNAVAILABLE_STATUSES = [Attendance.AttendanceStatus.ABSENT, Attendance.AttendanceStatus.EXCUSED, Attendance.AttendanceStatus.NO_RESPONSE]
def toggle_selection(lineup, member) -> bool:
"""Flips ``member``'s yes/no pick for ``lineup`` -- deletes their row if
they were in, creates one if they weren't. Returns the new state (True =
now selected)."""
deleted, _details = LineupSelection.objects.filter(lineup=lineup, member=member).delete()
if deleted:
return False
LineupSelection.objects.create(lineup=lineup, member=member)
return True
def publish_lineup(lineup):
"""Marks the lineup published and writes it into the game record via
Attendance -- the reason AttendanceStatus.SELECTED/NOT_SELECTED exist,
previously unused anywhere. Every selected member becomes SELECTED; every
other member with an Attendance row for this event who was actually
available (not out/silent, see UNAVAILABLE_STATUSES) becomes
NOT_SELECTED. Notifies only the selected players.
Clears scheduled_publish_at regardless of whether this run came from a
coach tapping Publish directly or from the scheduled sweep (events.tasks.
publish_scheduled_lineups) catching a due one -- once published, there's
nothing left pending either way."""
lineup.published_at = timezone.now()
lineup.scheduled_publish_at = None
lineup.save(update_fields=["published_at", "scheduled_publish_at"])
selected_member_ids = set(LineupSelection.objects.filter(lineup=lineup).values_list("member_id", flat=True))
Attendance.objects.filter(event=lineup.event, member_id__in=selected_member_ids).update(status=Attendance.AttendanceStatus.SELECTED)
Attendance.objects.filter(event=lineup.event).exclude(member_id__in=selected_member_ids).exclude(status__in=UNAVAILABLE_STATUSES).update(status=Attendance.AttendanceStatus.NOT_SELECTED)
selected_members = Member.objects.filter(pk__in=selected_member_ids)
if selected_members:
body = _("You're in the line-up for %(event)s.") % {"event": lineup.event.title}
notify_members(selected_members, club=lineup.event.club, title=_("Line-up published"), body=body, source=lineup.event)
return lineup
def schedule_lineup_publish(lineup, when):
"""Sets ``lineup`` to publish itself automatically at ``when`` (an aware
datetime in the future) instead of waiting for a manual Publish tap --
events.tasks.publish_scheduled_lineups is the periodic sweep that
actually calls publish_lineup once that time arrives. Overwrites any
previous schedule rather than erroring -- picking a new time is the
whole point of letting a coach come back and adjust it."""
lineup.scheduled_publish_at = when
lineup.save(update_fields=["scheduled_publish_at"])
return lineup
def cancel_scheduled_publish(lineup):
lineup.scheduled_publish_at = None
lineup.save(update_fields=["scheduled_publish_at"])
return lineup
def selected_members_by_position(lineup):
"""Every LineupSelection for ``lineup``, grouped by the member's roster
position for the lineup's own team/season ("category") -- the read side
behind the published, member-facing Line-up card (mobile/views.py's
EventDetailView). A selected member with no TeamMembership on this team/
season (a guest call-up) lands in a catch-all "No position set" bucket
rather than being dropped -- mirrors CoachLineupView's own grouping."""
season = event_season(lineup.event)
member_ids = list(LineupSelection.objects.filter(lineup=lineup).values_list("member_id", flat=True))
memberships_by_member = {}
if season is not None:
memberships_by_member = {tm.member_id: tm for tm in TeamMembership.objects.filter(team=lineup.team, season=season, member_id__in=member_ids).select_related("position")}
members_by_id = {member.pk: member for member in Member.objects.filter(pk__in=member_ids)}
buckets = {}
for member_id in member_ids:
member = members_by_id.get(member_id)
if member is None:
continue
position = memberships_by_member[member_id].position if member_id in memberships_by_member else None
key = position.pk if position else None
bucket = buckets.setdefault(key, {"label": position.name if position else _("No position set"), "ordering": position.ordering if position else 9999, "members": []})
bucket["members"].append(member)
categories = sorted(buckets.values(), key=lambda bucket: (bucket["ordering"], bucket["label"]))
for bucket in categories:
bucket["members"].sort(key=lambda member: (member.last_name, member.first_name))
return categories
def notify_dropout(event, member, note):
"""A player who was SELECTED in a published line-up reporting, after the
fact, that they can no longer make it (mobile.views.EventDetailView.post's
"dropout" status) -- unlike an ordinary pre-deadline Out, the roster is
already locked in, so this goes straight to whoever can still act on it:
every current-season manager of the event's own teams (management
position, not every staffer -- a physio can't swap a line-up slot)."""
manager_ids = StaffAssignment.objects.filter(team__in=event.teams.all(), position__management_position=True, season=event_season(event)).values_list("member_id", flat=True).distinct()
managers = Member.objects.filter(pk__in=manager_ids)
if managers:
body = _("%(member)s can no longer make it to %(event)s: “%(note)s") % {"member": member.get_full_name(), "event": event.title, "note": note}
notify_members(managers, club=event.club, title=_("Line-up dropout"), body=body, source=event)