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:
2026-08-21 22:21:25 +02:00
parent 9fbcc89646
commit 6a9a6204ed
12 changed files with 873 additions and 6 deletions

71
mobile/coach_mixins.py Normal file
View 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
View 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"))

View File

@@ -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,

View File

@@ -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>

View 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 }} &middot; {{ 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 %}

View 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 }} &middot; {{ 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" %} &middot; {{ 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>

View 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 &middot; {{ 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 &middot; {{ 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 %}

View File

@@ -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)

View File

@@ -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"),
]