Coach mode: bench-attendance filter overhaul, squad player detail, add-staff

- Attendance sheet: "All"/"Goalies" chips replaced with "Responded" (the
  new default -- present/selected/maybe) and "Silent"; goalies are just
  another player for a practice, and neither silent nor declined members
  are expected to show up, so the default view skips both.
- Today's KPI header gains an "Out" tile alongside Squad/In/Silent.
- Squad screen: each roster row now opens a per-player detail sheet with
  season attendance stats, tap-to-call buttons (the player's own phone/
  emergency phone, plus each guardian's for a child), and -- for whoever
  manages the team -- the position/jersey/captaincy edit and a remove-
  from-roster action that used to be desktop-only.
- Staff section gets its own "Add" entry point, mirroring the roster's
  bulk-add flow with a shared position picker (StaffAssignment.position
  is required, unlike a roster spot's).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 11:54:48 +02:00
parent 1365dcf18e
commit 15d890a24b
10 changed files with 675 additions and 35 deletions

View File

@@ -20,22 +20,26 @@ from club.models import Season
from club.services.access import can_add_news, current_season from club.services.access import can_add_news, current_season
from controlpanel.messages import notify from controlpanel.messages import notify
from events.models import Attendance, Event, Lineup, LineupSelection from events.models import Attendance, Event, Lineup, LineupSelection
from events.services.attendance import record_check_in from events.services.attendance import member_attendance_counts, record_check_in
from events.services.lineup import UNAVAILABLE_STATUSES, cancel_scheduled_publish, publish_lineup, schedule_lineup_publish, toggle_selection from events.services.lineup import UNAVAILABLE_STATUSES, cancel_scheduled_publish, publish_lineup, schedule_lineup_publish, toggle_selection
from events.tasks import notify_new_event from events.tasks import notify_new_event
from management.forms import EventForm, NewsForm from management.forms import EventForm, NewsForm
from news.models import News from news.models import News
from news.services import notify_editors_of_pending_review from news.services import notify_editors_of_pending_review
from teams.models import StaffAssignment, Team, TeamMembership from teams.models import Position, StaffAssignment, Team, TeamMembership
from teams.services import eligible_roster_members from teams.services import eligible_roster_members
from .coach_mixins import CoachScopeMixin from .coach_mixins import CoachScopeMixin
from .forms import _INPUT_CLASSES from .forms import _INPUT_CLASSES, CoachRosterEditForm
#: RSVP states that count as "in" for the stat tile -- present/selected are an #: RSVP states that count as "in" for the stat tile -- present/selected are an
#: explicit yes, maybe is still a lean-in rather than silence. #: explicit yes, maybe is still a lean-in rather than silence.
IN_STATUSES = [Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.SELECTED, Attendance.AttendanceStatus.MAYBE] IN_STATUSES = [Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.SELECTED, Attendance.AttendanceStatus.MAYBE]
#: An explicit no -- declined or, for a published line-up, not selected.
#: Distinct from NO_RESPONSE ("silent"), which is a non-answer rather than a no.
OUT_STATUSES = [Attendance.AttendanceStatus.ABSENT, Attendance.AttendanceStatus.EXCUSED, Attendance.AttendanceStatus.NOT_SELECTED]
class CoachTodayView(CoachScopeMixin, LoginRequiredMixin, TemplateView): class CoachTodayView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
"""C1 -- three stat tiles (Squad/In/Silent) for the active team's next """C1 -- three stat tiles (Squad/In/Silent) for the active team's next
@@ -75,6 +79,7 @@ class CoachTodayView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
session_event = None session_event = None
tonight_event = None tonight_event = None
in_count = 0 in_count = 0
out_count = 0
silent_count = 0 silent_count = 0
needs_you = [] needs_you = []
@@ -88,6 +93,7 @@ class CoachTodayView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
if session_event is not None: if session_event is not None:
attendances = Attendance.objects.filter(event=session_event) attendances = Attendance.objects.filter(event=session_event)
in_count = attendances.filter(status__in=IN_STATUSES).count() in_count = attendances.filter(status__in=IN_STATUSES).count()
out_count = attendances.filter(status__in=OUT_STATUSES).count()
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( needs_you.append(
@@ -134,6 +140,7 @@ class CoachTodayView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
session_event=session_event, session_event=session_event,
tonight_event=tonight_event, tonight_event=tonight_event,
in_count=in_count, in_count=in_count,
out_count=out_count,
silent_count=silent_count, silent_count=silent_count,
needs_you=needs_you, needs_you=needs_you,
hero_attendance=hero_attendance, hero_attendance=hero_attendance,
@@ -159,9 +166,11 @@ class CoachAttendanceView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
active_tab = "coach_today" active_tab = "coach_today"
#: ?filter= values this screen understands -- anything else (including no #: ?filter= values this screen understands -- anything else (including no
#: param) means "All". "Goalies" matches on position name rather than a #: param) means "Responded" (IN_STATUSES: present/selected/maybe), the
#: dedicated flag -- Position has no goalie-specific field to key off. #: default view. A coach doesn't need to check in someone silent or
FILTERS = {"silent", "goalies"} #: declined -- neither is expected to show up -- so those are left out of
#: the default rather than needing to be filtered away each time.
FILTERS = {"silent"}
def get_event(self): def get_event(self):
if self.active_team is None: if self.active_team is None:
@@ -186,15 +195,14 @@ class CoachAttendanceView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
filter_param = "" filter_param = ""
if filter_param == "silent": if filter_param == "silent":
rows = [row for row in attendances if row.is_silent] rows = [row for row in attendances if row.is_silent]
elif filter_param == "goalies":
rows = [row for row in attendances if row.membership and row.membership.position and "goal" in row.membership.position.name.lower()]
else: else:
rows = attendances rows = [row for row in attendances if row.status in IN_STATUSES]
return super().get_context_data( return super().get_context_data(
event=event, event=event,
rows=rows, rows=rows,
total_count=len(attendances), total_count=len(attendances),
responded_count=sum(1 for row in attendances if row.status in IN_STATUSES),
silent_count=sum(1 for row in attendances if row.is_silent), silent_count=sum(1 for row in attendances if row.is_silent),
checked_in_count=sum(1 for row in attendances if row.showed_up is not None), checked_in_count=sum(1 for row in attendances if row.showed_up is not None),
filter_param=filter_param, filter_param=filter_param,
@@ -455,6 +463,68 @@ class CoachAddPlayerView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
return HttpResponseRedirect(reverse("mobile:coach_today")) return HttpResponseRedirect(reverse("mobile:coach_today"))
class CoachAddStaffView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
"""Squad screen's staff "Add" entry point -- the same bulk-checkbox shape
as CoachAddPlayerView/C6, with one addition: StaffAssignment.position is
required (unlike a roster spot's optional one), so there's a single
position picker shared by however many candidates get checked, rather
than a per-row picker that wouldn't fit this screen. Good enough for the
common case (adding one or more assistants to the same role at once);
assigning several people to different positions in one visit still means
visiting this screen more than once.
"""
template_name = "mobile/coach/add_staff.html"
screen_title = _("Add staff")
active_tab = "coach_today"
def get(self, request, *args, **kwargs):
if not self.can_manage_active_team:
return HttpResponseRedirect(reverse("mobile:coach_squad"))
return super().get(request, *args, **kwargs)
def _candidate_pool(self, season):
taken = StaffAssignment.objects.filter(team=self.active_team, season=season).values_list("member_id", flat=True)
return eligible_roster_members(self.request.club).exclude(pk__in=taken)
def get_context_data(self, **kwargs):
season = current_season(self.request.club)
candidates = []
positions = Position.objects.none()
if self.active_team is not None and season is not None:
candidates = list(self._candidate_pool(season).order_by("last_name", "first_name"))
positions = Position.objects.filter(club=self.request.club, staff_position=True)
return super().get_context_data(candidates=candidates, positions=positions, **kwargs)
def post(self, request, *args, **kwargs):
if not self.can_manage_active_team:
return HttpResponseForbidden()
season = current_season(request.club)
if self.active_team is None or season is None:
return HttpResponseForbidden()
position = Position.objects.filter(club=request.club, staff_position=True, pk=request.POST.get("position")).first()
if position is None:
notify(request, f"e|{_('Could not add staff')}|{_('Pick a position first.')}")
return HttpResponseRedirect(reverse("mobile:coach_add_staff"))
pool_ids = {str(pk) for pk in self._candidate_pool(season).values_list("pk", flat=True)}
added = 0
for member_id in request.POST.getlist("member"):
if member_id not in pool_ids:
continue
StaffAssignment.objects.get_or_create(team=self.active_team, season=season, member_id=member_id, defaults={"position": position})
added += 1
if added:
body = ngettext("%(count)d staff member added.", "%(count)d staff members added.", added) % {"count": added}
notify(request, f"s|{_('Staff updated')}|{body}")
return HttpResponseRedirect(reverse("mobile:coach_squad"))
class CoachLineupView(CoachScopeMixin, LoginRequiredMixin, TemplateView): class CoachLineupView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
"""C3 -- a game's line-up, kept deliberately simple: a plain yes/no pick """C3 -- a game's line-up, kept deliberately simple: a plain yes/no pick
per available roster player, grouped by their roster position ("category") per available roster player, grouped by their roster position ("category")
@@ -576,11 +646,9 @@ class CoachLineupPublishView(CoachScopeMixin, LoginRequiredMixin, View):
class CoachSquadView(CoachScopeMixin, LoginRequiredMixin, TemplateView): class CoachSquadView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
"""Bottom-tab "Squad" -- the active team's roster and staff for the """Bottom-tab "Squad" -- the active team's roster and staff for the
current season, view-only beyond the "Add player" entry point (which current season. Each roster row links through to CoachRosterMemberView
reuses CoachAddPlayerView/C6). No per-row edit here (jersey number, for stats/contact/edit/remove; staff rows stay plain (no per-row action
position, captaincy) -- that stays a desktop-only action for now via yet beyond the "Add" entry point below, which reuses CoachAddStaffView).
management.forms.TeamMembershipForm; this screen is about seeing the
squad, not managing individual rows from a phone.
""" """
template_name = "mobile/coach/squad.html" template_name = "mobile/coach/squad.html"
@@ -597,6 +665,71 @@ class CoachSquadView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
return super().get_context_data(roster=roster, staff=staff, **kwargs) return super().get_context_data(roster=roster, staff=staff, **kwargs)
class CoachRosterMemberView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
"""Squad screen's per-player detail sheet: attendance stats for the
season, tap-to-call buttons (the player's own phone/emergency phone, plus
each guardian's if they're a child -- Member.guardians is only ever
non-empty for one), and -- for whoever manages this team -- the same
position/jersey/captaincy edit TeamMembershipForm exposes on desktop,
plus a remove-from-roster action. Read-only (no edit form, no remove
button) for staff without a management position, same hide-don't-disable
rule as everywhere else in coach mode.
"""
template_name = "mobile/coach/roster_member.html"
screen_title = _("Player")
active_tab = "coach_squad"
def get_membership(self):
if self.active_team is None:
raise Http404
return get_object_or_404(TeamMembership.objects.filter(team=self.active_team).select_related("member", "position"), pk=self.kwargs["membership_pk"])
def build_form(self, membership, data=None):
return CoachRosterEditForm(data, instance=membership, club=self.request.club, team=self.active_team, season=membership.season)
def get_context_data(self, **kwargs):
membership = self.get_membership()
member = membership.member
kwargs.setdefault("form", self.build_form(membership) if self.can_manage_active_team else None)
return super().get_context_data(
membership=membership,
member=member,
guardians=member.guardians,
attendance_counts=member_attendance_counts(member, membership.season),
**kwargs,
)
def post(self, request, *args, **kwargs):
membership = self.get_membership()
if not self.can_manage_active_team:
return HttpResponseForbidden()
form = self.build_form(membership, request.POST)
if not form.is_valid():
return self.render_to_response(self.get_context_data(form=form))
form.save()
notify(request, f"s|{_('Player updated')}|" + _("%(member)s” has been updated.") % {"member": membership.member})
return HttpResponseRedirect(reverse("mobile:coach_roster_member", kwargs={"membership_pk": membership.pk}))
class CoachRosterRemoveView(CoachScopeMixin, LoginRequiredMixin, View):
def post(self, request, *args, **kwargs):
if self.active_team is None:
raise Http404
if not self.can_manage_active_team:
return HttpResponseForbidden()
membership = get_object_or_404(TeamMembership.objects.filter(team=self.active_team), pk=kwargs["membership_pk"])
member = membership.member
membership.delete()
notify(request, f"w|{_('Player removed')}|" + _("%(member)s” removed from the roster.") % {"member": member})
return HttpResponseRedirect(reverse("mobile:coach_squad"))
class CoachScheduleView(CoachScopeMixin, LoginRequiredMixin, TemplateView): class CoachScheduleView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
"""Bottom-tab "Schedule" -- every upcoming event for the active team, full """Bottom-tab "Schedule" -- every upcoming event for the active team, full
stop (not Today's own "just the next session" scope). Each row jumps stop (not Today's own "just the next session" scope). Each row jumps

View File

@@ -1,6 +1,8 @@
from django import forms from django import forms
from django.utils.translation import gettext_lazy as _
from members.models import Member from members.models import Member
from teams.models import Position, TeamMembership
#: Shared by every text-ish field below -- mobile has no equivalent of #: Shared by every text-ish field below -- mobile has no equivalent of
#: management/controlpanel's templatetags/field.html (which builds widget #: management/controlpanel's templatetags/field.html (which builds widget
@@ -37,3 +39,37 @@ class MemberProfileForm(forms.ModelForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
for field in self.fields.values(): for field in self.fields.values():
field.widget.attrs["class"] = _INPUT_CLASSES field.widget.attrs["class"] = _INPUT_CLASSES
class CoachRosterEditForm(forms.ModelForm):
"""Coach-mode roster row edit -- mobile/coach/roster_member.html's player
detail sheet. Same fields management.forms.TeamMembershipForm edits,
minus ``member`` (fixed by the URL here, never reassigned from this
screen) -- this used to be a desktop-only action; see CoachSquadView's
own docstring for why that changed."""
class Meta:
model = TeamMembership
fields = ["position", "jersey_number", "is_captain", "is_alternate_captain"]
def __init__(self, *args, club=None, team=None, season=None, **kwargs):
super().__init__(*args, **kwargs)
self.team = team
self.season = season
self.fields["position"].queryset = Position.objects.filter(club=club, staff_position=False)
self.fields["position"].required = True
self.fields["position"].widget.attrs["class"] = _INPUT_CLASSES
self.fields["jersey_number"].widget.attrs["class"] = _INPUT_CLASSES
self.fields["is_captain"].widget.attrs["class"] = "h-5 w-5 shrink-0 accent-ink"
self.fields["is_alternate_captain"].widget.attrs["class"] = "h-5 w-5 shrink-0 accent-ink"
def clean(self):
# Same jersey-clash check as TeamMembershipForm.clean -- team/season aren't
# form fields, so Django's automatic validate_unique() can't catch this itself.
cleaned = super().clean()
jersey_number = cleaned.get("jersey_number")
if jersey_number is not None and self.team is not None and self.season is not None:
clash = TeamMembership.objects.filter(team=self.team, season=self.season, jersey_number=jersey_number).exclude(pk=self.instance.pk).exists()
if clash:
self.add_error("jersey_number", _("Another player on this team already has this jersey number this season."))
return cleaned

View File

@@ -0,0 +1,48 @@
{% extends "mobile/coach/base.html" %}
{% load i18n %}
{% comment %}
Squad screen's staff "Add" entry point -- see CoachAddStaffView's own
docstring for why this needs a shared position picker up top, unlike
CoachAddPlayerView/C6's plain checkbox list (a roster spot's position is
optional; StaffAssignment's is required).
{% endcomment %}
{% block header_extra %}
<div class="mt-3 flex items-center justify-between">
<span class="font-display text-lg font-extrabold text-white uppercase">{% blocktrans with team=active_team.name %}Add staff to {{ team }}{% endblocktrans %}</span>
</div>
{% endblock header_extra %}
{% block content %}
<form method="post" action="{% url "mobile:coach_add_staff" %}" x-data="{ count: 0 }" hx-boost="false">
{% csrf_token %}
<div class="m-card p-3.5">
<label class="mb-1 block text-xs font-semibold text-muted uppercase tracking-wide">{% trans "Position" %}</label>
<select class="h-11 w-full rounded-lg border border-stroke bg-paper px-3 text-[15px] text-ink focus:border-ink focus:outline-none" name="position" required>
<option value="">{% trans "Choose a position" %}</option>
{% for position in positions %}
<option value="{{ position.pk }}">{{ position.name }}</option>
{% endfor %}
</select>
</div>
<div class="m-card mt-3 flex flex-col overflow-hidden">
{% for candidate in candidates %}
<label class="flex items-center gap-3 px-4 py-2.5 {% if not forloop.last %}border-b border-rule{% endif %}">
{% include "mobile/_avatar.html" with person=candidate size_class="h-9 w-9" text_class="text-xs" %}
<span class="min-w-0 flex-1 text-sm font-semibold text-ink">{{ candidate.get_full_name }}</span>
<input class="h-5 w-5 shrink-0 accent-ink" type="checkbox" name="member" value="{{ candidate.pk }}" @change="count = $el.form.querySelectorAll('input[name=member]:checked').length">
</label>
{% empty %}
<div class="px-4 py-6 text-center text-sm text-muted">{% trans "No one is eligible to be added." %}</div>
{% endfor %}
</div>
<button class="btn btn-dark mt-4 w-full" type="submit" :disabled="count === 0">
<span x-show="count === 0">{% trans "Select people to add" %}</span>
<span x-show="count > 0" x-cloak>{% trans "Add" %} (<span x-text="count"></span>)</span>
</button>
</form>
{% endblock content %}

View File

@@ -26,14 +26,11 @@
{% block content %} {% block content %}
<div class="flex gap-2"> <div class="flex gap-2">
<a class="flex h-9 flex-1 items-center justify-center rounded-full font-display text-xs font-extrabold tracking-wide uppercase {% if not filter_param %}bg-ink text-white{% else %}border border-line bg-white text-muted{% endif %}" href="?"> <a class="flex h-9 flex-1 items-center justify-center rounded-full font-display text-xs font-extrabold tracking-wide uppercase {% if not filter_param %}bg-ink text-white{% else %}border border-line bg-white text-muted{% endif %}" href="?">
{% trans "All" %} {{ total_count }} {% trans "Responded" %} {{ responded_count }}
</a> </a>
<a class="flex h-9 flex-1 items-center justify-center rounded-full font-display text-xs font-extrabold tracking-wide uppercase {% if filter_param == "silent" %}bg-ink text-white{% else %}border border-line bg-white text-muted{% endif %}" href="?filter=silent"> <a class="flex h-9 flex-1 items-center justify-center rounded-full font-display text-xs font-extrabold tracking-wide uppercase {% if filter_param == "silent" %}bg-ink text-white{% else %}border border-line bg-white text-muted{% endif %}" href="?filter=silent">
{% trans "Silent" %} {{ silent_count }} {% trans "Silent" %} {{ silent_count }}
</a> </a>
<a class="flex h-9 flex-1 items-center justify-center rounded-full font-display text-xs font-extrabold tracking-wide uppercase {% if filter_param == "goalies" %}bg-ink text-white{% else %}border border-line bg-white text-muted{% endif %}" href="?filter=goalies">
{% trans "Goalies" %}
</a>
</div> </div>
<form method="post" action="{% url "mobile:coach_attendance" event.pk %}" hx-boost="false"> <form method="post" action="{% url "mobile:coach_attendance" event.pk %}" hx-boost="false">

View File

@@ -0,0 +1,97 @@
{% extends "mobile/coach/base.html" %}
{% load i18n lucide %}
{% comment %}
Squad screen's per-player detail sheet -- see CoachRosterMemberView's own
docstring. Call buttons/guardian section mirror management/templates/
management/member_detail.html's "Personal information" card (same tel:
pattern, same guardians-only-if-non-empty gate), just laid out for a
phone instead of a desktop two-column grid.
{% endcomment %}
{% block header_extra %}
<div class="mt-3 flex items-center gap-3">
{% include "mobile/_avatar.html" with person=member size_class="h-12 w-12" text_class="text-base" %}
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="truncate font-display text-lg leading-none font-extrabold text-white uppercase">{{ member.get_full_name }}</span>
{% if membership.is_captain %}<span class="font-display text-xs font-extrabold text-ice">C</span>{% endif %}
{% if membership.is_alternate_captain %}<span class="font-display text-xs font-extrabold text-ice">A</span>{% endif %}
</div>
<div class="mt-1 text-xs text-on-dark">{{ membership.position|default:_("No position set") }} {% if membership.jersey_number %}&middot; #{{ membership.jersey_number }}{% endif %}</div>
</div>
</div>
{% endblock header_extra %}
{% block content %}
<div class="m-card p-4">
<div class="mb-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Contact" %}</div>
<div class="flex flex-col gap-2">
{% if member.phone %}
<a class="btn w-full gap-2 border border-line bg-white text-ink" href="tel:{{ member.phone.as_international }}">{% lucide "phone" size=16 %} {% trans "Call" %}</a>
{% endif %}
{% if member.emergency_phone %}
<a class="btn w-full gap-2 border border-club-dark bg-white text-club-dark" href="tel:{{ member.emergency_phone.as_international }}">{% lucide "shield-alert" size=16 %} {% trans "Emergency call" %}</a>
{% endif %}
{% for guardian in guardians %}
{% if guardian.phone %}
<a class="btn w-full gap-2 border border-line bg-white text-ink" href="tel:{{ guardian.phone.as_international }}">{% lucide "phone" size=16 %} {% blocktrans with name=guardian.get_full_name %}Call {{ name }}{% endblocktrans %}</a>
{% endif %}
{% if guardian.emergency_phone %}
<a class="btn w-full gap-2 border border-club-dark bg-white text-club-dark" href="tel:{{ guardian.emergency_phone.as_international }}">{% lucide "shield-alert" size=16 %} {% blocktrans with name=guardian.get_full_name %}Emergency: {{ name }}{% endblocktrans %}</a>
{% endif %}
{% endfor %}
{% if not member.phone and not member.emergency_phone and not guardians %}
<p class="text-sm text-muted">{% trans "No phone numbers on file." %}</p>
{% endif %}
</div>
</div>
<div class="m-card mt-3 p-4">
<div class="mb-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Attendance this season" %}</div>
<div class="flex gap-4">
<div>
<div class="font-display text-2xl leading-none font-extrabold text-ok tabular-nums">{{ attendance_counts.present }}</div>
<div class="mt-1 font-display text-[10px] font-extrabold tracking-wide text-muted uppercase">{% trans "Present" %}</div>
</div>
<div>
<div class="font-display text-2xl leading-none font-extrabold text-club tabular-nums">{{ attendance_counts.absent }}</div>
<div class="mt-1 font-display text-[10px] font-extrabold tracking-wide text-muted uppercase">{% trans "Absent" %}</div>
</div>
<div>
<div class="font-display text-2xl leading-none font-extrabold text-dim tabular-nums">{{ attendance_counts.no_reply }}</div>
<div class="mt-1 font-display text-[10px] font-extrabold tracking-wide text-muted uppercase">{% trans "No reply" %}</div>
</div>
</div>
</div>
{% if form %}
<form class="m-card mt-3 flex flex-col gap-3 p-4" method="post" action="{% url "mobile:coach_roster_member" membership.pk %}" hx-boost="false">
{% csrf_token %}
<div class="mb-1 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Roster details" %}</div>
<div>
<label class="mb-1 block text-xs font-semibold text-muted uppercase tracking-wide">{% trans "Position" %}</label>
{{ form.position }}
{% if form.position.errors %}<p class="mt-1 text-xs text-club-dark">{{ form.position.errors.0 }}</p>{% endif %}
</div>
<div>
<label class="mb-1 block text-xs font-semibold text-muted uppercase tracking-wide">{% trans "Jersey number" %}</label>
{{ form.jersey_number }}
{% if form.jersey_number.errors %}<p class="mt-1 text-xs text-club-dark">{{ form.jersey_number.errors.0 }}</p>{% endif %}
</div>
<label class="flex items-center gap-2 text-sm text-ink">
{{ form.is_captain }} {% trans "Captain" %}
</label>
<label class="flex items-center gap-2 text-sm text-ink">
{{ form.is_alternate_captain }} {% trans "Alternate captain" %}
</label>
<button class="btn btn-dark mt-1 w-full" type="submit">{% trans "Save" %}</button>
</form>
{% trans "Remove this player from the roster? This cannot be undone." as remove_confirm_text %}
<form class="mt-3" method="post" action="{% url "mobile:coach_roster_remove" membership.pk %}" hx-boost="false" onsubmit="return confirm('{{ remove_confirm_text|escapejs }}')">
{% csrf_token %}
<button class="btn w-full gap-2 bg-club text-white" type="submit">{% lucide "trash-2" size=16 %} {% trans "Remove from roster" %}</button>
</form>
{% endif %}
{% endblock content %}

View File

@@ -3,8 +3,8 @@
{% comment %} {% comment %}
Bottom-tab "Squad" -- roster + staff for the active team, current season. Bottom-tab "Squad" -- roster + staff for the active team, current season.
See CoachSquadView's own docstring for why there's no per-row edit here Each roster row links through to CoachRosterMemberView (stats, tap-to-
(jersey number/position/captaincy stays a desktop-only action for now). call, edit, remove) -- see that view's own docstring.
{% endcomment %} {% endcomment %}
{% block content %} {% block content %}
@@ -25,7 +25,7 @@
<div class="m-card overflow-hidden"> <div class="m-card overflow-hidden">
{% for membership in roster %} {% for membership in roster %}
{% if not forloop.first %}<div class="h-px bg-rule"></div>{% endif %} {% if not forloop.first %}<div class="h-px bg-rule"></div>{% endif %}
<div class="flex items-center gap-3 p-3.5"> <a class="flex items-center gap-3 p-3.5" href="{% url "mobile:coach_roster_member" membership.pk %}">
<div class="w-8 shrink-0 text-center font-display text-lg font-extrabold text-ink tabular-nums">{{ membership.jersey_number|default:"—" }}</div> <div class="w-8 shrink-0 text-center font-display text-lg font-extrabold text-ink tabular-nums">{{ membership.jersey_number|default:"—" }}</div>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="text-[15px] font-semibold text-ink"> <div class="text-[15px] font-semibold text-ink">
@@ -35,7 +35,8 @@
</div> </div>
<div class="text-xs text-muted">{{ membership.position|default:"—" }}</div> <div class="text-xs text-muted">{{ membership.position|default:"—" }}</div>
</div> </div>
</div> {% lucide "chevron-right" size=18 class="shrink-0 text-dim" %}
</a>
{% empty %} {% empty %}
<div class="p-6 text-center text-sm text-muted">{% trans "No one on the roster for this season yet." %}</div> <div class="p-6 text-center text-sm text-muted">{% trans "No one on the roster for this season yet." %}</div>
{% endfor %} {% endfor %}
@@ -43,7 +44,14 @@
</div> </div>
<div> <div>
<div class="mb-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Staff" %}</div> <div class="mb-2 flex items-center justify-between">
<span class="font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Staff" %}</span>
{% if can_manage_active_team %}
<a class="flex items-center gap-1 font-display text-xs font-extrabold tracking-wide text-club uppercase" href="{% url "mobile:coach_add_staff" %}">
{% lucide "user-plus" size=14 %} {% trans "Add" %}
</a>
{% endif %}
</div>
<div class="m-card overflow-hidden"> <div class="m-card overflow-hidden">
{% for assignment in staff %} {% for assignment in staff %}
{% if not forloop.first %}<div class="h-px bg-rule"></div>{% endif %} {% if not forloop.first %}<div class="h-px bg-rule"></div>{% endif %}

View File

@@ -27,7 +27,7 @@
</div> </div>
{% endif %} {% endif %}
<div class="grid grid-cols-3 gap-2.5"> <div class="grid grid-cols-4 gap-2">
<div class="m-card p-3 text-center"> <div class="m-card p-3 text-center">
<div class="font-display text-2xl leading-none font-extrabold text-ink">{{ squad_count }}</div> <div class="font-display text-2xl leading-none font-extrabold text-ink">{{ squad_count }}</div>
<div class="mt-1 font-display text-[10px] font-extrabold tracking-wide text-muted uppercase">{% trans "Squad" %}</div> <div class="mt-1 font-display text-[10px] font-extrabold tracking-wide text-muted uppercase">{% trans "Squad" %}</div>
@@ -37,7 +37,11 @@
<div class="mt-1 font-display text-[10px] font-extrabold tracking-wide text-muted uppercase">{% trans "In" %}</div> <div class="mt-1 font-display text-[10px] font-extrabold tracking-wide text-muted uppercase">{% trans "In" %}</div>
</div> </div>
<div class="m-card p-3 text-center"> <div class="m-card p-3 text-center">
<div class="font-display text-2xl leading-none font-extrabold text-club">{{ silent_count }}</div> <div class="font-display text-2xl leading-none font-extrabold text-club">{{ out_count }}</div>
<div class="mt-1 font-display text-[10px] font-extrabold tracking-wide text-muted uppercase">{% trans "Out" %}</div>
</div>
<div class="m-card p-3 text-center">
<div class="font-display text-2xl leading-none font-extrabold text-warn-text">{{ silent_count }}</div>
<div class="mt-1 font-display text-[10px] font-extrabold tracking-wide text-muted uppercase">{% trans "Silent" %}</div> <div class="mt-1 font-display text-[10px] font-extrabold tracking-wide text-muted uppercase">{% trans "Silent" %}</div>
</div> </div>
</div> </div>

View File

@@ -2432,6 +2432,19 @@ class CoachTodayViewTests(TestCase):
self.assertEqual(response.context["silent_count"], 1) self.assertEqual(response.context["silent_count"], 1)
self.assertContains(response, "Silent players") self.assertContains(response, "Silent players")
def test_out_players_are_counted(self):
other_member = Member.objects.create(first_name="Anna", last_name="Player")
TeamMembership.objects.create(team=self.team, member=other_member, season=self.season)
event = Event.objects.create(club=self.club, title="Practice", kind=Event.EventKind.TRAINING, start=timezone.now() + datetime.timedelta(minutes=5))
event.teams.add(self.team)
Attendance.objects.update_or_create(event=event, member=other_member, defaults={"status": Attendance.AttendanceStatus.ABSENT})
self.client.force_login(self.user)
response = self._get()
self.assertEqual(response.context["out_count"], 1)
self.assertContains(response, "Out")
def test_check_attendance_cta_hidden_for_non_managing_staff(self): def test_check_attendance_cta_hidden_for_non_managing_staff(self):
physio_position = Position.objects.create(club=self.club, name="Physio", short_name="PHY", staff_position=True, management_position=False) 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_user = User.objects.create_user(email="physio@example.com", password="pw-secret-123")
@@ -2598,7 +2611,7 @@ class CoachSquadViewTests(TestCase):
def test_lists_roster_and_staff(self): def test_lists_roster_and_staff(self):
player_position = Position.objects.create(club=self.club, name="Forward", short_name="FW", staff_position=False) player_position = Position.objects.create(club=self.club, name="Forward", short_name="FW", staff_position=False)
player = Member.objects.create(first_name="Anna", last_name="Player") player = Member.objects.create(first_name="Anna", last_name="Player")
TeamMembership.objects.create(team=self.team, member=player, season=self.season, position=player_position, jersey_number=9) membership = TeamMembership.objects.create(team=self.team, member=player, season=self.season, position=player_position, jersey_number=9)
self.client.force_login(self.user) self.client.force_login(self.user)
response = self._get() response = self._get()
@@ -2607,8 +2620,9 @@ class CoachSquadViewTests(TestCase):
self.assertContains(response, "Forward") self.assertContains(response, "Forward")
self.assertContains(response, "Sam Coach") self.assertContains(response, "Sam Coach")
self.assertContains(response, "Head coach") self.assertContains(response, "Head coach")
self.assertContains(response, reverse("mobile:coach_roster_member", kwargs={"membership_pk": membership.pk}))
def test_add_link_hidden_for_non_managing_staff(self): def test_add_links_hidden_for_non_managing_staff(self):
physio_position = Position.objects.create(club=self.club, name="Physio", short_name="PHY", staff_position=True, management_position=False) 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_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) physio_member = Member.objects.create(first_name="Pat", last_name="Physio", user=physio_user)
@@ -2618,6 +2632,14 @@ class CoachSquadViewTests(TestCase):
response = self._get() response = self._get()
self.assertNotContains(response, reverse("mobile:coach_add_player")) self.assertNotContains(response, reverse("mobile:coach_add_player"))
self.assertNotContains(response, reverse("mobile:coach_add_staff"))
def test_add_staff_link_shown_for_managing_staff(self):
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, reverse("mobile:coach_add_staff"))
def test_no_staff_assignment_shows_a_graceful_empty_state(self): def test_no_staff_assignment_shows_a_graceful_empty_state(self):
bare_user = User.objects.create_user(email="new@example.com", password="pw-secret-123") bare_user = User.objects.create_user(email="new@example.com", password="pw-secret-123")
@@ -2629,6 +2651,284 @@ class CoachSquadViewTests(TestCase):
self.assertContains(response, "Not staffing a team yet") self.assertContains(response, "Not staffing a team yet")
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class CoachRosterMemberViewTests(TestCase):
"""Squad screen's per-player detail sheet -- stats, tap-to-call, edit,
remove. See CoachRosterMemberView's own docstring."""
@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.coach_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.coach_position)
cls.player_position = Position.objects.create(club=cls.club, name="Forward", short_name="FW", staff_position=False)
cls.player = Member.objects.create(first_name="Anna", last_name="Player", phone="+3247" "1234567", emergency_phone="+3247" "7654321")
cls.membership = TeamMembership.objects.create(team=cls.team, member=cls.player, season=cls.season, position=cls.player_position, jersey_number=9)
def _get(self, membership=None):
membership = membership or self.membership
return self.client.get(reverse("mobile:coach_roster_member", kwargs={"membership_pk": membership.pk}), HTTP_HOST="ajax-united.rosterchief.app")
def test_requires_login(self):
response = self._get()
self.assertEqual(response.status_code, 302)
def test_shows_the_players_own_call_buttons(self):
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, f"tel:{self.player.phone.as_international}")
self.assertContains(response, f"tel:{self.player.emergency_phone.as_international}")
def test_shows_a_guardians_call_buttons_for_a_child(self):
family = Family.objects.create(name="Player family")
guardian = Member.objects.create(first_name="Gail", last_name="Guardian", phone="+3247" "1112222")
FamilyMembership.objects.create(family=family, member=guardian, role=FamilyMembership.FamilyRole.PARENT)
FamilyMembership.objects.create(family=family, member=self.player, role=FamilyMembership.FamilyRole.CHILD)
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, f"tel:{guardian.phone.as_international}")
self.assertContains(response, "Gail Guardian")
def test_shows_attendance_counts(self):
past_event = Event.objects.create(club=self.club, title="Past practice", kind=Event.EventKind.TRAINING, start=timezone.now() - datetime.timedelta(days=2), season=self.season)
past_event.teams.add(self.team)
Attendance.objects.update_or_create(event=past_event, member=self.player, defaults={"status": Attendance.AttendanceStatus.PRESENT})
self.client.force_login(self.user)
response = self._get()
self.assertEqual(response.context["attendance_counts"]["present"], 1)
def test_managing_staff_sees_the_edit_form_and_remove_button(self):
self.client.force_login(self.user)
response = self._get()
self.assertIsNotNone(response.context["form"])
self.assertContains(response, "Remove from roster")
def test_non_managing_staff_sees_a_read_only_view(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)
self.client.force_login(physio_user)
response = self._get()
self.assertIsNone(response.context["form"])
self.assertNotContains(response, "Remove from roster")
def test_a_membership_from_another_team_is_not_reachable(self):
other_team = Team.objects.create(club=self.club, name="U14", short_name="U14")
other_membership = TeamMembership.objects.create(team=other_team, member=Member.objects.create(first_name="Other", last_name="Team"), season=self.season)
self.client.force_login(self.user)
response = self._get(other_membership)
self.assertEqual(response.status_code, 404)
def test_post_updates_position_jersey_and_captaincy(self):
new_position = Position.objects.create(club=self.club, name="Midfielder", short_name="MF", staff_position=False)
self.client.force_login(self.user)
response = self.client.post(
reverse("mobile:coach_roster_member", kwargs={"membership_pk": self.membership.pk}),
{"position": new_position.pk, "jersey_number": "11", "is_captain": "on"},
HTTP_HOST="ajax-united.rosterchief.app",
)
self.assertRedirects(response, reverse("mobile:coach_roster_member", kwargs={"membership_pk": self.membership.pk}), fetch_redirect_response=False)
self.membership.refresh_from_db()
self.assertEqual(self.membership.position, new_position)
self.assertEqual(self.membership.jersey_number, 11)
self.assertTrue(self.membership.is_captain)
def test_post_rejects_a_clashing_jersey_number(self):
TeamMembership.objects.create(team=self.team, member=Member.objects.create(first_name="Other", last_name="Player"), season=self.season, jersey_number=7)
self.client.force_login(self.user)
response = self.client.post(
reverse("mobile:coach_roster_member", kwargs={"membership_pk": self.membership.pk}),
{"position": self.player_position.pk, "jersey_number": "7"},
HTTP_HOST="ajax-united.rosterchief.app",
)
self.assertEqual(response.status_code, 200)
self.membership.refresh_from_db()
self.assertEqual(self.membership.jersey_number, 9)
def test_non_managing_staff_cannot_post(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="physio2@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)
self.client.force_login(physio_user)
response = self.client.post(
reverse("mobile:coach_roster_member", kwargs={"membership_pk": self.membership.pk}),
{"position": self.player_position.pk, "jersey_number": "99"},
HTTP_HOST="ajax-united.rosterchief.app",
)
self.assertEqual(response.status_code, 403)
self.membership.refresh_from_db()
self.assertEqual(self.membership.jersey_number, 9)
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class CoachRosterRemoveViewTests(TestCase):
@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.coach_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.coach_position)
cls.player = Member.objects.create(first_name="Anna", last_name="Player")
cls.membership = TeamMembership.objects.create(team=cls.team, member=cls.player, season=cls.season)
def test_removes_the_membership(self):
self.client.force_login(self.user)
response = self.client.post(reverse("mobile:coach_roster_remove", kwargs={"membership_pk": self.membership.pk}), HTTP_HOST="ajax-united.rosterchief.app")
self.assertRedirects(response, reverse("mobile:coach_squad"), fetch_redirect_response=False)
self.assertFalse(TeamMembership.objects.filter(pk=self.membership.pk).exists())
def test_non_managing_staff_cannot_remove(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)
self.client.force_login(physio_user)
response = self.client.post(reverse("mobile:coach_roster_remove", kwargs={"membership_pk": self.membership.pk}), HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.status_code, 403)
self.assertTrue(TeamMembership.objects.filter(pk=self.membership.pk).exists())
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class CoachAddStaffViewTests(TestCase):
"""Squad screen's staff "Add" entry point -- see CoachAddStaffView's own
docstring for the shared-position-picker shape."""
@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.coach_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.coach_position)
cls.assistant_position = Position.objects.create(club=cls.club, name="Assistant coach", short_name="AC", staff_position=True, management_position=False)
def make_eligible_member(self, first_name="Anna", last_name="Player"):
member = Member.objects.create(first_name=first_name, last_name=last_name)
ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE, kind=ClubMembership.Kind.MEMBER)
return member
def test_requires_login(self):
response = self.client.get(reverse("mobile:coach_add_staff"), HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.status_code, 302)
def test_get_redirects_a_non_managing_staffer(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)
self.client.force_login(physio_user)
response = self.client.get(reverse("mobile:coach_add_staff"), HTTP_HOST="ajax-united.rosterchief.app")
self.assertRedirects(response, reverse("mobile:coach_squad"), fetch_redirect_response=False)
def test_lists_eligible_members_not_already_staffing_this_team(self):
eligible = self.make_eligible_member()
already_staffing = self.make_eligible_member(first_name="Already", last_name="Staffing")
StaffAssignment.objects.create(team=self.team, member=already_staffing, season=self.season, position=self.assistant_position)
self.client.force_login(self.user)
response = self.client.get(reverse("mobile:coach_add_staff"), HTTP_HOST="ajax-united.rosterchief.app")
candidates = response.context["candidates"]
self.assertIn(eligible, candidates)
self.assertNotIn(already_staffing, candidates)
self.assertIn(self.assistant_position, response.context["positions"])
def test_post_assigns_selected_members_to_the_chosen_position(self):
first = self.make_eligible_member(first_name="First", last_name="Pick")
second = self.make_eligible_member(first_name="Second", last_name="Pick")
self.client.force_login(self.user)
response = self.client.post(
reverse("mobile:coach_add_staff"),
{"position": str(self.assistant_position.pk), "member": [str(first.pk), str(second.pk)]},
HTTP_HOST="ajax-united.rosterchief.app",
)
self.assertRedirects(response, reverse("mobile:coach_squad"), fetch_redirect_response=False)
self.assertTrue(StaffAssignment.objects.filter(team=self.team, season=self.season, member=first, position=self.assistant_position).exists())
self.assertTrue(StaffAssignment.objects.filter(team=self.team, season=self.season, member=second, position=self.assistant_position).exists())
def test_post_without_a_position_is_rejected(self):
candidate = self.make_eligible_member()
self.client.force_login(self.user)
response = self.client.post(reverse("mobile:coach_add_staff"), {"member": [str(candidate.pk)]}, HTTP_HOST="ajax-united.rosterchief.app")
self.assertRedirects(response, reverse("mobile:coach_add_staff"), fetch_redirect_response=False)
self.assertFalse(StaffAssignment.objects.filter(team=self.team, member=candidate).exists())
def test_post_ignores_a_member_id_outside_the_eligible_pool(self):
ineligible = Member.objects.create(first_name="Not", last_name="Eligible")
self.client.force_login(self.user)
response = self.client.post(
reverse("mobile:coach_add_staff"),
{"position": str(self.assistant_position.pk), "member": [str(ineligible.pk)]},
HTTP_HOST="ajax-united.rosterchief.app",
)
self.assertRedirects(response, reverse("mobile:coach_squad"), fetch_redirect_response=False)
self.assertFalse(StaffAssignment.objects.filter(team=self.team, member=ineligible).exists())
def test_non_managing_staff_cannot_post(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="physio2@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)
candidate = self.make_eligible_member()
self.client.force_login(physio_user)
response = self.client.post(
reverse("mobile:coach_add_staff"),
{"position": str(self.assistant_position.pk), "member": [str(candidate.pk)]},
HTTP_HOST="ajax-united.rosterchief.app",
)
self.assertEqual(response.status_code, 403)
self.assertFalse(StaffAssignment.objects.filter(team=self.team, member=candidate).exists())
@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 CoachScheduleViewTests(TestCase): class CoachScheduleViewTests(TestCase):
"""Bottom-tab "Schedule" -- every upcoming event for the active team, """Bottom-tab "Schedule" -- every upcoming event for the active team,
@@ -2795,17 +3095,31 @@ class CoachAttendanceViewTests(TestCase):
rows = response.context["rows"] rows = response.context["rows"]
self.assertEqual([row.member for row in rows], [silent_member]) self.assertEqual([row.member for row in rows], [silent_member])
def test_goalies_filter_matches_on_position_name(self): def test_default_view_shows_only_those_expected_to_attend(self):
goalie_position = Position.objects.create(club=self.club, name="Goalie", short_name="G", staff_position=False) # cls.attendance (Anna) is PRESENT -- shown. A silent and a declined
goalie = Member.objects.create(first_name="Gina", last_name="Keeper") # member are both left out of the default, since neither is expected
TeamMembership.objects.create(team=self.team, member=goalie, season=self.season, position=goalie_position) # to show up -- see CoachAttendanceView's own docstring.
Attendance.objects.update_or_create(event=self.event, member=goalie, defaults={"status": Attendance.AttendanceStatus.NO_RESPONSE}) silent_member = Member.objects.create(first_name="Ben", last_name="Silent")
TeamMembership.objects.create(team=self.team, member=silent_member, season=self.season)
Attendance.objects.update_or_create(event=self.event, member=silent_member, defaults={"status": Attendance.AttendanceStatus.NO_RESPONSE})
declined_member = Member.objects.create(first_name="Cara", last_name="Declined")
TeamMembership.objects.create(team=self.team, member=declined_member, season=self.season)
Attendance.objects.update_or_create(event=self.event, member=declined_member, defaults={"status": Attendance.AttendanceStatus.ABSENT})
self.client.force_login(self.user) self.client.force_login(self.user)
response = self._get(filter="goalies") response = self._get()
rows = response.context["rows"] rows = response.context["rows"]
self.assertEqual([row.member for row in rows], [goalie]) self.assertEqual([row.member for row in rows], [self.player])
self.assertEqual(response.context["responded_count"], 1)
def test_the_goalies_chip_is_gone(self):
self.client.force_login(self.user)
response = self._get()
self.assertNotContains(response, "Goalies")
self.assertContains(response, "Responded")
def test_event_from_another_team_is_not_reachable(self): def test_event_from_another_team_is_not_reachable(self):
other_team = Team.objects.create(club=self.club, name="U14", short_name="U14") other_team = Team.objects.create(club=self.club, name="U14", short_name="U14")

View File

@@ -26,11 +26,14 @@ urlpatterns = [
# Coach mode (C1-C6). # Coach mode (C1-C6).
path("coach/", coach_views.CoachTodayView.as_view(), name="coach_today"), path("coach/", coach_views.CoachTodayView.as_view(), name="coach_today"),
path("coach/squad/", coach_views.CoachSquadView.as_view(), name="coach_squad"), path("coach/squad/", coach_views.CoachSquadView.as_view(), name="coach_squad"),
path("coach/squad/<uuid:membership_pk>/", coach_views.CoachRosterMemberView.as_view(), name="coach_roster_member"),
path("coach/squad/<uuid:membership_pk>/remove/", coach_views.CoachRosterRemoveView.as_view(), name="coach_roster_remove"),
path("coach/schedule/", coach_views.CoachScheduleView.as_view(), name="coach_schedule"), path("coach/schedule/", coach_views.CoachScheduleView.as_view(), name="coach_schedule"),
path("coach/attendance/<uuid:event_id>/", coach_views.CoachAttendanceView.as_view(), name="coach_attendance"), path("coach/attendance/<uuid:event_id>/", coach_views.CoachAttendanceView.as_view(), name="coach_attendance"),
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/staff/add/", coach_views.CoachAddStaffView.as_view(), name="coach_add_staff"),
path("coach/lineup/<uuid:event_id>/", coach_views.CoachLineupView.as_view(), name="coach_lineup"), path("coach/lineup/<uuid:event_id>/", coach_views.CoachLineupView.as_view(), name="coach_lineup"),
path("coach/lineup/<uuid:event_id>/publish/", coach_views.CoachLineupPublishView.as_view(), name="coach_lineup_publish"), path("coach/lineup/<uuid:event_id>/publish/", coach_views.CoachLineupPublishView.as_view(), name="coach_lineup_publish"),
] ]

File diff suppressed because one or more lines are too long