Add Coach mode: dark-chrome shell, mode switcher, C1 Today, C2 Bench attendance
First slice of the coach-mode build (design_handoff_rosterchief_platform/ README.md's "Coach mode" section, C1-C6). Ships the foundation together with the two screens the design doc calls out as the reason coaches install anything at all, rather than landing a Today screen with a dead "Check attendance" button: - mobile/coach_mixins.py's CoachScopeMixin -- the dark-mode mirror of PersonScopeMixin, scoped by active team (via club.services.access' teams_staffed_by/teams_managed_by) instead of managed people. - A standalone dark ink/ice shell (mobile/templates/mobile/coach/base.html) with the mode's signature 20px-radius overlapping sheet, reusing the --color-ice/--color-ink tokens that already existed in assets/mobile.css but were unconsumed until now. - The Coach/Member role switcher is re-added to the member shell, gated on a real staff assignment (has_coach_access), replacing the "deliberately not rendered yet" placeholder. - C1 Today: stat tiles, a tonight's-session card, a silent-players "needs you" row, and an "Also yours" card reusing HomeView's own hero-RSVP pattern scoped to the coach's own member record. - C2 Bench attendance: writes through events.services.attendance. record_check_in, which existed for exactly this and had no caller yet. Read-only for staff on a team without a management position. Line-up (C3), create event (C4), post news (C5), and add-to-roster (C6) follow in later stages -- see the coach-mode plan. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
@@ -445,4 +445,49 @@
|
||||
color: #fff;
|
||||
border-radius: var(--radius-box);
|
||||
}
|
||||
|
||||
/* --- Coach mode shell (C1-C6) ---------------------------------------------------
|
||||
Dark-chrome mirror of the member .app-header/.tab-bar pair above. .coach-sheet is
|
||||
the mode's signature: a light body that overlaps the ink header by 20px via a
|
||||
matching negative margin, so the header appears to sit "behind" a rounded sheet
|
||||
rather than the two stacking edge-to-edge like the member shell's navy header does. */
|
||||
|
||||
.coach-header {
|
||||
padding: max(env(safe-area-inset-top), 14px) 16px 14px;
|
||||
background: var(--color-ink);
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.coach-sheet {
|
||||
background: var(--color-paper);
|
||||
border-radius: 20px 20px 0 0;
|
||||
margin-top: -20px;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.coach-tab-bar {
|
||||
flex-shrink: 0;
|
||||
background: var(--color-ink);
|
||||
border-top: 1px solid var(--color-hairline);
|
||||
padding: 8px 8px max(env(safe-area-inset-bottom), 26px);
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.coach-tab-bar-item {
|
||||
flex: 1;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
color: var(--color-on-dark);
|
||||
}
|
||||
|
||||
.coach-tab-bar-item-active {
|
||||
color: var(--color-ice);
|
||||
}
|
||||
}
|
||||
|
||||
71
mobile/coach_mixins.py
Normal file
71
mobile/coach_mixins.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Shared scaffolding for every Coach-mode screen (C1-C6) -- the dark-chrome
|
||||
mirror of mobile/mixins.py's PersonScopeMixin. Scoped by *team*, not by
|
||||
managed people: a coach acts on one team at a time (the header's team-picker
|
||||
pill), switching which team is "active" rather than aggregating across
|
||||
several people the way Member mode's person switcher does.
|
||||
"""
|
||||
|
||||
from club.services.access import current_season, teams_managed_by, teams_staffed_by
|
||||
from members.models import Member
|
||||
from members.views import ClubScopedPublicMixin
|
||||
|
||||
#: Session key remembering which team was last active, so navigating between
|
||||
#: coach screens (or leaving and coming back) doesn't reset the picker to
|
||||
#: whichever team happens to sort first.
|
||||
ACTIVE_TEAM_SESSION_KEY = "coach_active_team_id"
|
||||
|
||||
|
||||
class CoachScopeMixin(ClubScopedPublicMixin):
|
||||
"""Resolves the signed-in account's Coach-mode standing: which teams
|
||||
they're on the staff of at all (``staffed_teams`` -- visibility, any
|
||||
position, see club.services.access.teams_staffed_by), which of those
|
||||
they actually manage (``managed_teams`` -- management position only,
|
||||
current season, teams_managed_by), and which one is currently "active"
|
||||
(the team-picker pill on C1's header).
|
||||
|
||||
Every Coach view still needs its own ``LoginRequiredMixin`` (kept
|
||||
separate, same as PersonScopeMixin, so a view composes whichever other
|
||||
mixins it needs on top). Nothing here 404s when ``staffed_teams`` is
|
||||
empty -- each screen renders its own "not staffing a team yet" empty
|
||||
state instead, same judgment call as HomeView's "No one to show yet".
|
||||
|
||||
Actions gated on ``can_manage_active_team`` are hidden in templates, not
|
||||
disabled -- a coach on staff but without a management position (e.g. a
|
||||
physio) can see Coach mode but shouldn't see edit affordances they don't
|
||||
have the authority to use.
|
||||
"""
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.me = Member.objects.filter(user=request.user).first() if request.user.is_authenticated else None
|
||||
self.staffed_teams = list(teams_staffed_by(request.user, request.club)) if request.user.is_authenticated else []
|
||||
self.managed_teams = list(teams_managed_by(request.user, request.club)) if request.user.is_authenticated else []
|
||||
self.active_team = self._resolve_active_team(request)
|
||||
self.can_manage_active_team = self.active_team is not None and self.active_team in self.managed_teams
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def _resolve_active_team(self, request):
|
||||
requested_id = request.GET.get("team")
|
||||
for team in self.staffed_teams:
|
||||
if requested_id and str(team.pk) == requested_id:
|
||||
request.session[ACTIVE_TEAM_SESSION_KEY] = str(team.pk)
|
||||
return team
|
||||
|
||||
stored_id = request.session.get(ACTIVE_TEAM_SESSION_KEY)
|
||||
for team in self.staffed_teams:
|
||||
if stored_id and str(team.pk) == stored_id:
|
||||
return team
|
||||
|
||||
return self.staffed_teams[0] if self.staffed_teams else None
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs.setdefault("active_tab", getattr(self, "active_tab", ""))
|
||||
kwargs.setdefault("screen_title", getattr(self, "screen_title", ""))
|
||||
|
||||
return super().get_context_data(
|
||||
me=self.me,
|
||||
staffed_teams=self.staffed_teams,
|
||||
active_team=self.active_team,
|
||||
can_manage_active_team=self.can_manage_active_team,
|
||||
season=current_season(self.request.club),
|
||||
**kwargs,
|
||||
)
|
||||
176
mobile/coach_views.py
Normal file
176
mobile/coach_views.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""Coach-mode screens (C1-C6) -- design_handoff_rosterchief_platform/README.md's
|
||||
"Coach mode (mobile, dark chrome)" section. Kept separate from mobile/views.py
|
||||
(Member mode, M1-M7) since the two modes share almost no view logic beyond the
|
||||
club/season plumbing already factored into club.services.access -- see
|
||||
mobile/coach_mixins.py's CoachScopeMixin for the shared scaffolding.
|
||||
"""
|
||||
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.http import Http404, HttpResponseForbidden, HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import TemplateView
|
||||
|
||||
from club.services.access import current_season
|
||||
from controlpanel.messages import notify
|
||||
from events.models import Attendance, Event
|
||||
from events.services.attendance import record_check_in
|
||||
from teams.models import TeamMembership
|
||||
|
||||
from .coach_mixins import CoachScopeMixin
|
||||
|
||||
#: 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]
|
||||
|
||||
|
||||
class CoachTodayView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
|
||||
"""C1 -- three stat tiles (Squad/In/Silent) for the active team's next
|
||||
upcoming session, a "tonight's session" card when one is scheduled today,
|
||||
a "needs you" list, and an "Also yours" card surfacing the coach's own
|
||||
member-side RSVP obligation (the same hero_attendance/rsvp_closed pattern
|
||||
mobile.views.HomeView already computes, scoped to self.me only -- a
|
||||
coach's own obligations, not the whole roster's).
|
||||
|
||||
"Needs you" is scoped down from the design mock to what has real backing
|
||||
data today: a silent-players count for the next session. The mock's
|
||||
line-up-not-published row and member-blocker row are deferred -- no
|
||||
Lineup model or coach-facing member-edit screen exists yet for either to
|
||||
link to (see the coach-mode implementation plan's later stages).
|
||||
"""
|
||||
|
||||
template_name = "mobile/coach/today.html"
|
||||
screen_title = _("Today")
|
||||
active_tab = "coach_today"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
now = timezone.now()
|
||||
today = timezone.localdate()
|
||||
season = current_season(self.request.club)
|
||||
team = self.active_team
|
||||
|
||||
squad_count = 0
|
||||
session_event = None
|
||||
tonight_event = None
|
||||
in_count = 0
|
||||
silent_count = 0
|
||||
needs_you = []
|
||||
|
||||
if team is not None:
|
||||
squad_count = TeamMembership.objects.filter(team=team, season=season).count() if season is not None else 0
|
||||
|
||||
upcoming = Event.objects.filter(teams=team, cancelled=False, start__gte=now).order_by("start")
|
||||
tonight_event = upcoming.filter(start__date=today).first()
|
||||
session_event = tonight_event or upcoming.first()
|
||||
|
||||
if session_event is not None:
|
||||
attendances = Attendance.objects.filter(event=session_event)
|
||||
in_count = attendances.filter(status__in=IN_STATUSES).count()
|
||||
silent_count = attendances.filter(status=Attendance.AttendanceStatus.NO_RESPONSE).count()
|
||||
if silent_count > 0:
|
||||
needs_you.append({"severity": "warn", "title": _("Silent players"), "detail": _("%(count)d haven't answered yet") % {"count": silent_count}})
|
||||
|
||||
hero_attendance = None
|
||||
rsvp_closed = False
|
||||
if self.me is not None:
|
||||
my_upcoming = Attendance.objects.filter(
|
||||
member=self.me,
|
||||
event__club=self.request.club,
|
||||
event__cancelled=False,
|
||||
event__start__gte=now,
|
||||
).select_related("event", "event__location").order_by("event__start")
|
||||
hero_attendance = my_upcoming.first()
|
||||
if hero_attendance is not None:
|
||||
deadline = hero_attendance.event.deadline
|
||||
rsvp_closed = deadline is not None and deadline < now
|
||||
|
||||
return super().get_context_data(
|
||||
squad_count=squad_count,
|
||||
session_event=session_event,
|
||||
tonight_event=tonight_event,
|
||||
in_count=in_count,
|
||||
silent_count=silent_count,
|
||||
needs_you=needs_you,
|
||||
hero_attendance=hero_attendance,
|
||||
rsvp_closed=rsvp_closed,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class CoachAttendanceView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
|
||||
"""C2 -- bench attendance: check off who actually showed up to one event,
|
||||
separate from their RSVP. Writes through
|
||||
events.services.attendance.record_check_in, built for exactly this and
|
||||
previously uncalled anywhere in the codebase.
|
||||
|
||||
Read-only for staff without a management position on the active team --
|
||||
can_manage_active_team hides the two-state control and the Save button in
|
||||
the template (hide, don't disable), and ``post`` 403s regardless, since a
|
||||
hidden control is still a client-side fact, not a real permission check.
|
||||
"""
|
||||
|
||||
template_name = "mobile/coach/attendance.html"
|
||||
screen_title = _("Attendance")
|
||||
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"}
|
||||
|
||||
def get_event(self):
|
||||
if self.active_team is None:
|
||||
raise Http404
|
||||
return get_object_or_404(Event, pk=self.kwargs["event_id"], club=self.request.club, teams=self.active_team)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
event = self.get_event()
|
||||
season = current_season(self.request.club)
|
||||
|
||||
memberships_by_member = {}
|
||||
if season is not None:
|
||||
memberships_by_member = {tm.member_id: tm for tm in TeamMembership.objects.filter(team=self.active_team, season=season).select_related("position")}
|
||||
|
||||
attendances = list(Attendance.objects.filter(event=event).select_related("member").order_by("member__last_name", "member__first_name"))
|
||||
for attendance in attendances:
|
||||
attendance.membership = memberships_by_member.get(attendance.member_id)
|
||||
attendance.is_silent = attendance.status == Attendance.AttendanceStatus.NO_RESPONSE
|
||||
|
||||
filter_param = self.request.GET.get("filter")
|
||||
if filter_param not in self.FILTERS:
|
||||
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
|
||||
|
||||
return super().get_context_data(
|
||||
event=event,
|
||||
rows=rows,
|
||||
total_count=len(attendances),
|
||||
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,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
event = self.get_event()
|
||||
if not self.can_manage_active_team:
|
||||
return HttpResponseForbidden()
|
||||
|
||||
checked_in = 0
|
||||
for attendance in Attendance.objects.filter(event=event):
|
||||
value = request.POST.get(f"showed_up_{attendance.pk}")
|
||||
if value in ("true", "false"):
|
||||
record_check_in(attendance, showed_up=value == "true")
|
||||
checked_in += 1
|
||||
|
||||
title = _("Attendance saved")
|
||||
body = _("%(count)d players checked in.") % {"count": checked_in}
|
||||
notify(request, f"s|{title}|{body}")
|
||||
return HttpResponseRedirect(reverse("mobile:coach_today"))
|
||||
@@ -7,7 +7,7 @@ them) don't each re-derive this.
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from club.services.access import current_season, has_management_access
|
||||
from club.services.access import current_season, has_management_access, teams_staffed_by
|
||||
from members.models import FamilyMembership, Member
|
||||
from members.views import ClubScopedPublicMixin
|
||||
from notifications.models import Notification
|
||||
@@ -89,6 +89,12 @@ class PersonScopeMixin(ClubScopedPublicMixin):
|
||||
scope_person=self.scope_person,
|
||||
scope_everyone=self.scope_everyone,
|
||||
has_staff_access=self.me is not None and has_management_access(self.request.user, self.request.club),
|
||||
# Narrower than has_staff_access above: an ADMIN/EDITOR with no
|
||||
# personal StaffAssignment satisfies that (desktop management
|
||||
# access), but the design doc is explicit the Coach/Member
|
||||
# switcher itself only appears for an account holding an actual
|
||||
# staff assignment -- see mobile/coach_mixins.py's CoachScopeMixin.
|
||||
has_coach_access=self.me is not None and teams_staffed_by(self.request.user, self.request.club).exists(),
|
||||
unread_notification_count=unread_notification_count,
|
||||
season=current_season(self.request.club),
|
||||
vapid_public_key=settings.VAPID_PUBLIC_KEY,
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
file banner) rather than the iOS bezel in the design canvas, which is
|
||||
presentation-only.
|
||||
|
||||
The Coach/Member role switcher described in the design doc is deliberately
|
||||
NOT rendered yet: it would switch into a mode with no screens built (Coach
|
||||
mode is an explicitly separate, later phase). Re-add it here once C1-C6 ship.
|
||||
The Coach/Member role switcher renders once has_coach_access is true (an
|
||||
account with >=1 current-season staff assignment -- mobile/mixins.py's
|
||||
PersonScopeMixin) -- Coach mode (C1-C6, mobile/coach_views.py) is being
|
||||
built out screen by screen; only what's actually shipped is linked to.
|
||||
{% endcomment %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ LANGUAGE_CODE|default:"en" }}">
|
||||
@@ -74,6 +75,12 @@
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
{% if has_coach_access %}
|
||||
<div class="role-switcher">
|
||||
<a class="role-switcher-item role-switcher-item-active" href="{% url "mobile:home" %}">{% trans "Member" %}</a>
|
||||
<a class="role-switcher-item" href="{% url "mobile:coach_today" %}">{% trans "Coach" %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% block header_extra %}{% endblock header_extra %}
|
||||
</header>
|
||||
|
||||
|
||||
72
mobile/templates/mobile/coach/attendance.html
Normal file
72
mobile/templates/mobile/coach/attendance.html
Normal file
@@ -0,0 +1,72 @@
|
||||
{% extends "mobile/coach/base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% comment %}
|
||||
C2 -- design_handoff_rosterchief_platform/README.md's C2 section: check
|
||||
off who actually showed up, a separate axis from their RSVP (see
|
||||
CoachAttendanceView's own docstring). The mock's "fixed white footer"
|
||||
Save button is a plain in-flow button instead -- every other coach/member
|
||||
sub-page in this codebase keeps its action inline rather than adding a
|
||||
second fixed bar above the shell's own tab bar.
|
||||
{% endcomment %}
|
||||
|
||||
{% block header_extra %}
|
||||
<div class="mt-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-display text-xl leading-none font-extrabold text-white uppercase">{% trans "Attendance" %}</span>
|
||||
<span class="font-mono text-sm text-on-dark">{{ checked_in_count }}/{{ total_count }}</span>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-on-dark-dim">{{ event.title }} · {{ event.start|date:"D d M H:i" }}</div>
|
||||
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-steel">
|
||||
<div class="h-full bg-ice" style="width: {% widthratio checked_in_count total_count|default:1 100 %}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock header_extra %}
|
||||
|
||||
{% 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 }}
|
||||
</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 %}">
|
||||
{% csrf_token %}
|
||||
<div class="m-card flex flex-col overflow-hidden">
|
||||
{% for row in rows %}
|
||||
<div class="flex items-center gap-3 px-4 py-2.5 {% if not forloop.last %}border-b border-rule{% endif %} {% if row.is_silent %}bg-warn-bg{% endif %}" {% if can_manage_active_team %}x-data="{ state: '{{ row.showed_up|yesno:'true,false,' }}' }"{% endif %}>
|
||||
<div class="w-8 shrink-0 text-center font-display text-lg font-extrabold text-ink tabular-nums">{{ row.membership.jersey_number|default:"—" }}</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-semibold text-ink">{{ row.member.get_full_name }}</div>
|
||||
<div class="text-xs text-muted">{{ row.membership.position|default:"—" }}</div>
|
||||
</div>
|
||||
{% if can_manage_active_team %}
|
||||
<input type="hidden" name="showed_up_{{ row.pk }}" :value="state">
|
||||
<div class="flex h-11 w-23 shrink-0 overflow-hidden rounded-lg">
|
||||
<button type="button" class="flex-1" :class="state === 'true' ? 'bg-ok text-white' : 'bg-rule text-dim'" @click="state = 'true'" aria-label="{% trans "In" %}">
|
||||
<svg class="mx-auto" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
|
||||
</button>
|
||||
<button type="button" class="flex-1" :class="state === 'false' ? 'bg-club text-white' : 'bg-rule text-dim'" @click="state = 'false'" aria-label="{% trans "Out" %}">
|
||||
<svg class="mx-auto" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
{% elif row.showed_up is not None %}
|
||||
<span class="pill {% if row.showed_up %}pill-ok{% else %}pill-danger{% endif %} shrink-0">{% if row.showed_up %}{% trans "In" %}{% else %}{% trans "Out" %}{% endif %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="px-4 py-6 text-center text-sm text-muted">{% trans "No one matches this filter." %}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if can_manage_active_team %}
|
||||
<button class="btn btn-dark mt-4 w-full" type="submit">{% trans "Save attendance" %}</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endblock content %}
|
||||
117
mobile/templates/mobile/coach/base.html
Normal file
117
mobile/templates/mobile/coach/base.html
Normal file
@@ -0,0 +1,117 @@
|
||||
{% load static i18n %}
|
||||
|
||||
{% comment %}
|
||||
App shell for Coach mode (C1-C6) -- design_handoff_rosterchief_platform/README.md's
|
||||
"Coach mode (mobile, dark chrome)" section. Standalone from mobile/templates/mobile/
|
||||
base.html (Member mode's shell) rather than a shared parent: the two have almost no
|
||||
markup in common beyond the outer <html>/<body> skeleton -- dark ink header instead
|
||||
of navy, a light .coach-sheet body that overlaps the header by 20px (the mode's own
|
||||
signature, see assets/mobile.css's own comment), and a dark .coach-tab-bar instead of
|
||||
the white one.
|
||||
|
||||
Reuses the SAME stylesheet (static/css/mobile.css) and the same mobile:manifest/
|
||||
mobile:icon/mobile:service_worker PWA plumbing as Member mode -- one app, one
|
||||
manifest, two modes, not two separate PWAs.
|
||||
|
||||
The tab bar only ever links to screens that actually exist. Today (C1) is the only
|
||||
coach screen built so far, so it's the only coach item -- Me reuses the *existing*
|
||||
member mobile:me page rather than a separate coach-Me screen (see CoachTodayView's
|
||||
own docstring). No dead links: as C2/C4/C5/C6 land, they get their own tab/entry
|
||||
point then, not stubbed in ahead of time (same principle mobile/templates/mobile/
|
||||
me.html's own comment already applies to "Household & contacts"/"Coach mode" before
|
||||
this existed).
|
||||
{% endcomment %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ LANGUAGE_CODE|default:"en" }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0b1220">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
|
||||
<title>{% block title %}{{ screen_title }} · {{ club.name }}{% endblock title %}</title>
|
||||
|
||||
<link rel="manifest" href="{% url "mobile:manifest" %}">
|
||||
<link rel="apple-touch-icon" href="{% url "mobile:icon" size=192 %}">
|
||||
<link rel="icon" href="{% url "mobile:icon" size=192 %}">
|
||||
<link rel="stylesheet" href="{% static "css/mobile.css" %}">
|
||||
|
||||
{% if club.secondary_color %}
|
||||
<style>
|
||||
:root {
|
||||
--tenant-club: {{ club.secondary_color }};
|
||||
--tenant-club-dark: color-mix(in srgb, {{ club.secondary_color }} 80%, black);
|
||||
--tenant-club-content: {{ club.secondary_content_color }};
|
||||
}
|
||||
</style>
|
||||
{% endif %}
|
||||
|
||||
<script src="{% static "js/htmx.js" %}" defer></script>
|
||||
<script src="{% static "js/alpine.js" %}" defer></script>
|
||||
|
||||
{% block extra_head %}{% endblock extra_head %}
|
||||
</head>
|
||||
|
||||
<body class="flex h-screen flex-col overflow-hidden bg-ink font-sans text-slate" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' data-csrftoken="{{ csrf_token }}">
|
||||
<header class="coach-header">
|
||||
<div class="flex items-center gap-2.5">
|
||||
{% if club.logo %}
|
||||
<img class="app-crest" src="{{ club.logo.url }}" alt="">
|
||||
{% else %}
|
||||
<span class="app-crest app-crest-fallback">{{ club.initials }}</span>
|
||||
{% endif %}
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate font-display text-[19px] leading-none font-extrabold text-white uppercase">{{ active_team.name|default:club.name }}</span>
|
||||
{% if me %}<span class="block font-mono text-xs text-on-dark">{% trans "Head coach" %} · {{ me.first_name }}</span>{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if staffed_teams|length > 1 %}
|
||||
<div class="mt-3 flex gap-2 overflow-x-auto scrollbar-hide">
|
||||
{% for team in staffed_teams %}
|
||||
<a class="shrink-0 rounded-full px-3 py-1.5 font-display text-xs font-extrabold tracking-wide uppercase {% if team == active_team %}bg-ice text-ice-ink{% else %}bg-steel text-on-dark{% endif %}" href="?team={{ team.pk }}">
|
||||
{{ team.short_name }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="role-switcher">
|
||||
<a class="role-switcher-item" href="{% url "mobile:home" %}">{% trans "Member" %}</a>
|
||||
<a class="role-switcher-item role-switcher-item-active" href="{% url "mobile:coach_today" %}">{% trans "Coach" %}</a>
|
||||
</div>
|
||||
|
||||
{% block header_extra %}{% endblock header_extra %}
|
||||
</header>
|
||||
|
||||
<main class="coach-sheet">
|
||||
<div class="flex flex-col gap-4 px-4 py-5">
|
||||
{% if messages %}
|
||||
<div class="flex flex-col gap-2">
|
||||
{% for message in messages %}
|
||||
<div class="m-card p-3 text-sm font-medium text-ink">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% block content %}{% endblock content %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<nav class="coach-tab-bar">
|
||||
<a class="coach-tab-bar-item {% if active_tab == "coach_today" %}coach-tab-bar-item-active{% endif %}" href="{% url "mobile:coach_today" %}">
|
||||
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9.5 12 3l9 6.5V21a1 1 0 0 1-1 1h-5v-7H9v7H4a1 1 0 0 1-1-1Z"/></svg>
|
||||
<span class="tab-bar-label">{% trans "Today" %}</span>
|
||||
</a>
|
||||
<a class="coach-tab-bar-item" href="{% url "mobile:me" %}">
|
||||
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8"/></svg>
|
||||
<span class="tab-bar-label">{% trans "Me" %}</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<script src="{% static "js/mobile-app.js" %}" defer></script>
|
||||
{% block extra_body %}{% endblock extra_body %}
|
||||
</body>
|
||||
</html>
|
||||
99
mobile/templates/mobile/coach/today.html
Normal file
99
mobile/templates/mobile/coach/today.html
Normal file
@@ -0,0 +1,99 @@
|
||||
{% extends "mobile/coach/base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% comment %}
|
||||
C1 -- design_handoff_rosterchief_platform/README.md's C1 section: three
|
||||
stat tiles, a tonight's-session card (only rendered when one exists),
|
||||
a "needs you" list, and an "Also yours" card for the coach's own
|
||||
member-side RSVP. See CoachTodayView's own docstring for what's scoped
|
||||
down from the mock and why.
|
||||
{% endcomment %}
|
||||
|
||||
{% block content %}
|
||||
{% if not staffed_teams %}
|
||||
<div class="m-card p-6 text-center">
|
||||
<p class="font-display text-lg font-extrabold text-ink uppercase">{% trans "Not staffing a team yet" %}</p>
|
||||
<p class="mt-1 text-sm text-muted">{% trans "Once you're assigned to a team's staff, its schedule and roster will show up here." %}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="grid grid-cols-3 gap-2.5">
|
||||
<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>
|
||||
</div>
|
||||
<div class="m-card p-3 text-center">
|
||||
<div class="font-display text-2xl leading-none font-extrabold text-ink">{{ in_count }}</div>
|
||||
<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="mt-1 font-display text-[10px] font-extrabold tracking-wide text-muted uppercase">{% trans "Silent" %}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if tonight_event %}
|
||||
<div class="m-card-dark overflow-hidden">
|
||||
<div class="flex items-center justify-between px-4 py-2.5">
|
||||
<span class="font-display text-xs font-extrabold text-ice uppercase tracking-wide">{% blocktrans with time=tonight_event.start|date:"H:i" %}Tonight · {{ time }}{% endblocktrans %}</span>
|
||||
</div>
|
||||
<div class="px-4 pb-4">
|
||||
<p class="font-display text-xl leading-none font-extrabold uppercase">{{ tonight_event.title }}</p>
|
||||
{% if can_manage_active_team %}
|
||||
<a class="btn mt-3 w-full bg-ice text-ice-ink" href="{% url "mobile:coach_attendance" tonight_event.pk %}">{% trans "Check attendance" %}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if needs_you %}
|
||||
<div class="m-card overflow-hidden">
|
||||
<div class="border-b border-line px-4 py-3 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Needs you" %}</div>
|
||||
{% for item in needs_you %}
|
||||
<div class="flex items-center gap-3 border-l-4 {% if item.severity == "club" %}border-club{% elif item.severity == "ice" %}border-ice{% else %}border-warn{% endif %} px-4 py-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-semibold text-ink">{{ item.title }}</div>
|
||||
<div class="text-xs text-muted">{{ item.detail }}</div>
|
||||
</div>
|
||||
{% if item.severity == "warn" and session_event and can_manage_active_team %}
|
||||
<a class="shrink-0 font-display text-xs font-extrabold tracking-wide text-club uppercase" href="{% url "mobile:coach_attendance" session_event.pk %}">{% trans "Review" %}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if hero_attendance %}
|
||||
<div>
|
||||
<div class="mb-2.5 font-display text-xs font-extrabold text-muted uppercase tracking-wide">{% trans "Also yours" %}</div>
|
||||
<div class="m-card-dark p-4">
|
||||
<p class="font-display text-xs font-extrabold text-info uppercase tracking-wide">{% blocktrans with date=hero_attendance.event.start|date:"D d M H:i" %}Your own next event · {{ date }}{% endblocktrans %}</p>
|
||||
<p class="mt-1 font-display text-xl leading-none font-extrabold uppercase">{{ hero_attendance.event.title }}</p>
|
||||
{% if rsvp_closed %}
|
||||
<span class="pill pill-neutral mt-3">{{ hero_attendance.get_status_display }}</span>
|
||||
{% else %}
|
||||
<div class="mt-3 flex gap-2">
|
||||
<form class="flex-1" method="post" action="{% url "mobile:event_detail" hero_attendance.event.pk %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="member_id" value="{{ hero_attendance.member.pk }}">
|
||||
<input type="hidden" name="status" value="present">
|
||||
<button class="btn btn-positive w-full" type="submit">{% trans "In" %}</button>
|
||||
</form>
|
||||
<form class="flex-1" method="post" action="{% url "mobile:event_detail" hero_attendance.event.pk %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="member_id" value="{{ hero_attendance.member.pk }}">
|
||||
<input type="hidden" name="status" value="absent">
|
||||
<button class="btn w-full bg-steel text-on-dark" type="submit">{% trans "Out" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not tonight_event and not needs_you and not hero_attendance %}
|
||||
<div class="m-card p-6 text-center">
|
||||
<p class="text-sm text-muted">{% trans "Nothing needs your attention right now." %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock content %}
|
||||
265
mobile/tests.py
265
mobile/tests.py
@@ -87,6 +87,25 @@ class MobileShellTests(TestCase):
|
||||
self.assertIn("javascript", response["Content-Type"])
|
||||
self.assertIn("addEventListener", response.content.decode())
|
||||
|
||||
def test_mode_switcher_shows_for_an_account_with_a_staff_assignment(self):
|
||||
today = timezone.localdate()
|
||||
current_season = Season.objects.create(club=self.club, start_date=today - datetime.timedelta(days=10), end_date=today + datetime.timedelta(days=300))
|
||||
team = Team.objects.create(club=self.club, name="U16", short_name="U16")
|
||||
position = Position.objects.create(club=self.club, name="Head coach", short_name="HC", staff_position=True, management_position=True)
|
||||
StaffAssignment.objects.create(team=team, member=self.member, season=current_season, position=position)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self._get("home")
|
||||
|
||||
self.assertContains(response, reverse("mobile:coach_today"))
|
||||
|
||||
def test_mode_switcher_hidden_without_a_staff_assignment(self):
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self._get("home")
|
||||
|
||||
self.assertNotContains(response, reverse("mobile:coach_today"))
|
||||
|
||||
def test_person_switcher_lists_managed_children_alongside_me(self):
|
||||
family = Family.objects.create(name="Bakker")
|
||||
FamilyMembership.objects.create(family=family, member=self.member, role=FamilyMembership.FamilyRole.PARENT)
|
||||
@@ -1702,3 +1721,249 @@ class CalendarFeedSettingsViewTests(TestCase):
|
||||
self.assertRedirects(response, reverse("mobile:calendar_feed_settings"), fetch_redirect_response=False)
|
||||
old_token.refresh_from_db()
|
||||
self.assertNotEqual(old_token.token, old_value)
|
||||
|
||||
|
||||
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
|
||||
class CoachTodayViewTests(TestCase):
|
||||
"""C1 -- design_handoff_rosterchief_platform/README.md's C1 section, plus
|
||||
mobile/coach_mixins.py's CoachScopeMixin (team resolution, session
|
||||
persistence, can_manage_active_team) exercised through this, the first
|
||||
Coach-mode screen."""
|
||||
|
||||
@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.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.position)
|
||||
|
||||
def _get(self, **params):
|
||||
url = reverse("mobile:coach_today")
|
||||
if params:
|
||||
url += "?" + "&".join(f"{key}={value}" for key, value in params.items())
|
||||
return self.client.get(url, HTTP_HOST="ajax-united.rosterchief.app")
|
||||
|
||||
def test_requires_login(self):
|
||||
response = self._get()
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
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")
|
||||
self.client.force_login(bare_user)
|
||||
|
||||
response = self._get()
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Not staffing a team yet")
|
||||
|
||||
def test_shows_the_active_team_and_squad_count(self):
|
||||
other_member = Member.objects.create(first_name="Anna", last_name="Player")
|
||||
TeamMembership.objects.create(team=self.team, member=self.member, season=self.season)
|
||||
TeamMembership.objects.create(team=self.team, member=other_member, season=self.season)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self._get()
|
||||
|
||||
self.assertContains(response, "U16")
|
||||
self.assertEqual(response.context["squad_count"], 2)
|
||||
|
||||
def test_defaults_to_the_first_staffed_team_with_no_prior_selection(self):
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self._get()
|
||||
|
||||
self.assertEqual(response.context["active_team"], self.team)
|
||||
|
||||
def test_team_query_param_switches_and_persists_the_active_team(self):
|
||||
second_team = Team.objects.create(club=self.club, name="U14", short_name="U14")
|
||||
StaffAssignment.objects.create(team=second_team, member=self.member, season=self.season, position=self.position)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self._get(team=second_team.pk)
|
||||
self.assertEqual(response.context["active_team"], second_team)
|
||||
|
||||
# No ?team= this time -- the session should keep it on the second team.
|
||||
response = self._get()
|
||||
self.assertEqual(response.context["active_team"], second_team)
|
||||
|
||||
def test_tonights_session_card_shows_for_an_event_starting_today(self):
|
||||
# A small offset, not "+2 hours" -- late enough in the evening (this
|
||||
# environment is UTC+2), a couple hours out would roll into tomorrow
|
||||
# and silently break the "starts today" premise this test is about.
|
||||
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)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self._get()
|
||||
|
||||
self.assertEqual(response.context["tonight_event"], event)
|
||||
self.assertContains(response, "Practice")
|
||||
|
||||
def test_no_session_today_omits_the_tonight_card(self):
|
||||
event = Event.objects.create(club=self.club, title="Next week", kind=Event.EventKind.TRAINING, start=timezone.now() + datetime.timedelta(days=5))
|
||||
event.teams.add(self.team)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self._get()
|
||||
|
||||
self.assertIsNone(response.context["tonight_event"])
|
||||
self.assertEqual(response.context["session_event"], event)
|
||||
self.assertNotContains(response, "Tonight")
|
||||
|
||||
def test_silent_players_are_counted_and_listed_in_needs_you(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.NO_RESPONSE})
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self._get()
|
||||
|
||||
self.assertEqual(response.context["silent_count"], 1)
|
||||
self.assertContains(response, "Silent players")
|
||||
|
||||
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")
|
||||
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)
|
||||
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)
|
||||
self.client.force_login(physio_user)
|
||||
|
||||
response = self._get()
|
||||
|
||||
self.assertFalse(response.context["can_manage_active_team"])
|
||||
self.assertNotContains(response, "Check attendance")
|
||||
|
||||
def test_also_yours_card_shows_the_coachs_own_rsvp(self):
|
||||
event = Event.objects.create(club=self.club, title="My own game", kind=Event.EventKind.GAME, start=timezone.now() + datetime.timedelta(days=1))
|
||||
Attendance.objects.create(event=event, member=self.member, status=Attendance.AttendanceStatus.NO_RESPONSE)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self._get()
|
||||
|
||||
self.assertContains(response, "Also yours")
|
||||
self.assertContains(response, "My own game")
|
||||
|
||||
|
||||
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
|
||||
class CoachAttendanceViewTests(TestCase):
|
||||
"""C2 -- bench attendance: events.services.attendance.record_check_in
|
||||
written through for the first time anywhere in the codebase."""
|
||||
|
||||
@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.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.position)
|
||||
|
||||
cls.player = Member.objects.create(first_name="Anna", last_name="Player")
|
||||
cls.player_membership = TeamMembership.objects.create(team=cls.team, member=cls.player, season=cls.season, jersey_number=9)
|
||||
cls.event = Event.objects.create(club=cls.club, title="Practice", kind=Event.EventKind.TRAINING, start=timezone.now() + datetime.timedelta(hours=2))
|
||||
cls.event.teams.add(cls.team)
|
||||
cls.attendance, _created = Attendance.objects.update_or_create(event=cls.event, member=cls.player, defaults={"status": Attendance.AttendanceStatus.PRESENT})
|
||||
|
||||
def _get(self, **params):
|
||||
url = reverse("mobile:coach_attendance", kwargs={"event_id": self.event.pk})
|
||||
if params:
|
||||
url += "?" + "&".join(f"{key}={value}" for key, value in params.items())
|
||||
return self.client.get(url, HTTP_HOST="ajax-united.rosterchief.app")
|
||||
|
||||
def test_requires_login(self):
|
||||
response = self._get()
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_shows_the_roster_with_jersey_numbers(self):
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self._get()
|
||||
|
||||
self.assertContains(response, "Anna Player")
|
||||
self.assertContains(response, "9")
|
||||
|
||||
def test_save_records_check_ins_via_record_check_in(self):
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("mobile:coach_attendance", kwargs={"event_id": self.event.pk}),
|
||||
{f"showed_up_{self.attendance.pk}": "true"},
|
||||
HTTP_HOST="ajax-united.rosterchief.app",
|
||||
)
|
||||
|
||||
self.assertRedirects(response, reverse("mobile:coach_today"), fetch_redirect_response=False)
|
||||
self.attendance.refresh_from_db()
|
||||
self.assertTrue(self.attendance.showed_up)
|
||||
|
||||
def test_non_managing_staff_cannot_save(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_attendance", kwargs={"event_id": self.event.pk}),
|
||||
{f"showed_up_{self.attendance.pk}": "true"},
|
||||
HTTP_HOST="ajax-united.rosterchief.app",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.attendance.refresh_from_db()
|
||||
self.assertIsNone(self.attendance.showed_up)
|
||||
|
||||
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="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._get()
|
||||
|
||||
self.assertNotContains(response, "Save attendance")
|
||||
|
||||
def test_silent_filter_narrows_to_no_response_rows(self):
|
||||
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})
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self._get(filter="silent")
|
||||
|
||||
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})
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self._get(filter="goalies")
|
||||
|
||||
rows = response.context["rows"]
|
||||
self.assertEqual([row.member for row in rows], [goalie])
|
||||
|
||||
def test_event_from_another_team_is_not_reachable(self):
|
||||
other_team = Team.objects.create(club=self.club, name="U14", short_name="U14")
|
||||
other_event = Event.objects.create(club=self.club, title="Other practice", kind=Event.EventKind.TRAINING, start=timezone.now() + datetime.timedelta(hours=2))
|
||||
other_event.teams.add(other_team)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.get(reverse("mobile:coach_attendance", kwargs={"event_id": other_event.pk}), HTTP_HOST="ajax-united.rosterchief.app")
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
from . import coach_views, views
|
||||
|
||||
app_name = "mobile"
|
||||
|
||||
@@ -22,4 +22,7 @@ urlpatterns = [
|
||||
path("me/calendar-sync/", views.CalendarFeedSettingsView.as_view(), name="calendar_feed_settings"),
|
||||
path("me/<uuid:member_id>/edit/", views.EditProfileView.as_view(), name="edit_profile"),
|
||||
path("notifications/", views.NotificationsView.as_view(), name="notifications"),
|
||||
# Coach mode (C1-C6).
|
||||
path("coach/", coach_views.CoachTodayView.as_view(), name="coach_today"),
|
||||
path("coach/attendance/<uuid:event_id>/", coach_views.CoachAttendanceView.as_view(), name="coach_attendance"),
|
||||
]
|
||||
|
||||
@@ -4067,6 +4067,9 @@
|
||||
.w-20 {
|
||||
width: calc(var(--spacing) * 20);
|
||||
}
|
||||
.w-23 {
|
||||
width: calc(var(--spacing) * 23);
|
||||
}
|
||||
.w-24 {
|
||||
width: calc(var(--spacing) * 24);
|
||||
}
|
||||
@@ -4847,6 +4850,9 @@
|
||||
.pb-3 {
|
||||
padding-bottom: calc(var(--spacing) * 3);
|
||||
}
|
||||
.pb-4 {
|
||||
padding-bottom: calc(var(--spacing) * 4);
|
||||
}
|
||||
.pb-5 {
|
||||
padding-bottom: calc(var(--spacing) * 5);
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user