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:
@@ -18,12 +18,11 @@ from django.views.generic import TemplateView, View
|
||||
from club.models import Season
|
||||
from club.services.access import can_add_news, current_season
|
||||
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.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 management.forms import EventForm, NewsForm
|
||||
from members.models import Member
|
||||
from news.models import News
|
||||
from news.services import notify_editors_of_pending_review
|
||||
from teams.models import StaffAssignment, Team, TeamMembership
|
||||
@@ -427,24 +426,18 @@ class CoachAddPlayerView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
|
||||
|
||||
|
||||
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.
|
||||
"""C3 -- a game's line-up, kept deliberately simple: a plain yes/no pick
|
||||
per available roster player, grouped by their roster position ("category")
|
||||
so the coach reads it the same way the roster itself is grouped -- no
|
||||
lines, no slots, no drag-and-drop (an earlier build had units/slots with
|
||||
tap-to-place; replaced because it was harder to read than it needed to
|
||||
be for what's really just a selection call). One batch "Save line-up"
|
||||
submit, not a live per-tap POST -- same reasoning as coach/attendance.html.
|
||||
|
||||
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.
|
||||
team; saving/publishing is gated on can_manage_active_team, hidden in the
|
||||
template and 403'd here regardless.
|
||||
"""
|
||||
|
||||
template_name = "mobile/coach/lineup.html"
|
||||
@@ -456,18 +449,39 @@ class CoachLineupView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
|
||||
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 _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):
|
||||
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,
|
||||
categories=self._categories(event, lineup),
|
||||
unavailable=unavailable,
|
||||
**kwargs,
|
||||
)
|
||||
@@ -478,50 +492,23 @@ class CoachLineupView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
|
||||
|
||||
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)}
|
||||
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):
|
||||
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))
|
||||
selected_count = 0
|
||||
for attendance in available:
|
||||
wants_selected = request.POST.get(f"selected_{attendance.member_id}") == "true"
|
||||
if wants_selected != (attendance.member_id in selected_ids):
|
||||
toggle_selection(lineup, attendance.member)
|
||||
if wants_selected:
|
||||
selected_count += 1
|
||||
|
||||
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}))
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
@@ -6,19 +6,20 @@
|
||||
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
|
||||
one (see mobile.views._notification_source_link and
|
||||
NotificationsView.post). A club-coloured bar marks it unread, same
|
||||
treatment as management/templates/management/home.html's own
|
||||
notifications card.
|
||||
NotificationsView.post). A club-coloured bar marks it unread -- full
|
||||
height via self-stretch, same technique as mobile/_calendar_row.html's
|
||||
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 %}
|
||||
<form method="post" action="{% url "mobile:notifications" %}" hx-boost="false">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="mark_read">
|
||||
<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">
|
||||
<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="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 %}
|
||||
</span>
|
||||
<span class="shrink-0 font-mono text-[11px] text-dim">{% blocktrans with time=row.notification.created|timesince %}{{ time }} ago{% endblocktrans %}</span>
|
||||
|
||||
@@ -2,21 +2,20 @@
|
||||
{% 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.
|
||||
C3 -- design_handoff_rosterchief_platform/README.md's C3 section, kept
|
||||
deliberately simple per product feedback: a plain yes/no toggle per
|
||||
available roster player, grouped by position ("category") rather than
|
||||
the mock's lines/slots -- see CoachLineupView's own docstring for why an
|
||||
earlier tap-to-place build was replaced. 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.
|
||||
|
||||
Both forms are hx-boost="false" -- the first has three submit buttons
|
||||
sharing one <form> via formaction overrides (Save/+Add slot/+Add line),
|
||||
and htmx's boost reads the form's own action rather than the actual
|
||||
submitter's formaction override, so a boosted click would always post
|
||||
to the wrong endpoint. A plain navigation sidesteps that entirely.
|
||||
The one form is hx-boost="false", same reasoning as every other
|
||||
write-action form in this app (see mobile/templates/mobile/event_detail.
|
||||
html's own top-of-file comment) -- each row's Yes/No pair is
|
||||
Alpine-owned (x-data toggling a hidden input), and this codebase hasn't
|
||||
established an htmx interaction pattern to layer on top of that safely.
|
||||
{% endcomment %}
|
||||
|
||||
{% block header_extra %}
|
||||
@@ -31,33 +30,36 @@
|
||||
<form method="post" action="{% url "mobile:coach_lineup" event.pk %}" hx-boost="false">
|
||||
{% csrf_token %}
|
||||
<div class="flex flex-col gap-3">
|
||||
{% for unit in units %}
|
||||
{% for category in categories %}
|
||||
<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">
|
||||
{% 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>
|
||||
{% for row in category.rows %}
|
||||
<div class="flex items-center gap-3" {% if can_manage_active_team %}x-data="{ state: '{{ row.selected|yesno:'true,false' }}' }"{% endif %}>
|
||||
<div class="min-w-0 flex-1 text-sm text-white">
|
||||
{{ row.member.get_full_name }}
|
||||
{% if row.membership.jersey_number %}<span class="text-on-dark-dim">· #{{ row.membership.jersey_number }}</span>{% endif %}
|
||||
</div>
|
||||
{% 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 %}
|
||||
</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>
|
||||
<div class="m-card-dark p-6 text-center text-sm text-on-dark-dim">{% trans "No available players to pick from." %}</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>
|
||||
<button class="btn btn-dark mt-3 w-full" type="submit">{% trans "Save line-up" %}</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
|
||||
@@ -65,13 +65,11 @@
|
||||
<span class="pill pill-info">{% trans "Published" %}</span>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-col gap-3">
|
||||
{% for unit in lineup.units.all %}
|
||||
{% for category in lineup_categories %}
|
||||
<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">
|
||||
{% for slot in unit.slots.all %}
|
||||
{% if slot.member %}<span class="pill pill-neutral">{{ slot.member.get_full_name }}</span>{% endif %}
|
||||
{% endfor %}
|
||||
{% for member in category.members %}<span class="pill pill-neutral">{{ member.get_full_name }}</span>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
@@ -9,7 +9,7 @@ from django.utils import timezone, translation
|
||||
from icalendar import Calendar as ICalCalendar
|
||||
|
||||
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 news.models import News
|
||||
from notifications.models import Notification
|
||||
@@ -1113,24 +1113,23 @@ class EventDetailScreenTests(TestCase):
|
||||
|
||||
def test_unpublished_lineup_is_not_shown(self):
|
||||
lineup = Lineup.objects.create(event=self.event, team=self.team)
|
||||
unit = LineupUnit.objects.create(lineup=lineup, label="Line 1")
|
||||
LineupSlot.objects.create(unit=unit, member=self.member)
|
||||
LineupSelection.objects.create(lineup=lineup, member=self.member)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self._get()
|
||||
|
||||
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())
|
||||
unit = LineupUnit.objects.create(lineup=lineup, label="Line 1")
|
||||
LineupSlot.objects.create(unit=unit, member=self.member)
|
||||
LineupSelection.objects.create(lineup=lineup, member=self.member)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self._get()
|
||||
|
||||
self.assertEqual(response.context["lineup"], lineup)
|
||||
self.assertContains(response, "Line 1")
|
||||
self.assertContains(response, "Forward")
|
||||
self.assertContains(response, self.member.get_full_name())
|
||||
|
||||
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 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):
|
||||
stranger = Member.objects.create(first_name="Someone", last_name="Else")
|
||||
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"])
|
||||
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."""
|
||||
CoachLineupView's own docstring for the plain yes/no-per-player design
|
||||
(no lines/slots)."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -2798,73 +2806,52 @@ class CoachLineupViewTests(TestCase):
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
lineup = Lineup.objects.get(event=self.event)
|
||||
self.assertEqual(lineup.units.count(), 1)
|
||||
self.assertEqual(lineup.units.first().slots.count(), 1)
|
||||
categories = response.context["categories"]
|
||||
self.assertEqual(len(categories), 1)
|
||||
self.assertEqual(categories[0]["label"], "No position set")
|
||||
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)
|
||||
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)},
|
||||
{f"selected_{self.player.pk}": "true"},
|
||||
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)
|
||||
self.assertTrue(LineupSelection.objects.filter(lineup=lineup, member=self.player).exists())
|
||||
|
||||
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)
|
||||
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)
|
||||
LineupSelection.objects.create(lineup=lineup, 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",
|
||||
)
|
||||
response = self.client.post(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)
|
||||
slot.refresh_from_db()
|
||||
self.assertIsNone(slot.member)
|
||||
self.assertFalse(LineupSelection.objects.filter(lineup=lineup, member=self.player).exists())
|
||||
|
||||
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)},
|
||||
{f"selected_{outsider.pk}": "true"},
|
||||
HTTP_HOST="ajax-united.rosterchief.app",
|
||||
)
|
||||
|
||||
slot.refresh_from_db()
|
||||
self.assertIsNone(slot.member)
|
||||
self.assertFalse(LineupSelection.objects.filter(lineup=lineup, member=outsider).exists())
|
||||
|
||||
def test_publish_marks_the_lineup_published(self):
|
||||
self.client.force_login(self.user)
|
||||
|
||||
@@ -31,7 +31,5 @@ urlpatterns = [
|
||||
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/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"),
|
||||
]
|
||||
|
||||
@@ -29,7 +29,7 @@ from club.services.sponsors import active_sponsors
|
||||
from controlpanel.messages import notify
|
||||
from events.models import Attendance, Event, Lineup
|
||||
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.views import ClubScopedPublicMixin
|
||||
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
|
||||
# in, so "Your answers" below switches to read-only and the line-up
|
||||
# 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 = []
|
||||
if self.managed_people:
|
||||
@@ -440,7 +441,7 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
||||
"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):
|
||||
status = request.POST.get("status")
|
||||
|
||||
Reference in New Issue
Block a user