Files
RosterChief/mobile/coach_views.py
Bernard Siebens 1d159ca5de Redesign the Coach mode shell: ice-blue header, flat sheet, Squad/Schedule tabs
Reworks the coach shell per feedback that the previous dark-ink header
didn't read as visually distinct from Member mode's own navy header, and
that the rounded "sheet overlaps header" treatment wasn't landing:

- .coach-header is now solid ice-blue (--color-ice, never club-themed --
  Coach mode's own signature colour) with --color-ice-ink foreground text,
  instead of dark ink with white text. Every header_extra block across the
  coach templates (attendance, lineup) is recoloured to match.
- .coach-sheet drops the 20px rounded-top/negative-margin overlap -- flush
  edge-to-edge below the header now, same join the member shell already uses.
- The header's "Head coach" label was hardcoded regardless of the account's
  actual role -- CoachScopeMixin now resolves active_team_role from the
  person's own current-season StaffAssignment.position on the active team,
  so a team manager or physio sees their real title, not someone else's.

Tab bar is now Today / Squad / [+] / Schedule -- no Me tab (the account's
own settings already live in Member mode via the role switcher, no need
for a second one). Squad (CoachSquadView) is a new roster+staff screen for
the active team; Schedule (CoachScheduleView) is a new full upcoming-events
list for the team, each row routed straight to the coach-relevant action
(Attendance for a practice, Line-up for a game) rather than the Member-
shell RSVP page. The "+" is a raised ice-blue circle opening a small popup
with New event/New post/Add player -- three genuinely different actions,
so the button opens a menu rather than committing to one destination.
Today's own inline New event/New post/Add player buttons are gone now that
the tab bar covers the same ground.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 23:21:27 +02:00

581 lines
28 KiB
Python

"""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 import forms
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.utils.translation import ngettext
from django.views.generic import TemplateView, View
from club.models import Season
from club.services.access import can_add_news, current_season
from controlpanel.messages import notify
from events.models import Attendance, Event, Lineup, LineupSlot, LineupUnit
from events.services.attendance import record_check_in
from events.services.lineup import UNAVAILABLE_STATUSES, clear_slot, place_member, publish_lineup
from events.tasks import notify_new_event
from management.forms import EventForm, NewsForm
from members.models import Member
from news.models import News
from news.services import notify_editors_of_pending_review
from teams.models import StaffAssignment, Team, TeamMembership
from teams.services import eligible_roster_members
from .coach_mixins import CoachScopeMixin
from .forms import _INPUT_CLASSES
#: 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 and (for a game) an unpublished-
line-up flag for the next session. The mock's member-blocker row stays
deferred -- no coach-facing member-edit screen exists yet to link to.
"""
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}})
if session_event.kind == Event.EventKind.GAME:
lineup = Lineup.objects.filter(event=session_event).first()
if lineup is None or lineup.published_at is None:
needs_you.append({"severity": "club", "title": _("Line-up not published"), "detail": _("Build it before the game.")})
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"))
class CoachCreateEventView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
"""C4 -- reuses management.forms.EventForm as-is: its own __init__ already
scopes ``teams`` to teams_managed_by(user, club) via EventAudienceFormMixin,
exactly the restriction a coach needs, so there's nothing to re-scope.
Only a subset of the mock's fields is rendered in the template (Title,
Kind, Teams, Location, Start, Answers close) -- everything else EventForm
carries (groups/club_wide/invited & excluded members/opponent/
competition/external id) stays unrendered and simply unset; all of it is
optional on the model, so an unrendered field validates cleanly empty.
"Repeat weekly" from the mock isn't built this stage -- the recurring-
series machinery (EventSeriesForm) is a separate form with its own
fields; wiring it in is later work, not something to fake with an inert
toggle here.
After a successful save: the same notify_new_event.delay(...) call
management.views.EventCreateView.form_valid makes -- attendance sync is
automatic via events/signals.py, only the notification dispatch needs
replicating by hand for a view that isn't a CreateView.
"""
template_name = "mobile/coach/event_form.html"
screen_title = _("New event")
active_tab = "coach_today"
def get(self, request, *args, **kwargs):
if not self.can_manage_active_team:
return HttpResponseRedirect(reverse("mobile:coach_today"))
return super().get(request, *args, **kwargs)
def build_form(self, data=None):
instance = Event(club=self.request.club, created_by=self.me)
form = EventForm(data, club=self.request.club, user=self.request.user, editing=False, instance=instance)
# max_referees has a model default (2) but no blank=True, so the form
# field is required despite it -- delete it rather than render a
# referee-count control this screen has no use for; construct_instance
# skips deleted fields entirely, leaving the instance's own default.
del form.fields["max_referees"]
# The desktop searchable multi-select relies on management's own JS
# widget, not loaded here -- plain checkboxes work without it and
# read better on a phone regardless.
form.fields["teams"].widget = forms.CheckboxSelectMultiple()
if self.active_team is not None and data is None:
form.fields["teams"].initial = [self.active_team.pk]
# Same input styling as mobile.forms.MemberProfileForm (M6) -- one
# visual language for every text/date field across the app, not a
# diverging one for this screen.
for field_name in ("title", "start", "location", "deadline"):
form.fields[field_name].widget.attrs["class"] = _INPUT_CLASSES
return form
def get_context_data(self, **kwargs):
kwargs.setdefault("form", self.build_form())
return super().get_context_data(**kwargs)
def post(self, request, *args, **kwargs):
if not self.can_manage_active_team:
return HttpResponseForbidden()
form = self.build_form(request.POST)
if not form.is_valid():
return self.render_to_response(self.get_context_data(form=form))
event = form.save()
body = _("%(event)s” created.") % {"event": event}
notify(request, f"s|{_('Event created')}|{body}")
# A deliberately-planned single event, same as the desktop create
# flow -- see notify_new_event's own docstring for why a recurring
# series' occurrences aren't wired to this.
notify_new_event.delay(str(event.pk))
return HttpResponseRedirect(reverse("mobile:coach_today"))
class CoachCreateNewsView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
"""C5 -- reuses management.forms.NewsForm, re-scoped to the coach's own
managed team(s). NewsForm.__init__ defaults ``teams`` to every club team
-- fine for an editor/admin, but a real gap for a coach, who should only
ever be able to post as their own team, never "on behalf of" one they
don't run.
Gated with club.services.access.can_add_news, which already includes
is_coach_manager -- no new authorization logic needed. On submit, the
post is handed straight to News.submit_for_review() (plus the same
notify_editors_of_pending_review call the desktop's own
NewsSubmitForReviewView makes) rather than left a silent draft, so it
actually reaches an editor's queue -- can_publish_news stays editor/
admin-only, so "Send for review" is the honest label here, not "Publish"
the way the design mock has it.
title_en/body_en (the optional English fallback) and a cover photo
aren't part of this screen -- both text fields are blank=True on the
model (a coach posting from their phone isn't expected to also draft an
English translation), and News has no image field for a cover at all to
begin with. ``visibility`` is left at the model's own default
(INTERNAL -- team families, in-app only) rather than building the mock's
"also on club website" toggle: a coach's post always lands as
PENDING_REVIEW first, and an editor reviewing it can widen visibility
before publishing if a public-site placement is actually warranted --
that's a real gate, not a decorative row, so it isn't reproduced here as
one.
"""
template_name = "mobile/coach/news_form.html"
screen_title = _("New post")
active_tab = "coach_today"
def get(self, request, *args, **kwargs):
if not can_add_news(request.user, request.club):
return HttpResponseRedirect(reverse("mobile:coach_today"))
return super().get(request, *args, **kwargs)
def build_form(self, data=None):
instance = News(club=self.request.club, created_by=self.me)
form = NewsForm(data, club=self.request.club, instance=instance)
del form.fields["title_en"]
del form.fields["body_en"]
del form.fields["visibility"]
# A coach's post is always about their own team(s) -- never empty
# (which News.teams's own help_text defines as "club-wide", an
# editor/admin-only claim), and never any other team in the club.
form.fields["teams"].queryset = Team.objects.filter(pk__in=[team.pk for team in self.managed_teams])
form.fields["teams"].required = True
form.fields["teams"].widget = forms.CheckboxSelectMultiple()
if self.active_team is not None and data is None:
form.fields["teams"].initial = [self.active_team.pk]
for field_name in ("title", "body"):
form.fields[field_name].widget.attrs["class"] = _INPUT_CLASSES
return form
def get_context_data(self, **kwargs):
kwargs.setdefault("form", self.build_form())
return super().get_context_data(**kwargs)
def post(self, request, *args, **kwargs):
if not can_add_news(request.user, request.club):
return HttpResponseForbidden()
form = self.build_form(request.POST)
if not form.is_valid():
return self.render_to_response(self.get_context_data(form=form))
news_item = form.save()
news_item.submit_for_review()
notify_editors_of_pending_review(news_item)
body = _("%(news)s” is ready for review.") % {"news": news_item}
notify(request, f"s|{_('Sent for review')}|{body}")
return HttpResponseRedirect(reverse("mobile:coach_today"))
class CoachAddPlayerView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
"""C6 -- bulk-add players to the active team's roster: a checkbox per
candidate rather than management.forms.TeamMembershipForm's one-member-
at-a-time shape, which doesn't fit a "tap a few names, add them" flow
anyway (the mock itself shows plain checkboxes, no inline position
picker). A coach sets jersey number/position afterward on the desktop --
same as any roster spot added blank via the Sign-up page today
(TeamMembership.position's own help_text already documents this as a
normal, expected state, not a shortcut this screen invents).
The pool is teams.services.eligible_roster_members(club) minus whoever's
already on this team+season -- the same two rules TeamMembershipForm
applies internally, just reused directly rather than through the form.
"Suggested" (on this team last season) is real, computed data. "Age
eligible" from the mock isn't built -- neither Club nor Team carries an
age-group field to compare a birth date against, so faking that filter
would just mean it silently matched nothing.
"""
template_name = "mobile/coach/add_player.html"
screen_title = _("Add players")
active_tab = "coach_today"
#: ?filter= values this screen understands -- anything else (including no
#: param at all) means "All".
FILTERS = {"suggested", "no_team"}
def get(self, request, *args, **kwargs):
if not self.can_manage_active_team:
return HttpResponseRedirect(reverse("mobile:coach_today"))
return super().get(request, *args, **kwargs)
def _candidate_pool(self, season):
taken = TeamMembership.objects.filter(team=self.active_team, season=season).values_list("member_id", flat=True)
return eligible_roster_members(self.request.club).exclude(pk__in=taken)
def get_context_data(self, **kwargs):
season = current_season(self.request.club)
candidates = []
squad_count = 0
filter_param = self.request.GET.get("filter")
if filter_param not in self.FILTERS:
filter_param = ""
if self.active_team is not None and season is not None:
squad_count = TeamMembership.objects.filter(team=self.active_team, season=season).count()
pool = self._candidate_pool(season)
if filter_param == "no_team":
pool = pool.exclude(team_memberships__season=season)
elif filter_param == "suggested":
previous_season = Season.before(self.request.club, season)
pool = pool.filter(team_memberships__team=self.active_team, team_memberships__season=previous_season) if previous_season is not None else pool.none()
candidates = list(pool.distinct().order_by("last_name", "first_name"))
return super().get_context_data(
candidates=candidates,
squad_count=squad_count,
filter_param=filter_param,
**kwargs,
)
def post(self, request, *args, **kwargs):
if not self.can_manage_active_team:
return HttpResponseForbidden()
season = current_season(request.club)
if self.active_team is None or season is None:
return HttpResponseForbidden()
pool_ids = {str(pk) for pk in self._candidate_pool(season).values_list("pk", flat=True)}
added = 0
for member_id in request.POST.getlist("member"):
if member_id not in pool_ids:
continue
TeamMembership.objects.get_or_create(team=self.active_team, season=season, member_id=member_id)
added += 1
if added:
body = ngettext("%(count)d player added to the roster.", "%(count)d players added to the roster.", added) % {"count": added}
notify(request, f"s|{_('Roster updated')}|{body}")
return HttpResponseRedirect(reverse("mobile:coach_today"))
class CoachLineupView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
"""C3 -- a game's line-up: units (Line 1, Defence pair 1, ...) of ordered
slots, each assigned via a native <select> rather than the design mock's
drag-and-drop (see events.services.lineup's own module docstring for
why). One batch "Save line-up" submit, not a live per-tap POST -- this
codebase has no established htmx interaction pattern yet to build one on
(htmx.js is loaded but nothing uses it), and a reliable plain form beats
a first, unproven real-time interaction for a screen already this large.
Known rough edge, accepted for this stage: clicking "+ Add line"/"+ Add
slot" reloads the page via its own POST, which does NOT also save
whatever the coach had just picked in the other slots' <select>s (those
two actions don't read the assignment fields at all) -- add lines/slots
before filling them in, not after, or save first.
A Lineup is created lazily on first visit (get_or_create) -- there's no
separate "start a line-up" step. Viewing is open to anyone staffing the
team; every mutation (save/add line/add slot/publish) is gated on
can_manage_active_team, hidden in the template and 403'd here regardless.
"""
template_name = "mobile/coach/lineup.html"
screen_title = _("Line-up")
active_tab = "coach_today"
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, kind=Event.EventKind.GAME)
def get_context_data(self, **kwargs):
event = self.get_event()
lineup, _created = Lineup.objects.get_or_create(event=event, defaults={"team": self.active_team, "created_by": self.me})
units = list(lineup.units.prefetch_related("slots__member"))
available = list(Attendance.objects.filter(event=event).exclude(status__in=UNAVAILABLE_STATUSES).select_related("member").order_by("member__last_name", "member__first_name"))
unavailable = list(Attendance.objects.filter(event=event, status__in=UNAVAILABLE_STATUSES).select_related("member"))
return super().get_context_data(
event=event,
lineup=lineup,
units=units,
available=available,
unavailable=unavailable,
**kwargs,
)
def post(self, request, *args, **kwargs):
if not self.can_manage_active_team:
return HttpResponseForbidden()
event = self.get_event()
lineup, _created = Lineup.objects.get_or_create(event=event, defaults={"team": self.active_team, "created_by": self.me})
available_ids = {str(pk) for pk in Attendance.objects.filter(event=event).exclude(status__in=UNAVAILABLE_STATUSES).values_list("member_id", flat=True)}
for slot in LineupSlot.objects.filter(unit__lineup=lineup):
submitted = request.POST.get(f"slot_{slot.pk}", "")
if submitted == str(slot.member_id or ""):
continue
if not submitted:
clear_slot(slot)
elif submitted in available_ids:
place_member(lineup, slot, Member.objects.get(pk=submitted))
notify(request, f"s|{_('Line-up saved')}|{_('Your changes have been saved.')}")
return HttpResponseRedirect(reverse("mobile:coach_lineup", kwargs={"event_id": event.pk}))
class CoachLineupAddUnitView(CoachScopeMixin, LoginRequiredMixin, View):
"""A blank "+ Add line" -- one new unit, auto-labelled and seeded with a
single empty slot; grown further via CoachLineupAddSlotView. No fixed
sport structure to seed from (see LineupUnit's own docstring)."""
def post(self, request, *args, **kwargs):
if not self.can_manage_active_team:
return HttpResponseForbidden()
event = get_object_or_404(Event, pk=kwargs["event_id"], club=request.club, teams=self.active_team, kind=Event.EventKind.GAME)
lineup, _created = Lineup.objects.get_or_create(event=event, defaults={"team": self.active_team, "created_by": self.me})
ordering = lineup.units.count()
unit = LineupUnit.objects.create(lineup=lineup, label=_("Line %(number)d") % {"number": ordering + 1}, ordering=ordering)
LineupSlot.objects.create(unit=unit, ordering=0)
return HttpResponseRedirect(reverse("mobile:coach_lineup", kwargs={"event_id": event.pk}))
class CoachLineupAddSlotView(CoachScopeMixin, LoginRequiredMixin, View):
"""One more empty slot on an existing unit."""
def post(self, request, *args, **kwargs):
if not self.can_manage_active_team:
return HttpResponseForbidden()
unit = get_object_or_404(LineupUnit, pk=kwargs["unit_id"], lineup__team=self.active_team)
LineupSlot.objects.create(unit=unit, ordering=unit.slots.count())
return HttpResponseRedirect(reverse("mobile:coach_lineup", kwargs={"event_id": unit.lineup.event_id}))
class CoachLineupPublishView(CoachScopeMixin, LoginRequiredMixin, View):
"""Writes the line-up into the game record and notifies the selected
players -- events.services.lineup.publish_lineup does the actual work."""
def post(self, request, *args, **kwargs):
if not self.can_manage_active_team:
return HttpResponseForbidden()
event = get_object_or_404(Event, pk=kwargs["event_id"], club=request.club, teams=self.active_team, kind=Event.EventKind.GAME)
lineup = get_object_or_404(Lineup, event=event)
publish_lineup(lineup)
notify(request, f"s|{_('Line-up published')}|{_('Selected players have been notified.')}")
return HttpResponseRedirect(reverse("mobile:coach_lineup", kwargs={"event_id": event.pk}))
class CoachSquadView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
"""Bottom-tab "Squad" -- the active team's roster and staff for the
current season, view-only beyond the "Add player" entry point (which
reuses CoachAddPlayerView/C6). No per-row edit here (jersey number,
position, captaincy) -- that stays a desktop-only action for now via
management.forms.TeamMembershipForm; this screen is about seeing the
squad, not managing individual rows from a phone.
"""
template_name = "mobile/coach/squad.html"
screen_title = _("Squad")
active_tab = "coach_squad"
def get_context_data(self, **kwargs):
season = current_season(self.request.club)
roster, staff = [], []
if self.active_team is not None and season is not None:
roster = list(TeamMembership.objects.filter(team=self.active_team, season=season).select_related("member", "position").order_by("position__ordering", "member__last_name"))
staff = list(StaffAssignment.objects.filter(team=self.active_team, season=season).select_related("member", "position").order_by("position__ordering", "member__last_name"))
return super().get_context_data(roster=roster, staff=staff, **kwargs)
class CoachScheduleView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
"""Bottom-tab "Schedule" -- every upcoming event for the active team, full
stop (not Today's own "just the next session" scope). Each row jumps
straight into the coach-relevant action -- Bench attendance for a
practice, the Line-up for a game -- rather than mobile:event_detail (the
Member-shell RSVP page a coach browsing their own team's schedule has no
use for)."""
template_name = "mobile/coach/schedule.html"
screen_title = _("Schedule")
active_tab = "coach_schedule"
def get_context_data(self, **kwargs):
events = []
if self.active_team is not None:
events = list(Event.objects.filter(teams=self.active_team, cancelled=False, start__gte=timezone.now()).order_by("start"))
return super().get_context_data(events=events, **kwargs)