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 controlpanel.messages import notify
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.tasks import notify_new_event
from management.forms import EventForm, NewsForm
from news.models import News
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 .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
#: explicit yes, maybe is still a lean-in rather than silence.
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):
"""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
tonight_event = None
in_count = 0
out_count = 0
silent_count = 0
needs_you = []
@@ -88,6 +93,7 @@ class CoachTodayView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
if session_event is not None:
attendances = Attendance.objects.filter(event=session_event)
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()
if silent_count > 0:
needs_you.append(
@@ -134,6 +140,7 @@ class CoachTodayView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
session_event=session_event,
tonight_event=tonight_event,
in_count=in_count,
out_count=out_count,
silent_count=silent_count,
needs_you=needs_you,
hero_attendance=hero_attendance,
@@ -159,9 +166,11 @@ class CoachAttendanceView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
active_tab = "coach_today"
#: ?filter= values this screen understands -- anything else (including no
#: param) means "All". "Goalies" matches on position name rather than a
#: dedicated flag -- Position has no goalie-specific field to key off.
FILTERS = {"silent", "goalies"}
#: param) means "Responded" (IN_STATUSES: present/selected/maybe), the
#: default view. A coach doesn't need to check in someone silent or
#: 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):
if self.active_team is None:
@@ -186,15 +195,14 @@ class CoachAttendanceView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
filter_param = ""
if filter_param == "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:
rows = attendances
rows = [row for row in attendances if row.status in IN_STATUSES]
return super().get_context_data(
event=event,
rows=rows,
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),
checked_in_count=sum(1 for row in attendances if row.showed_up is not None),
filter_param=filter_param,
@@ -455,6 +463,68 @@ class CoachAddPlayerView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
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):
"""C3 -- a game's line-up, kept deliberately simple: a plain yes/no pick
per available roster player, grouped by their roster position ("category")
@@ -576,11 +646,9 @@ class CoachLineupPublishView(CoachScopeMixin, LoginRequiredMixin, View):
class CoachSquadView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
"""Bottom-tab "Squad" -- the active team's roster and staff for the
current season, view-only beyond the "Add player" entry point (which
reuses CoachAddPlayerView/C6). No per-row edit here (jersey number,
position, captaincy) -- that stays a desktop-only action for now via
management.forms.TeamMembershipForm; this screen is about seeing the
squad, not managing individual rows from a phone.
current season. Each roster row links through to CoachRosterMemberView
for stats/contact/edit/remove; staff rows stay plain (no per-row action
yet beyond the "Add" entry point below, which reuses CoachAddStaffView).
"""
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)
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):
"""Bottom-tab "Schedule" -- every upcoming event for the active team, full
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.utils.translation import gettext_lazy as _
from members.models import Member
from teams.models import Position, TeamMembership
#: Shared by every text-ish field below -- mobile has no equivalent of
#: management/controlpanel's templatetags/field.html (which builds widget
@@ -37,3 +39,37 @@ class MemberProfileForm(forms.ModelForm):
super().__init__(*args, **kwargs)
for field in self.fields.values():
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 %}
<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="?">
{% trans "All" %} {{ total_count }}
{% trans "Responded" %} {{ responded_count }}
</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">
{% trans "Silent" %} {{ silent_count }}
</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>
<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 %}
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
(jersey number/position/captaincy stays a desktop-only action for now).
Each roster row links through to CoachRosterMemberView (stats, tap-to-
call, edit, remove) -- see that view's own docstring.
{% endcomment %}
{% block content %}
@@ -25,7 +25,7 @@
<div class="m-card overflow-hidden">
{% for membership in roster %}
{% 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="min-w-0 flex-1">
<div class="text-[15px] font-semibold text-ink">
@@ -35,7 +35,8 @@
</div>
<div class="text-xs text-muted">{{ membership.position|default:"—" }}</div>
</div>
</div>
{% lucide "chevron-right" size=18 class="shrink-0 text-dim" %}
</a>
{% empty %}
<div class="p-6 text-center text-sm text-muted">{% trans "No one on the roster for this season yet." %}</div>
{% endfor %}
@@ -43,7 +44,14 @@
</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">
{% for assignment in staff %}
{% if not forloop.first %}<div class="h-px bg-rule"></div>{% endif %}

View File

@@ -27,7 +27,7 @@
</div>
{% 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="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>
@@ -37,7 +37,11 @@
<div class="mt-1 font-display text-[10px] font-extrabold tracking-wide text-muted uppercase">{% trans "In" %}</div>
</div>
<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>
</div>

View File

@@ -2432,6 +2432,19 @@ class CoachTodayViewTests(TestCase):
self.assertEqual(response.context["silent_count"], 1)
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):
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")
@@ -2598,7 +2611,7 @@ class CoachSquadViewTests(TestCase):
def test_lists_roster_and_staff(self):
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")
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)
response = self._get()
@@ -2607,8 +2620,9 @@ class CoachSquadViewTests(TestCase):
self.assertContains(response, "Forward")
self.assertContains(response, "Sam 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_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)
@@ -2618,6 +2632,14 @@ class CoachSquadViewTests(TestCase):
response = self._get()
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):
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")
@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"])
class CoachScheduleViewTests(TestCase):
"""Bottom-tab "Schedule" -- every upcoming event for the active team,
@@ -2795,17 +3095,31 @@ class CoachAttendanceViewTests(TestCase):
rows = response.context["rows"]
self.assertEqual([row.member for row in rows], [silent_member])
def test_goalies_filter_matches_on_position_name(self):
goalie_position = Position.objects.create(club=self.club, name="Goalie", short_name="G", staff_position=False)
goalie = Member.objects.create(first_name="Gina", last_name="Keeper")
TeamMembership.objects.create(team=self.team, member=goalie, season=self.season, position=goalie_position)
Attendance.objects.update_or_create(event=self.event, member=goalie, defaults={"status": Attendance.AttendanceStatus.NO_RESPONSE})
def test_default_view_shows_only_those_expected_to_attend(self):
# cls.attendance (Anna) is PRESENT -- shown. A silent and a declined
# member are both left out of the default, since neither is expected
# to show up -- see CoachAttendanceView's own docstring.
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)
response = self._get(filter="goalies")
response = self._get()
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):
other_team = Team.objects.create(club=self.club, name="U14", short_name="U14")

View File

@@ -26,11 +26,14 @@ urlpatterns = [
# Coach mode (C1-C6).
path("coach/", coach_views.CoachTodayView.as_view(), name="coach_today"),
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/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/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/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>/publish/", coach_views.CoachLineupPublishView.as_view(), name="coach_lineup_publish"),
]

File diff suppressed because one or more lines are too long