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

@@ -2,7 +2,7 @@ from django import forms
from django.contrib import admin from django.contrib import admin
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .models import Attendance, Competition, Event, EventReferee, EventSeries, Lineup, LineupSlot, LineupUnit, Location, Opponent from .models import Attendance, Competition, Event, EventReferee, EventSeries, Lineup, LineupSelection, Location, Opponent
@admin.register(Opponent) @admin.register(Opponent)
@@ -118,18 +118,11 @@ class LineupAdmin(admin.ModelAdmin):
raw_id_fields = ["event", "team", "created_by"] raw_id_fields = ["event", "team", "created_by"]
@admin.register(LineupUnit) @admin.register(LineupSelection)
class LineupUnitAdmin(admin.ModelAdmin): class LineupSelectionAdmin(admin.ModelAdmin):
list_display = ["lineup", "label", "ordering"] list_display = ["lineup", "member"]
search_fields = ["lineup__event__title", "label"] search_fields = ["lineup__event__title", "member__first_name", "member__last_name"]
raw_id_fields = ["lineup"] raw_id_fields = ["lineup", "member"]
@admin.register(LineupSlot)
class LineupSlotAdmin(admin.ModelAdmin):
list_display = ["unit", "ordering", "member"]
search_fields = ["unit__label", "member__first_name", "member__last_name"]
raw_id_fields = ["unit", "member"]
@admin.register(EventReferee) @admin.register(EventReferee)

View File

@@ -0,0 +1,44 @@
# Generated by Django 6.0.6 on 2026-08-22 14:23
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0024_lineup_lineupunit_lineupslot'),
('members', '0006_parentclaim_submitted_by_user'),
]
operations = [
migrations.RemoveField(
model_name='lineupunit',
name='lineup',
),
migrations.CreateModel(
name='LineupSelection',
fields=[
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('lineup', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='selections', to='events.lineup', verbose_name='line-up')),
('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lineup_selections', to='members.member', verbose_name='member')),
],
options={
'verbose_name': 'line-up selection',
'verbose_name_plural': 'line-up selections',
},
),
migrations.DeleteModel(
name='LineupSlot',
),
migrations.DeleteModel(
name='LineupUnit',
),
migrations.AddConstraint(
model_name='lineupselection',
constraint=models.UniqueConstraint(fields=('lineup', 'member'), name='unique_selection_per_lineup_per_member'),
),
]

View File

@@ -200,12 +200,13 @@ class Attendance(UUIDModel):
class Lineup(UUIDModel): class Lineup(UUIDModel):
"""A game's line-up -- coach mode's C3 screen (mobile/coach_views.py's """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, ...) CoachLineupView): a plain yes/no pick per roster player (``selections``),
hold ordered ``slots``, each optionally filled by a member. Publishing not lines/slots -- a coach doesn't need to think in terms of who's on
(events.services.lineup.publish_lineup) flips every slotted member's which line, just who's in. Publishing (events.services.lineup.
Attendance.status to SELECTED and every other available roster member's publish_lineup) flips every selected member's Attendance.status to
to NOT_SELECTED -- the reason those two statuses exist on Attendance in SELECTED and every other available roster member's to NOT_SELECTED --
the first place, previously unused.""" 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")) 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")) team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name="lineups", verbose_name=_("team"))
@@ -223,45 +224,25 @@ class Lineup(UUIDModel):
validate_club_scope(self, self.event.club_id if self.event_id else None, same_club_fields=("team",)) validate_club_scope(self, self.event.club_id if self.event_id else None, same_club_fields=("team",))
class LineupUnit(UUIDModel): class LineupSelection(UUIDModel):
"""One group of slots within a Lineup -- "Line 1", "Defence pair 1", ... """One roster player the coach has marked "in" for a Lineup -- presence
Coach-entered labels, not a fixed sport structure: neither Club nor Team of the row *is* the yes; there's no separate flag and no "no" row.
carries a sport field to derive one from.""" Grouped by the member's own roster position ("category") when displayed,
both in coach mode and on the published member-side view -- see
events.services.lineup for the publish-time read of this set."""
lineup = models.ForeignKey(Lineup, on_delete=models.CASCADE, related_name="units", verbose_name=_("line-up")) lineup = models.ForeignKey(Lineup, on_delete=models.CASCADE, related_name="selections", verbose_name=_("line-up"))
label = models.CharField(_("label"), max_length=100) member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="lineup_selections", verbose_name=_("member"))
ordering = models.PositiveSmallIntegerField(_("ordering"), default=0)
class Meta: class Meta:
verbose_name = _("line-up unit") verbose_name = _("line-up selection")
verbose_name_plural = _("line-up units") verbose_name_plural = _("line-up selections")
ordering = ["ordering"] constraints = [
models.UniqueConstraint(fields=["lineup", "member"], name="unique_selection_per_lineup_per_member"),
]
def __str__(self): def __str__(self):
return self.label return f"{self.lineup} - {self.member}"
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): class EventReferee(UUIDModel):

View File

@@ -1,53 +1,46 @@
"""Placing players into a Lineup's slots and publishing it -- coach mode's C3 """Picking players for a Lineup (plain yes/no, no lines/slots) and publishing
screen (mobile/coach_views.py's CoachLineupView/CoachLineupPlaceView). Mirrors it -- coach mode's C3 screen (mobile/coach_views.py's CoachLineupView).
events.services.attendance's shape: small, focused functions the view writes Mirrors events.services.attendance's shape: small, focused functions the
through rather than touching the models directly. view writes through rather than touching the models directly.
""" """
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from club.services.access import event_season 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 members.models import Member
from notifications.services import notify_members 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 #: Attendance statuses that mean "not actually available" -- these members
#: stay exactly as they are on publish, never flipped to NOT_SELECTED, and #: 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] UNAVAILABLE_STATUSES = [Attendance.AttendanceStatus.ABSENT, Attendance.AttendanceStatus.EXCUSED, Attendance.AttendanceStatus.NO_RESPONSE]
def place_member(lineup, slot, member): def toggle_selection(lineup, member) -> bool:
"""Tap-to-place: ``member`` moves into ``slot``, vacating any other slot """Flips ``member``'s yes/no pick for ``lineup`` -- deletes their row if
of theirs in the same ``lineup`` first (a member is only ever in one slot they were in, creates one if they weren't. Returns the new state (True =
at a time). Whoever was already in ``slot``, if anyone, is simply bumped now selected)."""
back to the available pool -- the tap equivalent of the mock's "slots deleted, _details = LineupSelection.objects.filter(lineup=lineup, member=member).delete()
accept one player and swap on drop", without true drag-and-drop's two-way if deleted:
swap (see LineupSlot's own docstring for why tap-to-place instead of return False
drag-and-drop at all).""" LineupSelection.objects.create(lineup=lineup, member=member)
LineupSlot.objects.filter(unit__lineup=lineup, member=member).exclude(pk=slot.pk).update(member=None) return True
slot.member = member
slot.save(update_fields=["member"])
def clear_slot(slot):
slot.member = None
slot.save(update_fields=["member"])
def publish_lineup(lineup): def publish_lineup(lineup):
"""Marks the lineup published and writes it into the game record via """Marks the lineup published and writes it into the game record via
Attendance -- the reason AttendanceStatus.SELECTED/NOT_SELECTED exist, 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 other member with an Attendance row for this event who was actually
available (not out/silent, see UNAVAILABLE_STATUSES) becomes available (not out/silent, see UNAVAILABLE_STATUSES) becomes
NOT_SELECTED. Notifies only the selected players.""" NOT_SELECTED. Notifies only the selected players."""
lineup.published_at = timezone.now() lineup.published_at = timezone.now()
lineup.save(update_fields=["published_at"]) 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, 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) 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 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): def notify_dropout(event, member, note):
"""A player who was SELECTED in a published line-up reporting, after the """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 fact, that they can no longer make it (mobile.views.EventDetailView.post's

View File

@@ -19,7 +19,7 @@ from notifications.models import Notification
from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership
from .admin import EventAdminForm from .admin import EventAdminForm
from .models import Attendance, Competition, Event, EventReferee, EventSeries, Lineup, LineupSlot, LineupUnit, Location, Opponent from .models import Attendance, Competition, Event, EventReferee, EventSeries, Lineup, LineupSelection, Location, Opponent
from .services import ( from .services import (
cancel_occurrence, cancel_occurrence,
detach_occurrence, detach_occurrence,
@@ -34,7 +34,7 @@ from .services import (
team_no_shows, team_no_shows,
) )
from .services.calendar import add_months, month_bounds, month_grid, season_grid, week_bounds, week_grid from .services.calendar import add_months, month_bounds, month_grid, season_grid, week_bounds, week_grid
from .services.lineup import clear_slot, notify_dropout, place_member, publish_lineup from .services.lineup import notify_dropout, publish_lineup, selected_members_by_position, toggle_selection
from .services.rbihf_import import RBIHFImportError, apply_plan, build_plan, extract_team_id, parse_fixtures, suggested_location, suggested_opponent from .services.rbihf_import import RBIHFImportError, apply_plan, build_plan, extract_team_id, parse_fixtures, suggested_location, suggested_opponent
from .services.referees import RefereeAssignmentError, add_external_referee, assign_referee, conflicting_events, eligible_referees, needs_referee_management, remove_referee, set_referee_fee from .services.referees import RefereeAssignmentError, add_external_referee, assign_referee, conflicting_events, eligible_referees, needs_referee_management, remove_referee, set_referee_fee
from .tasks import send_deadline_reminders from .tasks import send_deadline_reminders
@@ -373,71 +373,46 @@ class AttendanceSyncTests(EventsTestBase):
class LineupServiceTests(EventsTestBase): class LineupServiceTests(EventsTestBase):
"""events.services.lineup -- place_member/clear_slot/publish_lineup, the """events.services.lineup -- toggle_selection/publish_lineup/
write path behind coach mode's C3 screen (mobile/coach_views.py).""" selected_members_by_position, the write+read path behind coach mode's C3
screen (mobile/coach_views.py) and the published member-side view
(mobile/views.py's EventDetailView)."""
def make_game_with_lineup(self, **event_kwargs): def make_game_with_lineup(self, **event_kwargs):
event_kwargs.setdefault("kind", Event.EventKind.GAME) event_kwargs.setdefault("kind", Event.EventKind.GAME)
event = self.make_event(**event_kwargs) event = self.make_event(**event_kwargs)
event.teams.add(self.team) event.teams.add(self.team)
lineup = Lineup.objects.create(event=event, team=self.team) lineup = Lineup.objects.create(event=event, team=self.team)
unit = LineupUnit.objects.create(lineup=lineup, label="Line 1") return event, lineup
slot = LineupSlot.objects.create(unit=unit)
return event, lineup, unit, slot
def test_place_member_fills_the_slot(self): def test_toggle_selection_selects_an_unselected_member(self):
_event, lineup, _unit, slot = self.make_game_with_lineup() _event, lineup = self.make_game_with_lineup()
place_member(lineup, slot, self.alice) now_selected = toggle_selection(lineup, self.alice)
slot.refresh_from_db() self.assertTrue(now_selected)
self.assertEqual(slot.member, self.alice) self.assertTrue(LineupSelection.objects.filter(lineup=lineup, member=self.alice).exists())
def test_place_member_vacates_the_members_other_slot_in_the_same_lineup(self): def test_toggle_selection_deselects_a_selected_member(self):
_event, lineup, unit, slot = self.make_game_with_lineup() _event, lineup = self.make_game_with_lineup()
other_slot = LineupSlot.objects.create(unit=unit, ordering=1, member=self.alice) LineupSelection.objects.create(lineup=lineup, member=self.alice)
place_member(lineup, slot, self.alice) now_selected = toggle_selection(lineup, self.alice)
other_slot.refresh_from_db() self.assertFalse(now_selected)
self.assertIsNone(other_slot.member) self.assertFalse(LineupSelection.objects.filter(lineup=lineup, member=self.alice).exists())
slot.refresh_from_db()
self.assertEqual(slot.member, self.alice)
def test_place_member_bumps_whoever_was_already_in_the_slot(self):
_event, lineup, _unit, slot = self.make_game_with_lineup()
slot.member = self.bob
slot.save()
place_member(lineup, slot, self.alice)
slot.refresh_from_db()
self.assertEqual(slot.member, self.alice)
# Bumped, not swapped -- Bob doesn't land in any other slot.
self.assertFalse(LineupSlot.objects.filter(unit__lineup=lineup, member=self.bob).exists())
def test_clear_slot_empties_it(self):
_event, _lineup, _unit, slot = self.make_game_with_lineup()
slot.member = self.alice
slot.save()
clear_slot(slot)
slot.refresh_from_db()
self.assertIsNone(slot.member)
def test_publish_sets_published_at(self): def test_publish_sets_published_at(self):
_event, lineup, _unit, _slot = self.make_game_with_lineup() _event, lineup = self.make_game_with_lineup()
publish_lineup(lineup) publish_lineup(lineup)
lineup.refresh_from_db() lineup.refresh_from_db()
self.assertIsNotNone(lineup.published_at) self.assertIsNotNone(lineup.published_at)
def test_publish_selects_slotted_members_and_not_selects_the_rest(self): def test_publish_selects_picked_members_and_not_selects_the_rest(self):
event, lineup, _unit, slot = self.make_game_with_lineup() event, lineup = self.make_game_with_lineup()
slot.member = self.alice LineupSelection.objects.create(lineup=lineup, member=self.alice)
slot.save()
Attendance.objects.update_or_create(event=event, member=self.alice, defaults={"status": Attendance.AttendanceStatus.PRESENT}) Attendance.objects.update_or_create(event=event, member=self.alice, defaults={"status": Attendance.AttendanceStatus.PRESENT})
Attendance.objects.update_or_create(event=event, member=self.bob, defaults={"status": Attendance.AttendanceStatus.PRESENT}) Attendance.objects.update_or_create(event=event, member=self.bob, defaults={"status": Attendance.AttendanceStatus.PRESENT})
@@ -447,17 +422,42 @@ class LineupServiceTests(EventsTestBase):
self.assertEqual(Attendance.objects.get(event=event, member=self.bob).status, Attendance.AttendanceStatus.NOT_SELECTED) self.assertEqual(Attendance.objects.get(event=event, member=self.bob).status, Attendance.AttendanceStatus.NOT_SELECTED)
def test_publish_leaves_unavailable_members_untouched(self): def test_publish_leaves_unavailable_members_untouched(self):
event, lineup, _unit, _slot = self.make_game_with_lineup() event, lineup = self.make_game_with_lineup()
Attendance.objects.update_or_create(event=event, member=self.bob, defaults={"status": Attendance.AttendanceStatus.ABSENT}) Attendance.objects.update_or_create(event=event, member=self.bob, defaults={"status": Attendance.AttendanceStatus.ABSENT})
publish_lineup(lineup) publish_lineup(lineup)
self.assertEqual(Attendance.objects.get(event=event, member=self.bob).status, Attendance.AttendanceStatus.ABSENT) self.assertEqual(Attendance.objects.get(event=event, member=self.bob).status, Attendance.AttendanceStatus.ABSENT)
def test_selected_members_by_position_groups_by_the_teams_roster_position(self):
_event, lineup = self.make_game_with_lineup()
winger = Position.objects.create(club=self.club, name="Winger", short_name="W", ordering=1)
defense = Position.objects.create(club=self.club, name="Defense", short_name="D", ordering=2)
TeamMembership.objects.filter(team=self.team, member=self.alice).update(position=winger)
TeamMembership.objects.filter(team=self.team, member=self.bob).update(position=defense)
LineupSelection.objects.create(lineup=lineup, member=self.alice)
LineupSelection.objects.create(lineup=lineup, member=self.bob)
categories = selected_members_by_position(lineup)
self.assertEqual([c["label"] for c in categories], ["Winger", "Defense"])
self.assertEqual(categories[0]["members"], [self.alice])
self.assertEqual(categories[1]["members"], [self.bob])
def test_selected_members_by_position_falls_back_to_no_position_bucket(self):
_event, lineup = self.make_game_with_lineup()
guest = Member.objects.create(first_name="Gia", last_name="Guest")
LineupSelection.objects.create(lineup=lineup, member=guest)
categories = selected_members_by_position(lineup)
self.assertEqual(len(categories), 1)
self.assertEqual(categories[0]["label"], "No position set")
self.assertEqual(categories[0]["members"], [guest])
def test_publish_notifies_only_selected_members(self): def test_publish_notifies_only_selected_members(self):
event, lineup, _unit, slot = self.make_game_with_lineup() event, lineup = self.make_game_with_lineup()
slot.member = self.alice LineupSelection.objects.create(lineup=lineup, member=self.alice)
slot.save()
Attendance.objects.update_or_create(event=event, member=self.alice, defaults={"status": Attendance.AttendanceStatus.PRESENT}) Attendance.objects.update_or_create(event=event, member=self.alice, defaults={"status": Attendance.AttendanceStatus.PRESENT})
Attendance.objects.update_or_create(event=event, member=self.bob, defaults={"status": Attendance.AttendanceStatus.PRESENT}) Attendance.objects.update_or_create(event=event, member=self.bob, defaults={"status": Attendance.AttendanceStatus.PRESENT})
@@ -467,7 +467,7 @@ class LineupServiceTests(EventsTestBase):
self.assertEqual(notified_member_ids, {self.alice.pk}) self.assertEqual(notified_member_ids, {self.alice.pk})
def test_notify_dropout_notifies_the_teams_managers(self): def test_notify_dropout_notifies_the_teams_managers(self):
event, _lineup, _unit, _slot = self.make_game_with_lineup() event, _lineup = self.make_game_with_lineup()
manager = Member.objects.create(first_name="Cara", last_name="Coach") manager = Member.objects.create(first_name="Cara", last_name="Coach")
management_position = Position.objects.create(club=self.club, name="Head coach", short_name="HC", staff_position=True, management_position=True) management_position = Position.objects.create(club=self.club, name="Head coach", short_name="HC", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=self.team, member=manager, season=self.season, position=management_position) StaffAssignment.objects.create(team=self.team, member=manager, season=self.season, position=management_position)
@@ -479,7 +479,7 @@ class LineupServiceTests(EventsTestBase):
self.assertIn("Twisted an ankle", notification.body) self.assertIn("Twisted an ankle", notification.body)
def test_notify_dropout_does_not_notify_non_management_staff(self): def test_notify_dropout_does_not_notify_non_management_staff(self):
event, _lineup, _unit, _slot = self.make_game_with_lineup() event, _lineup = self.make_game_with_lineup()
physio = Member.objects.create(first_name="Pat", last_name="Physio") physio = Member.objects.create(first_name="Pat", last_name="Physio")
physio_position = Position.objects.create(club=self.club, name="Physio", short_name="PH", staff_position=True, management_position=False) physio_position = Position.objects.create(club=self.club, name="Physio", short_name="PH", staff_position=True, management_position=False)
StaffAssignment.objects.create(team=self.team, member=physio, season=self.season, position=physio_position) StaffAssignment.objects.create(team=self.team, member=physio, season=self.season, position=physio_position)

View File

@@ -18,12 +18,11 @@ from django.views.generic import TemplateView, View
from club.models import Season from club.models import Season
from club.services.access import can_add_news, current_season from club.services.access import can_add_news, current_season
from controlpanel.messages import notify from controlpanel.messages import notify
from events.models import Attendance, Event, Lineup, LineupSlot, LineupUnit from events.models import Attendance, Event, Lineup, LineupSelection
from events.services.attendance import record_check_in from events.services.attendance import record_check_in
from events.services.lineup import UNAVAILABLE_STATUSES, clear_slot, place_member, publish_lineup from events.services.lineup import UNAVAILABLE_STATUSES, publish_lineup, toggle_selection
from events.tasks import notify_new_event from events.tasks import notify_new_event
from management.forms import EventForm, NewsForm from management.forms import EventForm, NewsForm
from members.models import Member
from news.models import News from news.models import News
from news.services import notify_editors_of_pending_review from news.services import notify_editors_of_pending_review
from teams.models import StaffAssignment, Team, TeamMembership from teams.models import StaffAssignment, Team, TeamMembership
@@ -427,24 +426,18 @@ class CoachAddPlayerView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
class CoachLineupView(CoachScopeMixin, LoginRequiredMixin, TemplateView): class CoachLineupView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
"""C3 -- a game's line-up: units (Line 1, Defence pair 1, ...) of ordered """C3 -- a game's line-up, kept deliberately simple: a plain yes/no pick
slots, each assigned via a native <select> rather than the design mock's per available roster player, grouped by their roster position ("category")
drag-and-drop (see events.services.lineup's own module docstring for so the coach reads it the same way the roster itself is grouped -- no
why). One batch "Save line-up" submit, not a live per-tap POST -- this lines, no slots, no drag-and-drop (an earlier build had units/slots with
codebase has no established htmx interaction pattern yet to build one on tap-to-place; replaced because it was harder to read than it needed to
(htmx.js is loaded but nothing uses it), and a reliable plain form beats be for what's really just a selection call). One batch "Save line-up"
a first, unproven real-time interaction for a screen already this large. submit, not a live per-tap POST -- same reasoning as coach/attendance.html.
Known rough edge, accepted for this stage: clicking "+ Add line"/"+ Add
slot" reloads the page via its own POST, which does NOT also save
whatever the coach had just picked in the other slots' <select>s (those
two actions don't read the assignment fields at all) -- add lines/slots
before filling them in, not after, or save first.
A Lineup is created lazily on first visit (get_or_create) -- there's no A Lineup is created lazily on first visit (get_or_create) -- there's no
separate "start a line-up" step. Viewing is open to anyone staffing the separate "start a line-up" step. Viewing is open to anyone staffing the
team; every mutation (save/add line/add slot/publish) is gated on team; saving/publishing is gated on can_manage_active_team, hidden in the
can_manage_active_team, hidden in the template and 403'd here regardless. template and 403'd here regardless.
""" """
template_name = "mobile/coach/lineup.html" template_name = "mobile/coach/lineup.html"
@@ -456,18 +449,39 @@ class CoachLineupView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
raise Http404 raise Http404
return get_object_or_404(Event, pk=self.kwargs["event_id"], club=self.request.club, teams=self.active_team, kind=Event.EventKind.GAME) return get_object_or_404(Event, pk=self.kwargs["event_id"], club=self.request.club, teams=self.active_team, kind=Event.EventKind.GAME)
def _categories(self, event, lineup):
"""Available roster players, grouped by position -- same "category"
the member-side published view groups by (mobile/views.py's
EventDetailView). A player with no TeamMembership for this team/
season (a guest call-up) lands in a catch-all "No position set"
bucket rather than being dropped."""
season = current_season(self.request.club)
memberships_by_member = {}
if season is not None:
memberships_by_member = {tm.member_id: tm for tm in TeamMembership.objects.filter(team=self.active_team, season=season).select_related("position")}
selected_ids = set(LineupSelection.objects.filter(lineup=lineup).values_list("member_id", flat=True))
available = Attendance.objects.filter(event=event).exclude(status__in=UNAVAILABLE_STATUSES).select_related("member").order_by("member__last_name", "member__first_name")
buckets = {}
for attendance in available:
membership = memberships_by_member.get(attendance.member_id)
position = membership.position if membership 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, "rows": []})
bucket["rows"].append({"member": attendance.member, "membership": membership, "selected": attendance.member_id in selected_ids})
return sorted(buckets.values(), key=lambda bucket: (bucket["ordering"], bucket["label"]))
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
event = self.get_event() event = self.get_event()
lineup, _created = Lineup.objects.get_or_create(event=event, defaults={"team": self.active_team, "created_by": self.me}) lineup, _created = Lineup.objects.get_or_create(event=event, defaults={"team": self.active_team, "created_by": self.me})
units = list(lineup.units.prefetch_related("slots__member"))
available = list(Attendance.objects.filter(event=event).exclude(status__in=UNAVAILABLE_STATUSES).select_related("member").order_by("member__last_name", "member__first_name"))
unavailable = list(Attendance.objects.filter(event=event, status__in=UNAVAILABLE_STATUSES).select_related("member")) unavailable = list(Attendance.objects.filter(event=event, status__in=UNAVAILABLE_STATUSES).select_related("member"))
return super().get_context_data( return super().get_context_data(
event=event, event=event,
lineup=lineup, lineup=lineup,
units=units, categories=self._categories(event, lineup),
available=available,
unavailable=unavailable, unavailable=unavailable,
**kwargs, **kwargs,
) )
@@ -478,50 +492,23 @@ class CoachLineupView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
event = self.get_event() event = self.get_event()
lineup, _created = Lineup.objects.get_or_create(event=event, defaults={"team": self.active_team, "created_by": self.me}) lineup, _created = Lineup.objects.get_or_create(event=event, defaults={"team": self.active_team, "created_by": self.me})
available_ids = {str(pk) for pk in Attendance.objects.filter(event=event).exclude(status__in=UNAVAILABLE_STATUSES).values_list("member_id", flat=True)} selected_ids = set(LineupSelection.objects.filter(lineup=lineup).values_list("member_id", flat=True))
available = Attendance.objects.filter(event=event).exclude(status__in=UNAVAILABLE_STATUSES).select_related("member")
for slot in LineupSlot.objects.filter(unit__lineup=lineup): selected_count = 0
submitted = request.POST.get(f"slot_{slot.pk}", "") for attendance in available:
if submitted == str(slot.member_id or ""): wants_selected = request.POST.get(f"selected_{attendance.member_id}") == "true"
continue if wants_selected != (attendance.member_id in selected_ids):
if not submitted: toggle_selection(lineup, attendance.member)
clear_slot(slot) if wants_selected:
elif submitted in available_ids: selected_count += 1
place_member(lineup, slot, Member.objects.get(pk=submitted))
notify(request, f"s|{_('Line-up saved')}|{_('Your changes have been saved.')}") title = _("Line-up saved")
body = _("%(count)d player(s) selected.") % {"count": selected_count}
notify(request, f"s|{title}|{body}")
return HttpResponseRedirect(reverse("mobile:coach_lineup", kwargs={"event_id": event.pk})) return HttpResponseRedirect(reverse("mobile:coach_lineup", kwargs={"event_id": event.pk}))
class CoachLineupAddUnitView(CoachScopeMixin, LoginRequiredMixin, View):
"""A blank "+ Add line" -- one new unit, auto-labelled and seeded with a
single empty slot; grown further via CoachLineupAddSlotView. No fixed
sport structure to seed from (see LineupUnit's own docstring)."""
def post(self, request, *args, **kwargs):
if not self.can_manage_active_team:
return HttpResponseForbidden()
event = get_object_or_404(Event, pk=kwargs["event_id"], club=request.club, teams=self.active_team, kind=Event.EventKind.GAME)
lineup, _created = Lineup.objects.get_or_create(event=event, defaults={"team": self.active_team, "created_by": self.me})
ordering = lineup.units.count()
unit = LineupUnit.objects.create(lineup=lineup, label=_("Line %(number)d") % {"number": ordering + 1}, ordering=ordering)
LineupSlot.objects.create(unit=unit, ordering=0)
return HttpResponseRedirect(reverse("mobile:coach_lineup", kwargs={"event_id": event.pk}))
class CoachLineupAddSlotView(CoachScopeMixin, LoginRequiredMixin, View):
"""One more empty slot on an existing unit."""
def post(self, request, *args, **kwargs):
if not self.can_manage_active_team:
return HttpResponseForbidden()
unit = get_object_or_404(LineupUnit, pk=kwargs["unit_id"], lineup__team=self.active_team)
LineupSlot.objects.create(unit=unit, ordering=unit.slots.count())
return HttpResponseRedirect(reverse("mobile:coach_lineup", kwargs={"event_id": unit.lineup.event_id}))
class CoachLineupPublishView(CoachScopeMixin, LoginRequiredMixin, View): class CoachLineupPublishView(CoachScopeMixin, LoginRequiredMixin, View):
"""Writes the line-up into the game record and notifies the selected """Writes the line-up into the game record and notifies the selected
players -- events.services.lineup.publish_lineup does the actual work.""" players -- events.services.lineup.publish_lineup does the actual work."""

View File

@@ -6,19 +6,20 @@
POST form can't wrap another form/link) -- tapping always marks it read, POST form can't wrap another form/link) -- tapping always marks it read,
and also navigates to the linked News/Event when its source resolves to and also navigates to the linked News/Event when its source resolves to
one (see mobile.views._notification_source_link and one (see mobile.views._notification_source_link and
NotificationsView.post). A club-coloured bar marks it unread, same NotificationsView.post). A club-coloured bar marks it unread -- full
treatment as management/templates/management/home.html's own height via self-stretch, same technique as mobile/_calendar_row.html's
notifications card. own kind marker, so it still spans the row now that the body text below
is shown in full (no truncatechars) and can wrap to several lines.
{% endcomment %} {% endcomment %}
<form method="post" action="{% url "mobile:notifications" %}" hx-boost="false"> <form method="post" action="{% url "mobile:notifications" %}" hx-boost="false">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="action" value="mark_read"> <input type="hidden" name="action" value="mark_read">
<input type="hidden" name="notification_id" value="{{ row.notification.pk }}"> <input type="hidden" name="notification_id" value="{{ row.notification.pk }}">
<button type="submit" class="m-card flex w-full items-center gap-3 p-3.5 text-left"> <button type="submit" class="m-card flex w-full items-center gap-3 p-3.5 text-left">
<span class="h-8.5 w-1.5 shrink-0 rounded-sm {% if not row.notification.read_at %}bg-club{% else %}bg-line{% endif %}"></span> <span class="w-[3px] shrink-0 self-stretch rounded-full {% if not row.notification.read_at %}bg-club{% else %}bg-line{% endif %}"></span>
<span class="min-w-0 flex-1"> <span class="min-w-0 flex-1">
<span class="block text-sm font-semibold text-ink">{{ row.notification.title }}</span> <span class="block text-sm font-semibold text-ink">{{ row.notification.title }}</span>
<span class="block truncate text-xs text-muted">{{ row.notification.body|truncatechars:120 }}</span> <span class="block text-xs text-muted">{{ row.notification.body }}</span>
{% if row.source_label %}<span class="block font-display text-[11px] font-extrabold text-club uppercase tracking-wide">{{ row.source_label }}</span>{% endif %} {% if row.source_label %}<span class="block font-display text-[11px] font-extrabold text-club uppercase tracking-wide">{{ row.source_label }}</span>{% endif %}
</span> </span>
<span class="shrink-0 font-mono text-[11px] text-dim">{% blocktrans with time=row.notification.created|timesince %}{{ time }} ago{% endblocktrans %}</span> <span class="shrink-0 font-mono text-[11px] text-dim">{% blocktrans with time=row.notification.created|timesince %}{{ time }} ago{% endblocktrans %}</span>

View File

@@ -2,21 +2,20 @@
{% load i18n %} {% load i18n %}
{% comment %} {% comment %}
C3 -- design_handoff_rosterchief_platform/README.md's C3 section: units C3 -- design_handoff_rosterchief_platform/README.md's C3 section, kept
(Line 1, Defence pair 1, ...) of ordered slots, each filled via a native deliberately simple per product feedback: a plain yes/no toggle per
<select> rather than the mock's drag-and-drop -- see available roster player, grouped by position ("category") rather than
CoachLineupView's own docstring for why, and for the known "+ Add line"/ the mock's lines/slots -- see CoachLineupView's own docstring for why an
"+ Add slot" rough edge (they don't save the other slots' picks first). earlier tap-to-place build was replaced. The mock's "fully dark screen"
The mock's "fully dark screen" is approximated with dark cards is approximated with dark cards throughout rather than overriding the
throughout rather than overriding the shared .coach-sheet's own light shared .coach-sheet's own light background -- a real per-screen shell
background -- a real per-screen shell hook is more infrastructure than hook is more infrastructure than one screen justifies.
one screen justifies.
Both forms are hx-boost="false" -- the first has three submit buttons The one form is hx-boost="false", same reasoning as every other
sharing one <form> via formaction overrides (Save/+Add slot/+Add line), write-action form in this app (see mobile/templates/mobile/event_detail.
and htmx's boost reads the form's own action rather than the actual html's own top-of-file comment) -- each row's Yes/No pair is
submitter's formaction override, so a boosted click would always post Alpine-owned (x-data toggling a hidden input), and this codebase hasn't
to the wrong endpoint. A plain navigation sidesteps that entirely. established an htmx interaction pattern to layer on top of that safely.
{% endcomment %} {% endcomment %}
{% block header_extra %} {% block header_extra %}
@@ -31,33 +30,36 @@
<form method="post" action="{% url "mobile:coach_lineup" event.pk %}" hx-boost="false"> <form method="post" action="{% url "mobile:coach_lineup" event.pk %}" hx-boost="false">
{% csrf_token %} {% csrf_token %}
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
{% for unit in units %} {% for category in categories %}
<div class="m-card-dark p-3"> <div class="m-card-dark p-3">
<div class="mb-2 font-display text-xs font-extrabold tracking-wide text-ice uppercase">{{ unit.label }}</div> <div class="mb-2 font-display text-xs font-extrabold tracking-wide text-ice uppercase">{{ category.label }}</div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{% for slot in unit.slots.all %} {% for row in category.rows %}
<select class="h-10 w-full rounded-lg border border-steel bg-steel px-2 text-sm text-white" name="slot_{{ slot.pk }}" {% if not can_manage_active_team %}disabled{% endif %}> <div class="flex items-center gap-3" {% if can_manage_active_team %}x-data="{ state: '{{ row.selected|yesno:'true,false' }}' }"{% endif %}>
<option value="">{% trans "Empty" %}</option> <div class="min-w-0 flex-1 text-sm text-white">
{% for attendance in available %} {{ row.member.get_full_name }}
<option value="{{ attendance.member.pk }}" {% if slot.member_id == attendance.member.pk %}selected{% endif %}>{{ attendance.member.get_full_name }}</option> {% if row.membership.jersey_number %}<span class="text-on-dark-dim">&middot; #{{ row.membership.jersey_number }}</span>{% endif %}
{% endfor %} </div>
</select> {% if can_manage_active_team %}
<input type="hidden" name="selected_{{ row.member.pk }}" :value="state">
<div class="flex h-9 w-24 shrink-0 overflow-hidden rounded-lg">
<button type="button" class="flex-1 font-display text-xs font-extrabold tracking-wide uppercase" :class="state === 'true' ? 'bg-ok text-white' : 'bg-steel text-on-dark-dim'" @click="state = 'true'">{% trans "Yes" %}</button>
<button type="button" class="flex-1 font-display text-xs font-extrabold tracking-wide uppercase" :class="state === 'false' ? 'bg-club text-white' : 'bg-steel text-on-dark-dim'" @click="state = 'false'">{% trans "No" %}</button>
</div>
{% elif row.selected %}
<span class="pill pill-ok shrink-0">{% trans "Yes" %}</span>
{% endif %}
</div>
{% endfor %} {% endfor %}
</div> </div>
{% if can_manage_active_team %}
<button class="mt-2 font-display text-xs font-extrabold tracking-wide text-ice uppercase" type="submit" formaction="{% url "mobile:coach_lineup_add_slot" unit.pk %}">{% trans "+ Add slot" %}</button>
{% endif %}
</div> </div>
{% empty %} {% empty %}
<div class="m-card-dark p-6 text-center text-sm text-on-dark-dim">{% trans "No lines yet." %}</div> <div class="m-card-dark p-6 text-center text-sm text-on-dark-dim">{% trans "No available players to pick from." %}</div>
{% endfor %} {% endfor %}
</div> </div>
{% if can_manage_active_team %} {% if can_manage_active_team %}
<div class="mt-3 flex gap-2"> <button class="btn btn-dark mt-3 w-full" type="submit">{% trans "Save line-up" %}</button>
<button class="btn flex-1 bg-steel text-on-dark" type="submit" formaction="{% url "mobile:coach_lineup_add_unit" event.pk %}">{% trans "+ Add line" %}</button>
<button class="btn btn-dark flex-1" type="submit">{% trans "Save line-up" %}</button>
</div>
{% endif %} {% endif %}
</form> </form>

View File

@@ -65,13 +65,11 @@
<span class="pill pill-info">{% trans "Published" %}</span> <span class="pill pill-info">{% trans "Published" %}</span>
</div> </div>
<div class="mt-3 flex flex-col gap-3"> <div class="mt-3 flex flex-col gap-3">
{% for unit in lineup.units.all %} {% for category in lineup_categories %}
<div> <div>
<div class="mb-1.5 font-display text-xs font-extrabold tracking-wide text-club uppercase">{{ unit.label }}</div> <div class="mb-1.5 font-display text-xs font-extrabold tracking-wide text-club uppercase">{{ category.label }}</div>
<div class="flex flex-wrap gap-1.5"> <div class="flex flex-wrap gap-1.5">
{% for slot in unit.slots.all %} {% for member in category.members %}<span class="pill pill-neutral">{{ member.get_full_name }}</span>{% endfor %}
{% if slot.member %}<span class="pill pill-neutral">{{ slot.member.get_full_name }}</span>{% endif %}
{% endfor %}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}

View File

@@ -9,7 +9,7 @@ from django.utils import timezone, translation
from icalendar import Calendar as ICalCalendar from icalendar import Calendar as ICalCalendar
from club.models import Club, ClubMembership, DuesInvoice, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor from club.models import Club, ClubMembership, DuesInvoice, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
from events.models import Attendance, Event, Lineup, LineupSlot, LineupUnit from events.models import Attendance, Event, Lineup, LineupSelection
from members.models import Family, FamilyMembership, Member from members.models import Family, FamilyMembership, Member
from news.models import News from news.models import News
from notifications.models import Notification from notifications.models import Notification
@@ -1113,24 +1113,23 @@ class EventDetailScreenTests(TestCase):
def test_unpublished_lineup_is_not_shown(self): def test_unpublished_lineup_is_not_shown(self):
lineup = Lineup.objects.create(event=self.event, team=self.team) lineup = Lineup.objects.create(event=self.event, team=self.team)
unit = LineupUnit.objects.create(lineup=lineup, label="Line 1") LineupSelection.objects.create(lineup=lineup, member=self.member)
LineupSlot.objects.create(unit=unit, member=self.member)
self.client.force_login(self.user) self.client.force_login(self.user)
response = self._get() response = self._get()
self.assertIsNone(response.context["lineup"]) self.assertIsNone(response.context["lineup"])
def test_published_lineup_shows_its_units_and_slotted_members(self): def test_published_lineup_shows_its_selected_members_grouped_by_position(self):
TeamMembership.objects.create(team=self.team, member=self.member, season=self.season, position=self.position)
lineup = Lineup.objects.create(event=self.event, team=self.team, published_at=timezone.now()) lineup = Lineup.objects.create(event=self.event, team=self.team, published_at=timezone.now())
unit = LineupUnit.objects.create(lineup=lineup, label="Line 1") LineupSelection.objects.create(lineup=lineup, member=self.member)
LineupSlot.objects.create(unit=unit, member=self.member)
self.client.force_login(self.user) self.client.force_login(self.user)
response = self._get() response = self._get()
self.assertEqual(response.context["lineup"], lineup) self.assertEqual(response.context["lineup"], lineup)
self.assertContains(response, "Line 1") self.assertContains(response, "Forward")
self.assertContains(response, self.member.get_full_name()) self.assertContains(response, self.member.get_full_name())
def test_rsvp_buttons_are_replaced_by_a_readonly_pill_once_the_lineup_is_published(self): def test_rsvp_buttons_are_replaced_by_a_readonly_pill_once_the_lineup_is_published(self):
@@ -1255,6 +1254,15 @@ class NotificationsViewTests(TestCase):
self.assertContains(response, "For Lars") self.assertContains(response, "For Lars")
self.assertContains(response, "For Noor") self.assertContains(response, "For Noor")
def test_body_text_is_shown_in_full_not_truncated(self):
long_body = "This is a long notification body. " * 10
Notification.objects.create(club=self.club, member=self.member, title="Long one", body=long_body)
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, long_body)
def test_notification_for_someone_not_managed_is_excluded(self): def test_notification_for_someone_not_managed_is_excluded(self):
stranger = Member.objects.create(first_name="Someone", last_name="Else") stranger = Member.objects.create(first_name="Someone", last_name="Else")
Notification.objects.create(club=self.club, member=stranger, title="Not yours", body="Body.") Notification.objects.create(club=self.club, member=stranger, title="Not yours", body="Body.")
@@ -2749,8 +2757,8 @@ class CoachAddPlayerViewTests(TestCase):
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"]) @override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class CoachLineupViewTests(TestCase): class CoachLineupViewTests(TestCase):
"""C3 -- design_handoff_rosterchief_platform/README.md's C3 section; see """C3 -- design_handoff_rosterchief_platform/README.md's C3 section; see
CoachLineupView's own docstring for the tap-to-place -> native-select CoachLineupView's own docstring for the plain yes/no-per-player design
simplification and its known rough edges.""" (no lines/slots)."""
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@@ -2798,73 +2806,52 @@ class CoachLineupViewTests(TestCase):
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
def test_add_line_creates_a_unit_with_one_slot(self): def test_get_groups_available_players_by_position(self):
self.client.force_login(self.user) self.client.force_login(self.user)
response = self.client.post(reverse("mobile:coach_lineup_add_unit", kwargs={"event_id": self.event.pk}), HTTP_HOST="ajax-united.rosterchief.app") response = self.client.get(reverse("mobile:coach_lineup", kwargs={"event_id": self.event.pk}), HTTP_HOST="ajax-united.rosterchief.app")
self.assertRedirects(response, reverse("mobile:coach_lineup", kwargs={"event_id": self.event.pk}), fetch_redirect_response=False) categories = response.context["categories"]
lineup = Lineup.objects.get(event=self.event) self.assertEqual(len(categories), 1)
self.assertEqual(lineup.units.count(), 1) self.assertEqual(categories[0]["label"], "No position set")
self.assertEqual(lineup.units.first().slots.count(), 1) self.assertEqual([row["member"] for row in categories[0]["rows"]], [self.player])
self.assertFalse(categories[0]["rows"][0]["selected"])
def test_add_slot_grows_an_existing_unit(self): def test_save_selects_the_submitted_players(self):
self.client.force_login(self.user) self.client.force_login(self.user)
lineup = Lineup.objects.create(event=self.event, team=self.team) lineup = Lineup.objects.create(event=self.event, team=self.team)
unit = LineupUnit.objects.create(lineup=lineup, label="Line 1")
response = self.client.post(reverse("mobile:coach_lineup_add_slot", kwargs={"unit_id": unit.pk}), HTTP_HOST="ajax-united.rosterchief.app")
self.assertRedirects(response, reverse("mobile:coach_lineup", kwargs={"event_id": self.event.pk}), fetch_redirect_response=False)
self.assertEqual(unit.slots.count(), 1)
def test_save_places_the_selected_member_in_the_slot(self):
self.client.force_login(self.user)
lineup = Lineup.objects.create(event=self.event, team=self.team)
unit = LineupUnit.objects.create(lineup=lineup, label="Line 1")
slot = LineupSlot.objects.create(unit=unit)
response = self.client.post( response = self.client.post(
reverse("mobile:coach_lineup", kwargs={"event_id": self.event.pk}), reverse("mobile:coach_lineup", kwargs={"event_id": self.event.pk}),
{f"slot_{slot.pk}": str(self.player.pk)}, {f"selected_{self.player.pk}": "true"},
HTTP_HOST="ajax-united.rosterchief.app", HTTP_HOST="ajax-united.rosterchief.app",
) )
self.assertRedirects(response, reverse("mobile:coach_lineup", kwargs={"event_id": self.event.pk}), fetch_redirect_response=False) self.assertRedirects(response, reverse("mobile:coach_lineup", kwargs={"event_id": self.event.pk}), fetch_redirect_response=False)
slot.refresh_from_db() self.assertTrue(LineupSelection.objects.filter(lineup=lineup, member=self.player).exists())
self.assertEqual(slot.member, self.player)
def test_save_clears_a_slot_when_empty_is_submitted(self): def test_save_deselects_a_player_when_not_submitted(self):
self.client.force_login(self.user) self.client.force_login(self.user)
lineup = Lineup.objects.create(event=self.event, team=self.team) lineup = Lineup.objects.create(event=self.event, team=self.team)
unit = LineupUnit.objects.create(lineup=lineup, label="Line 1") LineupSelection.objects.create(lineup=lineup, member=self.player)
slot = LineupSlot.objects.create(unit=unit, member=self.player)
response = self.client.post( response = self.client.post(reverse("mobile:coach_lineup", kwargs={"event_id": self.event.pk}), {}, HTTP_HOST="ajax-united.rosterchief.app")
reverse("mobile:coach_lineup", kwargs={"event_id": self.event.pk}),
{f"slot_{slot.pk}": ""},
HTTP_HOST="ajax-united.rosterchief.app",
)
self.assertRedirects(response, reverse("mobile:coach_lineup", kwargs={"event_id": self.event.pk}), fetch_redirect_response=False) self.assertRedirects(response, reverse("mobile:coach_lineup", kwargs={"event_id": self.event.pk}), fetch_redirect_response=False)
slot.refresh_from_db() self.assertFalse(LineupSelection.objects.filter(lineup=lineup, member=self.player).exists())
self.assertIsNone(slot.member)
def test_save_ignores_a_member_id_outside_the_available_pool(self): def test_save_ignores_a_member_id_outside_the_available_pool(self):
outsider = Member.objects.create(first_name="Not", last_name="Available") outsider = Member.objects.create(first_name="Not", last_name="Available")
self.client.force_login(self.user) self.client.force_login(self.user)
lineup = Lineup.objects.create(event=self.event, team=self.team) lineup = Lineup.objects.create(event=self.event, team=self.team)
unit = LineupUnit.objects.create(lineup=lineup, label="Line 1")
slot = LineupSlot.objects.create(unit=unit)
self.client.post( self.client.post(
reverse("mobile:coach_lineup", kwargs={"event_id": self.event.pk}), reverse("mobile:coach_lineup", kwargs={"event_id": self.event.pk}),
{f"slot_{slot.pk}": str(outsider.pk)}, {f"selected_{outsider.pk}": "true"},
HTTP_HOST="ajax-united.rosterchief.app", HTTP_HOST="ajax-united.rosterchief.app",
) )
slot.refresh_from_db() self.assertFalse(LineupSelection.objects.filter(lineup=lineup, member=outsider).exists())
self.assertIsNone(slot.member)
def test_publish_marks_the_lineup_published(self): def test_publish_marks_the_lineup_published(self):
self.client.force_login(self.user) self.client.force_login(self.user)

View File

@@ -31,7 +31,5 @@ urlpatterns = [
path("coach/news/new/", coach_views.CoachCreateNewsView.as_view(), name="coach_create_news"), path("coach/news/new/", coach_views.CoachCreateNewsView.as_view(), name="coach_create_news"),
path("coach/roster/add/", coach_views.CoachAddPlayerView.as_view(), name="coach_add_player"), path("coach/roster/add/", coach_views.CoachAddPlayerView.as_view(), name="coach_add_player"),
path("coach/lineup/<uuid:event_id>/", coach_views.CoachLineupView.as_view(), name="coach_lineup"), path("coach/lineup/<uuid:event_id>/", coach_views.CoachLineupView.as_view(), name="coach_lineup"),
path("coach/lineup/<uuid:event_id>/units/add/", coach_views.CoachLineupAddUnitView.as_view(), name="coach_lineup_add_unit"),
path("coach/lineup/units/<uuid:unit_id>/slots/add/", coach_views.CoachLineupAddSlotView.as_view(), name="coach_lineup_add_slot"),
path("coach/lineup/<uuid:event_id>/publish/", coach_views.CoachLineupPublishView.as_view(), name="coach_lineup_publish"), path("coach/lineup/<uuid:event_id>/publish/", coach_views.CoachLineupPublishView.as_view(), name="coach_lineup_publish"),
] ]

View File

@@ -29,7 +29,7 @@ from club.services.sponsors import active_sponsors
from controlpanel.messages import notify from controlpanel.messages import notify
from events.models import Attendance, Event, Lineup from events.models import Attendance, Event, Lineup
from events.services.calendar import week_bounds from events.services.calendar import week_bounds
from events.services.lineup import notify_dropout from events.services.lineup import notify_dropout, selected_members_by_position
from members.models import FamilyMembership, Member from members.models import FamilyMembership, Member
from members.views import ClubScopedPublicMixin from members.views import ClubScopedPublicMixin
from news.models import News from news.models import News
@@ -400,7 +400,8 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
# A published line-up supersedes ordinary RSVP -- the roster's locked # A published line-up supersedes ordinary RSVP -- the roster's locked
# in, so "Your answers" below switches to read-only and the line-up # in, so "Your answers" below switches to read-only and the line-up
# itself gets its own card. # itself gets its own card.
lineup = Lineup.objects.filter(event=event, published_at__isnull=False).prefetch_related("units__slots__member").first() lineup = Lineup.objects.filter(event=event, published_at__isnull=False).first()
lineup_categories = selected_members_by_position(lineup) if lineup is not None else []
your_answers = [] your_answers = []
if self.managed_people: if self.managed_people:
@@ -440,7 +441,7 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
"no_reply_pct": round(100 * counts["no_reply_count"] / total), "no_reply_pct": round(100 * counts["no_reply_count"] / total),
} }
return super().get_context_data(screen_title=event.title, event=event, rsvp_closed=rsvp_closed, lineup=lineup, your_answers=your_answers, squad_summary=squad_summary, **kwargs) return super().get_context_data(screen_title=event.title, event=event, rsvp_closed=rsvp_closed, lineup=lineup, lineup_categories=lineup_categories, your_answers=your_answers, squad_summary=squad_summary, **kwargs)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
status = request.POST.get("status") status = request.POST.get("status")

File diff suppressed because one or more lines are too long