"""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)