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
122 lines
6.4 KiB
Python
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)
|