Add Coach mode C3: game line-up, the last of the six coach screens
New models (events/models.py): Lineup (one per event), LineupUnit (Line 1, Defence pair 1, ... -- coach-entered labels, no fixed sport structure since neither Club nor Team carries one), LineupSlot (one position, optionally filled by a member). events/services/lineup.py's publish_lineup is the reason Attendance.AttendanceStatus.SELECTED/NOT_SELECTED existed at all -- publishing flips every slotted member to SELECTED and every other available (non-out, non-silent) roster member to NOT_SELECTED, then notifies only the selected players. Placement is a native <select> per slot, batch-saved with one "Save line-up" submit, not the design mock's drag-and-drop -- this codebase has no established htmx interaction pattern yet (htmx.js is loaded but nothing uses it) to build a live per-tap version on, and a reliable plain form beats a first, unproven real-time interaction for an already-large screen. place_member handles "swap" semantics for it: placing a member vacates any other slot of theirs in the same lineup, bumping whoever was already in the target slot back to the available pool. Wires the missing pieces from C1's own docstring: the "needs you" line-up- not-published row and the tonight card's "Line-up" button, both deferred in that stage specifically because this model didn't exist yet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
@@ -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, Location, Opponent
|
from .models import Attendance, Competition, Event, EventReferee, EventSeries, Lineup, LineupSlot, LineupUnit, Location, Opponent
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Opponent)
|
@admin.register(Opponent)
|
||||||
@@ -110,6 +110,28 @@ class AttendanceAdmin(admin.ModelAdmin):
|
|||||||
raw_id_fields = ["event", "member"]
|
raw_id_fields = ["event", "member"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Lineup)
|
||||||
|
class LineupAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["event", "team", "published_at", "created_by"]
|
||||||
|
list_filter = ["team"]
|
||||||
|
search_fields = ["event__title", "team__name"]
|
||||||
|
raw_id_fields = ["event", "team", "created_by"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(LineupUnit)
|
||||||
|
class LineupUnitAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["lineup", "label", "ordering"]
|
||||||
|
search_fields = ["lineup__event__title", "label"]
|
||||||
|
raw_id_fields = ["lineup"]
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
class EventRefereeAdmin(admin.ModelAdmin):
|
class EventRefereeAdmin(admin.ModelAdmin):
|
||||||
list_display = ["event", "display_name", "fee", "km", "total_payable", "assigned_by"]
|
list_display = ["event", "display_name", "fee", "km", "total_payable", "assigned_by"]
|
||||||
|
|||||||
65
events/migrations/0024_lineup_lineupunit_lineupslot.py
Normal file
65
events/migrations/0024_lineup_lineupunit_lineupslot.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-08-21 20:37
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('events', '0023_event_deadline_reminder_sent_at'),
|
||||||
|
('members', '0006_parentclaim_submitted_by_user'),
|
||||||
|
('teams', '0012_alter_refereelevel_options_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Lineup',
|
||||||
|
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)),
|
||||||
|
('published_at', models.DateTimeField(blank=True, null=True, verbose_name='published at')),
|
||||||
|
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_lineups', to='members.member', verbose_name='created by')),
|
||||||
|
('event', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='lineup', to='events.event', verbose_name='event')),
|
||||||
|
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lineups', to='teams.team', verbose_name='team')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'line-up',
|
||||||
|
'verbose_name_plural': 'line-ups',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='LineupUnit',
|
||||||
|
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)),
|
||||||
|
('label', models.CharField(max_length=100, verbose_name='label')),
|
||||||
|
('ordering', models.PositiveSmallIntegerField(default=0, verbose_name='ordering')),
|
||||||
|
('lineup', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='units', to='events.lineup', verbose_name='line-up')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'line-up unit',
|
||||||
|
'verbose_name_plural': 'line-up units',
|
||||||
|
'ordering': ['ordering'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='LineupSlot',
|
||||||
|
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)),
|
||||||
|
('ordering', models.PositiveSmallIntegerField(default=0, verbose_name='ordering')),
|
||||||
|
('member', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='lineup_slots', to='members.member', verbose_name='member')),
|
||||||
|
('unit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='slots', to='events.lineupunit', verbose_name='unit')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'line-up slot',
|
||||||
|
'verbose_name_plural': 'line-up slots',
|
||||||
|
'ordering': ['ordering'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -198,6 +198,72 @@ class Attendance(UUIDModel):
|
|||||||
return f"{self.event} - {self.member}"
|
return f"{self.event} - {self.member}"
|
||||||
|
|
||||||
|
|
||||||
|
class Lineup(UUIDModel):
|
||||||
|
"""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, ...)
|
||||||
|
hold ordered ``slots``, each optionally filled by a member. Publishing
|
||||||
|
(events.services.lineup.publish_lineup) flips every slotted member's
|
||||||
|
Attendance.status to SELECTED and every other available roster member's
|
||||||
|
to NOT_SELECTED -- 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"))
|
||||||
|
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name="lineups", verbose_name=_("team"))
|
||||||
|
published_at = models.DateTimeField(_("published at"), null=True, blank=True)
|
||||||
|
created_by = models.ForeignKey(Member, on_delete=models.SET_NULL, null=True, blank=True, related_name="created_lineups", verbose_name=_("created by"))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("line-up")
|
||||||
|
verbose_name_plural = _("line-ups")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.team} - {self.event}"
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
validate_club_scope(self, self.event.club_id if self.event_id else None, same_club_fields=("team",))
|
||||||
|
|
||||||
|
|
||||||
|
class LineupUnit(UUIDModel):
|
||||||
|
"""One group of slots within a Lineup -- "Line 1", "Defence pair 1", ...
|
||||||
|
Coach-entered labels, not a fixed sport structure: neither Club nor Team
|
||||||
|
carries a sport field to derive one from."""
|
||||||
|
|
||||||
|
lineup = models.ForeignKey(Lineup, on_delete=models.CASCADE, related_name="units", verbose_name=_("line-up"))
|
||||||
|
label = models.CharField(_("label"), max_length=100)
|
||||||
|
ordering = models.PositiveSmallIntegerField(_("ordering"), default=0)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("line-up unit")
|
||||||
|
verbose_name_plural = _("line-up units")
|
||||||
|
ordering = ["ordering"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.label
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
"""One referee assigned to one (home) game -- either a club member
|
"""One referee assigned to one (home) game -- either a club member
|
||||||
(``member`` set) or an external referee logged by name only
|
(``member`` set) or an external referee logged by name only
|
||||||
|
|||||||
58
events/services/lineup.py
Normal file
58
events/services/lineup.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"""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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from events.models import Attendance, LineupSlot
|
||||||
|
from members.models import Member
|
||||||
|
from notifications.services import notify_members
|
||||||
|
|
||||||
|
#: 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.
|
||||||
|
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 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
|
||||||
|
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))
|
||||||
|
|
||||||
|
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
|
||||||
@@ -19,7 +19,7 @@ from notifications.models import Notification
|
|||||||
from teams.models import Position, RefereeLevel, RefereeProfile, Team, TeamMembership
|
from teams.models import Position, RefereeLevel, RefereeProfile, Team, TeamMembership
|
||||||
|
|
||||||
from .admin import EventAdminForm
|
from .admin import EventAdminForm
|
||||||
from .models import Attendance, Competition, Event, EventReferee, EventSeries, Location, Opponent
|
from .models import Attendance, Competition, Event, EventReferee, EventSeries, Lineup, LineupSlot, LineupUnit, Location, Opponent
|
||||||
from .services import (
|
from .services import (
|
||||||
cancel_occurrence,
|
cancel_occurrence,
|
||||||
detach_occurrence,
|
detach_occurrence,
|
||||||
@@ -34,6 +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, place_member, publish_lineup
|
||||||
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
|
||||||
@@ -371,6 +372,101 @@ class AttendanceSyncTests(EventsTestBase):
|
|||||||
self.assertEqual(event.attendances.count(), 0)
|
self.assertEqual(event.attendances.count(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
class LineupServiceTests(EventsTestBase):
|
||||||
|
"""events.services.lineup -- place_member/clear_slot/publish_lineup, the
|
||||||
|
write path behind coach mode's C3 screen (mobile/coach_views.py)."""
|
||||||
|
|
||||||
|
def make_game_with_lineup(self, **event_kwargs):
|
||||||
|
event_kwargs.setdefault("kind", Event.EventKind.GAME)
|
||||||
|
event = self.make_event(**event_kwargs)
|
||||||
|
event.teams.add(self.team)
|
||||||
|
lineup = Lineup.objects.create(event=event, team=self.team)
|
||||||
|
unit = LineupUnit.objects.create(lineup=lineup, label="Line 1")
|
||||||
|
slot = LineupSlot.objects.create(unit=unit)
|
||||||
|
return event, lineup, unit, slot
|
||||||
|
|
||||||
|
def test_place_member_fills_the_slot(self):
|
||||||
|
_event, lineup, _unit, slot = self.make_game_with_lineup()
|
||||||
|
|
||||||
|
place_member(lineup, slot, self.alice)
|
||||||
|
|
||||||
|
slot.refresh_from_db()
|
||||||
|
self.assertEqual(slot.member, self.alice)
|
||||||
|
|
||||||
|
def test_place_member_vacates_the_members_other_slot_in_the_same_lineup(self):
|
||||||
|
_event, lineup, unit, slot = self.make_game_with_lineup()
|
||||||
|
other_slot = LineupSlot.objects.create(unit=unit, ordering=1, member=self.alice)
|
||||||
|
|
||||||
|
place_member(lineup, slot, self.alice)
|
||||||
|
|
||||||
|
other_slot.refresh_from_db()
|
||||||
|
self.assertIsNone(other_slot.member)
|
||||||
|
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):
|
||||||
|
_event, lineup, _unit, _slot = self.make_game_with_lineup()
|
||||||
|
|
||||||
|
publish_lineup(lineup)
|
||||||
|
|
||||||
|
lineup.refresh_from_db()
|
||||||
|
self.assertIsNotNone(lineup.published_at)
|
||||||
|
|
||||||
|
def test_publish_selects_slotted_members_and_not_selects_the_rest(self):
|
||||||
|
event, lineup, _unit, slot = self.make_game_with_lineup()
|
||||||
|
slot.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.bob, defaults={"status": Attendance.AttendanceStatus.PRESENT})
|
||||||
|
|
||||||
|
publish_lineup(lineup)
|
||||||
|
|
||||||
|
self.assertEqual(Attendance.objects.get(event=event, member=self.alice).status, Attendance.AttendanceStatus.SELECTED)
|
||||||
|
self.assertEqual(Attendance.objects.get(event=event, member=self.bob).status, Attendance.AttendanceStatus.NOT_SELECTED)
|
||||||
|
|
||||||
|
def test_publish_leaves_unavailable_members_untouched(self):
|
||||||
|
event, lineup, _unit, _slot = self.make_game_with_lineup()
|
||||||
|
Attendance.objects.update_or_create(event=event, member=self.bob, defaults={"status": Attendance.AttendanceStatus.ABSENT})
|
||||||
|
|
||||||
|
publish_lineup(lineup)
|
||||||
|
|
||||||
|
self.assertEqual(Attendance.objects.get(event=event, member=self.bob).status, Attendance.AttendanceStatus.ABSENT)
|
||||||
|
|
||||||
|
def test_publish_notifies_only_selected_members(self):
|
||||||
|
event, lineup, _unit, slot = self.make_game_with_lineup()
|
||||||
|
slot.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.bob, defaults={"status": Attendance.AttendanceStatus.PRESENT})
|
||||||
|
|
||||||
|
publish_lineup(lineup)
|
||||||
|
|
||||||
|
notified_member_ids = set(Notification.objects.filter(member__in=[self.alice, self.bob]).values_list("member_id", flat=True))
|
||||||
|
self.assertEqual(notified_member_ids, {self.alice.pk})
|
||||||
|
|
||||||
|
|
||||||
class RosterChangeSyncTests(EventsTestBase):
|
class RosterChangeSyncTests(EventsTestBase):
|
||||||
def test_adding_roster_member_syncs_future_events(self):
|
def test_adding_roster_member_syncs_future_events(self):
|
||||||
event = self.make_event()
|
event = self.make_event()
|
||||||
|
|||||||
@@ -13,15 +13,17 @@ from django.urls import reverse
|
|||||||
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 django.utils.translation import ngettext
|
from django.utils.translation import ngettext
|
||||||
from django.views.generic import TemplateView
|
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
|
from events.models import Attendance, Event, Lineup, LineupSlot, LineupUnit
|
||||||
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.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 Team, TeamMembership
|
from teams.models import Team, TeamMembership
|
||||||
@@ -44,10 +46,9 @@ class CoachTodayView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
|
|||||||
coach's own obligations, not the whole roster's).
|
coach's own obligations, not the whole roster's).
|
||||||
|
|
||||||
"Needs you" is scoped down from the design mock to what has real backing
|
"Needs you" is scoped down from the design mock to what has real backing
|
||||||
data today: a silent-players count for the next session. The mock's
|
data today: a silent-players count and (for a game) an unpublished-
|
||||||
line-up-not-published row and member-blocker row are deferred -- no
|
line-up flag for the next session. The mock's member-blocker row stays
|
||||||
Lineup model or coach-facing member-edit screen exists yet for either to
|
deferred -- no coach-facing member-edit screen exists yet to link to.
|
||||||
link to (see the coach-mode implementation plan's later stages).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
template_name = "mobile/coach/today.html"
|
template_name = "mobile/coach/today.html"
|
||||||
@@ -80,6 +81,10 @@ class CoachTodayView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
|
|||||||
silent_count = attendances.filter(status=Attendance.AttendanceStatus.NO_RESPONSE).count()
|
silent_count = attendances.filter(status=Attendance.AttendanceStatus.NO_RESPONSE).count()
|
||||||
if silent_count > 0:
|
if silent_count > 0:
|
||||||
needs_you.append({"severity": "warn", "title": _("Silent players"), "detail": _("%(count)d haven't answered yet") % {"count": silent_count}})
|
needs_you.append({"severity": "warn", "title": _("Silent players"), "detail": _("%(count)d haven't answered yet") % {"count": silent_count}})
|
||||||
|
if session_event.kind == Event.EventKind.GAME:
|
||||||
|
lineup = Lineup.objects.filter(event=session_event).first()
|
||||||
|
if lineup is None or lineup.published_at is None:
|
||||||
|
needs_you.append({"severity": "club", "title": _("Line-up not published"), "detail": _("Build it before the game.")})
|
||||||
|
|
||||||
hero_attendance = None
|
hero_attendance = None
|
||||||
rsvp_closed = False
|
rsvp_closed = False
|
||||||
@@ -419,3 +424,114 @@ class CoachAddPlayerView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
|
|||||||
body = ngettext("%(count)d player added to the roster.", "%(count)d players added to the roster.", added) % {"count": added}
|
body = ngettext("%(count)d player added to the roster.", "%(count)d players added to the roster.", added) % {"count": added}
|
||||||
notify(request, f"s|{_('Roster updated')}|{body}")
|
notify(request, f"s|{_('Roster updated')}|{body}")
|
||||||
return HttpResponseRedirect(reverse("mobile:coach_today"))
|
return HttpResponseRedirect(reverse("mobile:coach_today"))
|
||||||
|
|
||||||
|
|
||||||
|
class CoachLineupView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
|
||||||
|
"""C3 -- a game's line-up: units (Line 1, Defence pair 1, ...) of ordered
|
||||||
|
slots, each assigned via a native <select> rather than the design mock's
|
||||||
|
drag-and-drop (see events.services.lineup's own module docstring for
|
||||||
|
why). One batch "Save line-up" submit, not a live per-tap POST -- this
|
||||||
|
codebase has no established htmx interaction pattern yet to build one on
|
||||||
|
(htmx.js is loaded but nothing uses it), and a reliable plain form beats
|
||||||
|
a first, unproven real-time interaction for a screen already this large.
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
can_manage_active_team, hidden in the template and 403'd here regardless.
|
||||||
|
"""
|
||||||
|
|
||||||
|
template_name = "mobile/coach/lineup.html"
|
||||||
|
screen_title = _("Line-up")
|
||||||
|
active_tab = "coach_today"
|
||||||
|
|
||||||
|
def get_event(self):
|
||||||
|
if self.active_team is None:
|
||||||
|
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)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
event = self.get_event()
|
||||||
|
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"))
|
||||||
|
|
||||||
|
return super().get_context_data(
|
||||||
|
event=event,
|
||||||
|
lineup=lineup,
|
||||||
|
units=units,
|
||||||
|
available=available,
|
||||||
|
unavailable=unavailable,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
if not self.can_manage_active_team:
|
||||||
|
return HttpResponseForbidden()
|
||||||
|
|
||||||
|
event = self.get_event()
|
||||||
|
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)}
|
||||||
|
|
||||||
|
for slot in LineupSlot.objects.filter(unit__lineup=lineup):
|
||||||
|
submitted = request.POST.get(f"slot_{slot.pk}", "")
|
||||||
|
if submitted == str(slot.member_id or ""):
|
||||||
|
continue
|
||||||
|
if not submitted:
|
||||||
|
clear_slot(slot)
|
||||||
|
elif submitted in available_ids:
|
||||||
|
place_member(lineup, slot, Member.objects.get(pk=submitted))
|
||||||
|
|
||||||
|
notify(request, f"s|{_('Line-up saved')}|{_('Your changes have been saved.')}")
|
||||||
|
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):
|
||||||
|
"""Writes the line-up into the game record and notifies the selected
|
||||||
|
players -- events.services.lineup.publish_lineup does the actual work."""
|
||||||
|
|
||||||
|
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 = get_object_or_404(Lineup, event=event)
|
||||||
|
publish_lineup(lineup)
|
||||||
|
notify(request, f"s|{_('Line-up published')}|{_('Selected players have been notified.')}")
|
||||||
|
return HttpResponseRedirect(reverse("mobile:coach_lineup", kwargs={"event_id": event.pk}))
|
||||||
|
|||||||
75
mobile/templates/mobile/coach/lineup.html
Normal file
75
mobile/templates/mobile/coach/lineup.html
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{% extends "mobile/coach/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
C3 -- design_handoff_rosterchief_platform/README.md's C3 section: units
|
||||||
|
(Line 1, Defence pair 1, ...) of ordered slots, each filled via a native
|
||||||
|
<select> rather than the mock's drag-and-drop -- see
|
||||||
|
CoachLineupView's own docstring for why, and for the known "+ Add line"/
|
||||||
|
"+ Add slot" rough edge (they don't save the other slots' picks first).
|
||||||
|
The mock's "fully dark screen" is approximated with dark cards
|
||||||
|
throughout rather than overriding the shared .coach-sheet's own light
|
||||||
|
background -- a real per-screen shell hook is more infrastructure than
|
||||||
|
one screen justifies.
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
|
{% block header_extra %}
|
||||||
|
<div class="mt-3 flex items-center justify-between">
|
||||||
|
<span class="font-display text-xl leading-none font-extrabold text-white uppercase">{% trans "Line-up" %}</span>
|
||||||
|
{% if lineup.published_at %}<span class="pill pill-info">{% trans "Published" %}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-on-dark-dim">{{ event.title }} · {{ event.start|date:"D d M H:i" }}</div>
|
||||||
|
{% endblock header_extra %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<form method="post" action="{% url "mobile:coach_lineup" event.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
{% for unit in units %}
|
||||||
|
<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="flex flex-col gap-2">
|
||||||
|
{% for slot in unit.slots.all %}
|
||||||
|
<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 %}>
|
||||||
|
<option value="">{% trans "Empty" %}</option>
|
||||||
|
{% for attendance in available %}
|
||||||
|
<option value="{{ attendance.member.pk }}" {% if slot.member_id == attendance.member.pk %}selected{% endif %}>{{ attendance.member.get_full_name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% endfor %}
|
||||||
|
</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>
|
||||||
|
{% empty %}
|
||||||
|
<div class="m-card-dark p-6 text-center text-sm text-on-dark-dim">{% trans "No lines yet." %}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if can_manage_active_team %}
|
||||||
|
<div class="mt-3 flex gap-2">
|
||||||
|
<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 %}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if unavailable %}
|
||||||
|
<div class="mt-1">
|
||||||
|
<div class="mb-2 font-display text-xs font-extrabold tracking-wide text-on-dark-dim uppercase">{% trans "Unavailable" %}</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{% for attendance in unavailable %}
|
||||||
|
<span class="rounded-full bg-steel px-3 py-1.5 text-xs text-on-dark-faint opacity-50">{{ attendance.member.get_full_name }} · {{ attendance.get_status_display }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if can_manage_active_team and not lineup.published_at %}
|
||||||
|
<form class="mt-2" method="post" action="{% url "mobile:coach_lineup_publish" event.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button class="btn w-full bg-ice text-ice-ink" type="submit">{% trans "Publish" %}</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock content %}
|
||||||
@@ -47,7 +47,12 @@
|
|||||||
<div class="px-4 pb-4">
|
<div class="px-4 pb-4">
|
||||||
<p class="font-display text-xl leading-none font-extrabold uppercase">{{ tonight_event.title }}</p>
|
<p class="font-display text-xl leading-none font-extrabold uppercase">{{ tonight_event.title }}</p>
|
||||||
{% if can_manage_active_team %}
|
{% if can_manage_active_team %}
|
||||||
<a class="btn mt-3 w-full bg-ice text-ice-ink" href="{% url "mobile:coach_attendance" tonight_event.pk %}">{% trans "Check attendance" %}</a>
|
<div class="mt-3 flex gap-2">
|
||||||
|
<a class="btn flex-1 bg-ice text-ice-ink" href="{% url "mobile:coach_attendance" tonight_event.pk %}">{% trans "Check attendance" %}</a>
|
||||||
|
{% if tonight_event.kind == "game" %}
|
||||||
|
<a class="btn flex-1 bg-steel text-on-dark" href="{% url "mobile:coach_lineup" tonight_event.pk %}">{% trans "Line-up" %}</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,6 +69,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{% if item.severity == "warn" and session_event and can_manage_active_team %}
|
{% if item.severity == "warn" and session_event and can_manage_active_team %}
|
||||||
<a class="shrink-0 font-display text-xs font-extrabold tracking-wide text-club uppercase" href="{% url "mobile:coach_attendance" session_event.pk %}">{% trans "Review" %}</a>
|
<a class="shrink-0 font-display text-xs font-extrabold tracking-wide text-club uppercase" href="{% url "mobile:coach_attendance" session_event.pk %}">{% trans "Review" %}</a>
|
||||||
|
{% elif item.severity == "club" and session_event and can_manage_active_team %}
|
||||||
|
<a class="shrink-0 font-display text-xs font-extrabold tracking-wide text-club uppercase" href="{% url "mobile:coach_lineup" session_event.pk %}">{% trans "Build" %}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
180
mobile/tests.py
180
mobile/tests.py
@@ -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
|
from events.models import Attendance, Event, Lineup, LineupSlot, LineupUnit
|
||||||
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
|
||||||
@@ -1852,6 +1852,25 @@ class CoachTodayViewTests(TestCase):
|
|||||||
self.assertContains(response, "Also yours")
|
self.assertContains(response, "Also yours")
|
||||||
self.assertContains(response, "My own game")
|
self.assertContains(response, "My own game")
|
||||||
|
|
||||||
|
def test_unpublished_lineup_shows_in_needs_you_for_a_game_session(self):
|
||||||
|
event = Event.objects.create(club=self.club, title="Big game", kind=Event.EventKind.GAME, start=timezone.now() + datetime.timedelta(minutes=5))
|
||||||
|
event.teams.add(self.team)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertContains(response, "Line-up not published")
|
||||||
|
|
||||||
|
def test_published_lineup_does_not_show_in_needs_you(self):
|
||||||
|
event = Event.objects.create(club=self.club, title="Big game", kind=Event.EventKind.GAME, start=timezone.now() + datetime.timedelta(minutes=5))
|
||||||
|
event.teams.add(self.team)
|
||||||
|
Lineup.objects.create(event=event, team=self.team, published_at=timezone.now())
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertNotContains(response, "Line-up not published")
|
||||||
|
|
||||||
|
|
||||||
@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 CoachAttendanceViewTests(TestCase):
|
class CoachAttendanceViewTests(TestCase):
|
||||||
@@ -2242,3 +2261,162 @@ class CoachAddPlayerViewTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
self.assertFalse(TeamMembership.objects.filter(team=self.team, member=candidate).exists())
|
self.assertFalse(TeamMembership.objects.filter(team=self.team, member=candidate).exists())
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
|
||||||
|
class CoachLineupViewTests(TestCase):
|
||||||
|
"""C3 -- design_handoff_rosterchief_platform/README.md's C3 section; see
|
||||||
|
CoachLineupView's own docstring for the tap-to-place -> native-select
|
||||||
|
simplification and its known rough edges."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.club = make_club()
|
||||||
|
today = timezone.localdate()
|
||||||
|
cls.season = Season.objects.create(club=cls.club, start_date=today - datetime.timedelta(days=30), end_date=today + datetime.timedelta(days=300))
|
||||||
|
cls.user = User.objects.create_user(email="coach@example.com", password="pw-secret-123")
|
||||||
|
cls.member = Member.objects.create(first_name="Sam", last_name="Coach", email="coach@example.com", user=cls.user)
|
||||||
|
cls.team = Team.objects.create(club=cls.club, name="U16", short_name="U16")
|
||||||
|
cls.position = Position.objects.create(club=cls.club, name="Head coach", short_name="HC", staff_position=True, management_position=True)
|
||||||
|
StaffAssignment.objects.create(team=cls.team, member=cls.member, season=cls.season, position=cls.position)
|
||||||
|
|
||||||
|
cls.player = Member.objects.create(first_name="Anna", last_name="Player")
|
||||||
|
TeamMembership.objects.create(team=cls.team, member=cls.player, season=cls.season)
|
||||||
|
cls.event = Event.objects.create(club=cls.club, title="Big game", kind=Event.EventKind.GAME, start=timezone.now() + datetime.timedelta(days=2))
|
||||||
|
cls.event.teams.add(cls.team)
|
||||||
|
Attendance.objects.update_or_create(event=cls.event, member=cls.player, defaults={"status": Attendance.AttendanceStatus.PRESENT})
|
||||||
|
|
||||||
|
def make_physio(self):
|
||||||
|
physio_position = Position.objects.create(club=self.club, name="Physio", short_name="PHY", staff_position=True, management_position=False)
|
||||||
|
physio_user = User.objects.create_user(email="physio@example.com", password="pw-secret-123")
|
||||||
|
physio_member = Member.objects.create(first_name="Pat", last_name="Physio", user=physio_user)
|
||||||
|
StaffAssignment.objects.create(team=self.team, member=physio_member, season=self.season, position=physio_position)
|
||||||
|
return physio_user
|
||||||
|
|
||||||
|
def test_requires_login(self):
|
||||||
|
response = self.client.get(reverse("mobile:coach_lineup", kwargs={"event_id": self.event.pk}), HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
def test_get_creates_a_lineup_lazily(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("mobile:coach_lineup", kwargs={"event_id": self.event.pk}), HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(Lineup.objects.filter(event=self.event).exists())
|
||||||
|
|
||||||
|
def test_a_non_game_event_is_not_reachable(self):
|
||||||
|
practice = Event.objects.create(club=self.club, title="Practice", kind=Event.EventKind.TRAINING, start=timezone.now() + datetime.timedelta(days=2))
|
||||||
|
practice.teams.add(self.team)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("mobile:coach_lineup", kwargs={"event_id": practice.pk}), HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_add_line_creates_a_unit_with_one_slot(self):
|
||||||
|
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")
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("mobile:coach_lineup", kwargs={"event_id": self.event.pk}), fetch_redirect_response=False)
|
||||||
|
lineup = Lineup.objects.get(event=self.event)
|
||||||
|
self.assertEqual(lineup.units.count(), 1)
|
||||||
|
self.assertEqual(lineup.units.first().slots.count(), 1)
|
||||||
|
|
||||||
|
def test_add_slot_grows_an_existing_unit(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")
|
||||||
|
|
||||||
|
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(
|
||||||
|
reverse("mobile:coach_lineup", kwargs={"event_id": self.event.pk}),
|
||||||
|
{f"slot_{slot.pk}": str(self.player.pk)},
|
||||||
|
HTTP_HOST="ajax-united.rosterchief.app",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("mobile:coach_lineup", kwargs={"event_id": self.event.pk}), fetch_redirect_response=False)
|
||||||
|
slot.refresh_from_db()
|
||||||
|
self.assertEqual(slot.member, self.player)
|
||||||
|
|
||||||
|
def test_save_clears_a_slot_when_empty_is_submitted(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, member=self.player)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
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)
|
||||||
|
slot.refresh_from_db()
|
||||||
|
self.assertIsNone(slot.member)
|
||||||
|
|
||||||
|
def test_save_ignores_a_member_id_outside_the_available_pool(self):
|
||||||
|
outsider = Member.objects.create(first_name="Not", last_name="Available")
|
||||||
|
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)
|
||||||
|
|
||||||
|
self.client.post(
|
||||||
|
reverse("mobile:coach_lineup", kwargs={"event_id": self.event.pk}),
|
||||||
|
{f"slot_{slot.pk}": str(outsider.pk)},
|
||||||
|
HTTP_HOST="ajax-united.rosterchief.app",
|
||||||
|
)
|
||||||
|
|
||||||
|
slot.refresh_from_db()
|
||||||
|
self.assertIsNone(slot.member)
|
||||||
|
|
||||||
|
def test_publish_marks_the_lineup_published(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
lineup = Lineup.objects.create(event=self.event, team=self.team)
|
||||||
|
|
||||||
|
response = self.client.post(reverse("mobile:coach_lineup_publish", 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)
|
||||||
|
lineup.refresh_from_db()
|
||||||
|
self.assertIsNotNone(lineup.published_at)
|
||||||
|
|
||||||
|
def test_non_managing_staff_sees_a_read_only_view(self):
|
||||||
|
physio_user = self.make_physio()
|
||||||
|
self.client.force_login(physio_user)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("mobile:coach_lineup", kwargs={"event_id": self.event.pk}), HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertNotContains(response, "Save line-up")
|
||||||
|
|
||||||
|
def test_non_managing_staff_cannot_save(self):
|
||||||
|
physio_user = self.make_physio()
|
||||||
|
self.client.force_login(physio_user)
|
||||||
|
|
||||||
|
response = self.client.post(reverse("mobile:coach_lineup", kwargs={"event_id": self.event.pk}), {}, HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_non_managing_staff_cannot_publish(self):
|
||||||
|
physio_user = self.make_physio()
|
||||||
|
lineup = Lineup.objects.create(event=self.event, team=self.team)
|
||||||
|
self.client.force_login(physio_user)
|
||||||
|
|
||||||
|
response = self.client.post(reverse("mobile:coach_lineup_publish", kwargs={"event_id": self.event.pk}), HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
lineup.refresh_from_db()
|
||||||
|
self.assertIsNone(lineup.published_at)
|
||||||
|
|||||||
@@ -28,4 +28,8 @@ urlpatterns = [
|
|||||||
path("coach/events/new/", coach_views.CoachCreateEventView.as_view(), name="coach_create_event"),
|
path("coach/events/new/", coach_views.CoachCreateEventView.as_view(), name="coach_create_event"),
|
||||||
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>/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"),
|
||||||
]
|
]
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user