"""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.dateparse import parse_datetime 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, LineupSelection from events.services.attendance import record_check_in from events.services.lineup import UNAVAILABLE_STATUSES, cancel_scheduled_publish, publish_lineup, schedule_lineup_publish, toggle_selection from events.tasks import notify_new_event from management.forms import EventForm, NewsForm from news.models import News from news.services import notify_editors_of_pending_review from teams.models import StaffAssignment, Team, TeamMembership from teams.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 session card for it (today's, if there's one on the calendar today, otherwise whichever is soonest), 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, plus an unpublished-line-up flag for *every* upcoming game within UPCOMING_GAMES_CHECKED (not just whichever happens to be the very next session -- a practice landing before Saturday's game shouldn't hide that the game's own line-up still needs building). 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" #: How many of the team's soonest upcoming games to check for a missing #: line-up -- unbounded would mean querying arbitrarily far into a full #: season; this many is already more advance notice than useful. UPCOMING_GAMES_CHECKED = 5 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}, "action_label": _("Review"), "action_url": reverse("mobile:coach_attendance", kwargs={"event_id": session_event.pk}) if self.can_manage_active_team else "", } ) upcoming_games = list(upcoming.filter(kind=Event.EventKind.GAME)[: self.UPCOMING_GAMES_CHECKED]) published_event_ids = set(Lineup.objects.filter(event__in=upcoming_games, published_at__isnull=False).values_list("event_id", flat=True)) for game in upcoming_games: if game.pk in published_event_ids: continue needs_you.append( { "severity": "club", "title": _("Line-up not published"), "detail": _("%(title)s · %(date)s") % {"title": game.title, "date": timezone.localtime(game.start).strftime("%a %d %b, %H:%M")}, "action_label": _("Build"), "action_url": reverse("mobile:coach_lineup", kwargs={"event_id": game.pk}) if self.can_manage_active_team else "", } ) 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, kept deliberately simple: a plain yes/no pick per available roster player, grouped by their roster position ("category") so the coach reads it the same way the roster itself is grouped -- no lines, no slots, no drag-and-drop (an earlier build had units/slots with tap-to-place; replaced because it was harder to read than it needed to be for what's really just a selection call). One batch "Save line-up" submit, not a live per-tap POST -- same reasoning as coach/attendance.html. 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; saving/publishing 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 _categories(self, event, lineup): """Available roster players, grouped by position -- same "category" the member-side published view groups by (mobile/views.py's EventDetailView). A player with no TeamMembership for this team/ season (a guest call-up) lands in a catch-all "No position set" bucket rather than being dropped.""" 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")} selected_ids = set(LineupSelection.objects.filter(lineup=lineup).values_list("member_id", flat=True)) available = Attendance.objects.filter(event=event).exclude(status__in=UNAVAILABLE_STATUSES).select_related("member").order_by("member__last_name", "member__first_name") buckets = {} for attendance in available: membership = memberships_by_member.get(attendance.member_id) position = membership.position if membership else None key = position.pk if position else None bucket = buckets.setdefault(key, {"label": position.name if position else _("No position set"), "ordering": position.ordering if position else 9999, "rows": []}) bucket["rows"].append({"member": attendance.member, "membership": membership, "selected": attendance.member_id in selected_ids}) return sorted(buckets.values(), key=lambda bucket: (bucket["ordering"], bucket["label"])) 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}) unavailable = list(Attendance.objects.filter(event=event, status__in=UNAVAILABLE_STATUSES).select_related("member")) return super().get_context_data( event=event, lineup=lineup, categories=self._categories(event, lineup), 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}) selected_ids = set(LineupSelection.objects.filter(lineup=lineup).values_list("member_id", flat=True)) available = Attendance.objects.filter(event=event).exclude(status__in=UNAVAILABLE_STATUSES).select_related("member") selected_count = 0 for attendance in available: wants_selected = request.POST.get(f"selected_{attendance.member_id}") == "true" if wants_selected != (attendance.member_id in selected_ids): toggle_selection(lineup, attendance.member) if wants_selected: selected_count += 1 title = _("Line-up saved") body = _("%(count)d player(s) selected.") % {"count": selected_count} notify(request, f"s|{title}|{body}") return HttpResponseRedirect(reverse("mobile:coach_lineup", kwargs={"event_id": event.pk})) class CoachLineupPublishView(CoachScopeMixin, LoginRequiredMixin, View): """Publish now, schedule for a later time, or cancel a pending schedule -- events.services.lineup.publish_lineup/schedule_lineup_publish/ cancel_scheduled_publish do the actual work; events.tasks. publish_scheduled_lineups is the periodic sweep that catches a schedule once its time arrives. ``action`` picks which (default "publish_now", so the plain "Publish" button posts with no extra fields).""" 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) action = request.POST.get("action", "publish_now") if action == "schedule": when = parse_datetime(request.POST.get("publish_at", "")) if when is not None and timezone.is_naive(when): when = timezone.make_aware(when) if when is None or when <= timezone.now(): notify(request, f"e|{_('Could not schedule')}|{_('Pick a date and time in the future.')}") else: schedule_lineup_publish(lineup, when) notify(request, f"s|{_('Publish scheduled')}|{_('The line-up will publish itself automatically at that time.')}") elif action == "cancel_schedule": cancel_scheduled_publish(lineup) notify(request, f"s|{_('Schedule cancelled')}|{_('Publish it manually whenever you are ready.')}") else: 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)