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:
@@ -2,7 +2,7 @@ from django import forms
|
||||
from django.contrib import admin
|
||||
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)
|
||||
@@ -118,18 +118,11 @@ class LineupAdmin(admin.ModelAdmin):
|
||||
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(LineupSelection)
|
||||
class LineupSelectionAdmin(admin.ModelAdmin):
|
||||
list_display = ["lineup", "member"]
|
||||
search_fields = ["lineup__event__title", "member__first_name", "member__last_name"]
|
||||
raw_id_fields = ["lineup", "member"]
|
||||
|
||||
|
||||
@admin.register(EventReferee)
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -200,12 +200,13 @@ class Attendance(UUIDModel):
|
||||
|
||||
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."""
|
||||
CoachLineupView): a plain yes/no pick per roster player (``selections``),
|
||||
not lines/slots -- a coach doesn't need to think in terms of who's on
|
||||
which line, just who's in. Publishing (events.services.lineup.
|
||||
publish_lineup) flips every selected 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"))
|
||||
@@ -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",))
|
||||
|
||||
|
||||
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."""
|
||||
class LineupSelection(UUIDModel):
|
||||
"""One roster player the coach has marked "in" for a Lineup -- presence
|
||||
of the row *is* the yes; there's no separate flag and no "no" row.
|
||||
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"))
|
||||
label = models.CharField(_("label"), max_length=100)
|
||||
ordering = models.PositiveSmallIntegerField(_("ordering"), default=0)
|
||||
lineup = models.ForeignKey(Lineup, on_delete=models.CASCADE, related_name="selections", verbose_name=_("line-up"))
|
||||
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="lineup_selections", verbose_name=_("member"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("line-up unit")
|
||||
verbose_name_plural = _("line-up units")
|
||||
ordering = ["ordering"]
|
||||
verbose_name = _("line-up selection")
|
||||
verbose_name_plural = _("line-up selections")
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["lineup", "member"], name="unique_selection_per_lineup_per_member"),
|
||||
]
|
||||
|
||||
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'}"
|
||||
return f"{self.lineup} - {self.member}"
|
||||
|
||||
|
||||
class EventReferee(UUIDModel):
|
||||
|
||||
@@ -1,53 +1,46 @@
|
||||
"""Placing players into a Lineup's slots and publishing it -- coach mode's C3
|
||||
screen (mobile/coach_views.py's CoachLineupView/CoachLineupPlaceView). Mirrors
|
||||
events.services.attendance's shape: small, focused functions the view writes
|
||||
through rather than touching the models directly.
|
||||
"""Picking players for a Lineup (plain yes/no, no lines/slots) and publishing
|
||||
it -- coach mode's C3 screen (mobile/coach_views.py's CoachLineupView).
|
||||
Mirrors events.services.attendance's shape: small, focused functions the
|
||||
view writes through rather than touching the models directly.
|
||||
"""
|
||||
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from club.services.access import event_season
|
||||
from events.models import Attendance, LineupSlot
|
||||
from events.models import Attendance, LineupSelection
|
||||
from members.models import Member
|
||||
from notifications.services import notify_members
|
||||
from teams.models import StaffAssignment
|
||||
from teams.models import StaffAssignment, TeamMembership
|
||||
|
||||
#: Attendance statuses that mean "not actually available" -- these members
|
||||
#: stay exactly as they are on publish, never flipped to NOT_SELECTED, and
|
||||
#: aren't offered as placeable in the available-players pool.
|
||||
#: aren't offered as pickable for the line-up.
|
||||
UNAVAILABLE_STATUSES = [Attendance.AttendanceStatus.ABSENT, Attendance.AttendanceStatus.EXCUSED, Attendance.AttendanceStatus.NO_RESPONSE]
|
||||
|
||||
|
||||
def place_member(lineup, slot, member):
|
||||
"""Tap-to-place: ``member`` moves into ``slot``, vacating any other slot
|
||||
of theirs in the same ``lineup`` first (a member is only ever in one slot
|
||||
at a time). Whoever was already in ``slot``, if anyone, is simply bumped
|
||||
back to the available pool -- the tap equivalent of the mock's "slots
|
||||
accept one player and swap on drop", without true drag-and-drop's two-way
|
||||
swap (see LineupSlot's own docstring for why tap-to-place instead of
|
||||
drag-and-drop at all)."""
|
||||
LineupSlot.objects.filter(unit__lineup=lineup, member=member).exclude(pk=slot.pk).update(member=None)
|
||||
slot.member = member
|
||||
slot.save(update_fields=["member"])
|
||||
|
||||
|
||||
def clear_slot(slot):
|
||||
slot.member = None
|
||||
slot.save(update_fields=["member"])
|
||||
def toggle_selection(lineup, member) -> bool:
|
||||
"""Flips ``member``'s yes/no pick for ``lineup`` -- deletes their row if
|
||||
they were in, creates one if they weren't. Returns the new state (True =
|
||||
now selected)."""
|
||||
deleted, _details = LineupSelection.objects.filter(lineup=lineup, member=member).delete()
|
||||
if deleted:
|
||||
return False
|
||||
LineupSelection.objects.create(lineup=lineup, member=member)
|
||||
return True
|
||||
|
||||
|
||||
def publish_lineup(lineup):
|
||||
"""Marks the lineup published and writes it into the game record via
|
||||
Attendance -- the reason AttendanceStatus.SELECTED/NOT_SELECTED exist,
|
||||
previously unused anywhere. Every slotted member becomes SELECTED; every
|
||||
previously unused anywhere. Every selected member becomes SELECTED; every
|
||||
other member with an Attendance row for this event who was actually
|
||||
available (not out/silent, see UNAVAILABLE_STATUSES) becomes
|
||||
NOT_SELECTED. Notifies only the selected players."""
|
||||
lineup.published_at = timezone.now()
|
||||
lineup.save(update_fields=["published_at"])
|
||||
|
||||
selected_member_ids = set(LineupSlot.objects.filter(unit__lineup=lineup, member__isnull=False).values_list("member_id", flat=True))
|
||||
selected_member_ids = set(LineupSelection.objects.filter(lineup=lineup).values_list("member_id", flat=True))
|
||||
|
||||
Attendance.objects.filter(event=lineup.event, member_id__in=selected_member_ids).update(status=Attendance.AttendanceStatus.SELECTED)
|
||||
Attendance.objects.filter(event=lineup.event).exclude(member_id__in=selected_member_ids).exclude(status__in=UNAVAILABLE_STATUSES).update(status=Attendance.AttendanceStatus.NOT_SELECTED)
|
||||
@@ -60,6 +53,36 @@ def publish_lineup(lineup):
|
||||
return lineup
|
||||
|
||||
|
||||
def selected_members_by_position(lineup):
|
||||
"""Every LineupSelection for ``lineup``, grouped by the member's roster
|
||||
position for the lineup's own team/season ("category") -- the read side
|
||||
behind the published, member-facing Line-up card (mobile/views.py's
|
||||
EventDetailView). A selected member with no TeamMembership on this team/
|
||||
season (a guest call-up) lands in a catch-all "No position set" bucket
|
||||
rather than being dropped -- mirrors CoachLineupView's own grouping."""
|
||||
season = event_season(lineup.event)
|
||||
member_ids = list(LineupSelection.objects.filter(lineup=lineup).values_list("member_id", flat=True))
|
||||
memberships_by_member = {}
|
||||
if season is not None:
|
||||
memberships_by_member = {tm.member_id: tm for tm in TeamMembership.objects.filter(team=lineup.team, season=season, member_id__in=member_ids).select_related("position")}
|
||||
members_by_id = {member.pk: member for member in Member.objects.filter(pk__in=member_ids)}
|
||||
|
||||
buckets = {}
|
||||
for member_id in member_ids:
|
||||
member = members_by_id.get(member_id)
|
||||
if member is None:
|
||||
continue
|
||||
position = memberships_by_member[member_id].position if member_id in memberships_by_member else None
|
||||
key = position.pk if position else None
|
||||
bucket = buckets.setdefault(key, {"label": position.name if position else _("No position set"), "ordering": position.ordering if position else 9999, "members": []})
|
||||
bucket["members"].append(member)
|
||||
|
||||
categories = sorted(buckets.values(), key=lambda bucket: (bucket["ordering"], bucket["label"]))
|
||||
for bucket in categories:
|
||||
bucket["members"].sort(key=lambda member: (member.last_name, member.first_name))
|
||||
return categories
|
||||
|
||||
|
||||
def notify_dropout(event, member, note):
|
||||
"""A player who was SELECTED in a published line-up reporting, after the
|
||||
fact, that they can no longer make it (mobile.views.EventDetailView.post's
|
||||
|
||||
106
events/tests.py
106
events/tests.py
@@ -19,7 +19,7 @@ from notifications.models import Notification
|
||||
from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership
|
||||
|
||||
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 (
|
||||
cancel_occurrence,
|
||||
detach_occurrence,
|
||||
@@ -34,7 +34,7 @@ from .services import (
|
||||
team_no_shows,
|
||||
)
|
||||
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.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
|
||||
@@ -373,71 +373,46 @@ class AttendanceSyncTests(EventsTestBase):
|
||||
|
||||
|
||||
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)."""
|
||||
"""events.services.lineup -- toggle_selection/publish_lineup/
|
||||
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):
|
||||
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
|
||||
return event, lineup
|
||||
|
||||
def test_place_member_fills_the_slot(self):
|
||||
_event, lineup, _unit, slot = self.make_game_with_lineup()
|
||||
def test_toggle_selection_selects_an_unselected_member(self):
|
||||
_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.assertEqual(slot.member, self.alice)
|
||||
self.assertTrue(now_selected)
|
||||
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):
|
||||
_event, lineup, unit, slot = self.make_game_with_lineup()
|
||||
other_slot = LineupSlot.objects.create(unit=unit, ordering=1, member=self.alice)
|
||||
def test_toggle_selection_deselects_a_selected_member(self):
|
||||
_event, lineup = self.make_game_with_lineup()
|
||||
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.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)
|
||||
self.assertFalse(now_selected)
|
||||
self.assertFalse(LineupSelection.objects.filter(lineup=lineup, member=self.alice).exists())
|
||||
|
||||
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)
|
||||
|
||||
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()
|
||||
def test_publish_selects_picked_members_and_not_selects_the_rest(self):
|
||||
event, lineup = self.make_game_with_lineup()
|
||||
LineupSelection.objects.create(lineup=lineup, member=self.alice)
|
||||
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})
|
||||
|
||||
@@ -447,17 +422,42 @@ class LineupServiceTests(EventsTestBase):
|
||||
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()
|
||||
event, lineup = 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_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):
|
||||
event, lineup, _unit, slot = self.make_game_with_lineup()
|
||||
slot.member = self.alice
|
||||
slot.save()
|
||||
event, lineup = self.make_game_with_lineup()
|
||||
LineupSelection.objects.create(lineup=lineup, member=self.alice)
|
||||
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})
|
||||
|
||||
@@ -467,7 +467,7 @@ class LineupServiceTests(EventsTestBase):
|
||||
self.assertEqual(notified_member_ids, {self.alice.pk})
|
||||
|
||||
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")
|
||||
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)
|
||||
@@ -479,7 +479,7 @@ class LineupServiceTests(EventsTestBase):
|
||||
self.assertIn("Twisted an ankle", notification.body)
|
||||
|
||||
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_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)
|
||||
|
||||
Reference in New Issue
Block a user