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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
48
mobile/templates/mobile/coach/add_staff.html
Normal file
48
mobile/templates/mobile/coach/add_staff.html
Normal 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 %}
|
||||
@@ -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">
|
||||
|
||||
97
mobile/templates/mobile/coach/roster_member.html
Normal file
97
mobile/templates/mobile/coach/roster_member.html
Normal 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 %}· #{{ 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
332
mobile/tests.py
332
mobile/tests.py
@@ -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")
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user