Simplify the line-up to yes/no picks, and stop truncating notifications

Coach mode's line-up screen no longer has lines/slots -- just a yes/no
toggle per available roster player, grouped by their roster position, both
in coach mode and on the published member-side view. Replaces LineupUnit/
LineupSlot with a single LineupSelection model.

Notifications now show their full body text (no more truncatechars) and the
unread colour bar spans the full row height via self-stretch, matching the
calendar row's own marker, so it still reads correctly once a body wraps to
several lines.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
2026-08-22 16:29:21 +02:00
parent 2b6a4d21bb
commit 135580d83e
13 changed files with 300 additions and 285 deletions

View File

@@ -1,53 +1,46 @@
"""Placing players into a Lineup's slots and publishing it -- coach mode's C3
screen (mobile/coach_views.py's CoachLineupView/CoachLineupPlaceView). Mirrors
events.services.attendance's shape: small, focused functions the view writes
through rather than touching the models directly.
"""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, LineupSlot
from events.models import Attendance, LineupSelection
from members.models import Member
from notifications.services import notify_members
from teams.models import StaffAssignment
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 placeable in the available-players pool.
#: aren't offered as pickable for the line-up.
UNAVAILABLE_STATUSES = [Attendance.AttendanceStatus.ABSENT, Attendance.AttendanceStatus.EXCUSED, Attendance.AttendanceStatus.NO_RESPONSE]
def place_member(lineup, slot, member):
"""Tap-to-place: ``member`` moves into ``slot``, vacating any other slot
of theirs in the same ``lineup`` first (a member is only ever in one slot
at a time). Whoever was already in ``slot``, if anyone, is simply bumped
back to the available pool -- the tap equivalent of the mock's "slots
accept one player and swap on drop", without true drag-and-drop's two-way
swap (see LineupSlot's own docstring for why tap-to-place instead of
drag-and-drop at all)."""
LineupSlot.objects.filter(unit__lineup=lineup, member=member).exclude(pk=slot.pk).update(member=None)
slot.member = member
slot.save(update_fields=["member"])
def clear_slot(slot):
slot.member = None
slot.save(update_fields=["member"])
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 slotted member becomes SELECTED; every
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."""
lineup.published_at = timezone.now()
lineup.save(update_fields=["published_at"])
selected_member_ids = set(LineupSlot.objects.filter(unit__lineup=lineup, member__isnull=False).values_list("member_id", flat=True))
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)
@@ -60,6 +53,36 @@ def publish_lineup(lineup):
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