Team carries no real age-group field, so this is a guess -- the club's own team with the closest smaller "U<N>" number in its name/short name (if either side has one at all), current-season roster only. Silently adds nothing extra for a club that doesn't name teams that way, same as the existing "last season's roster" half of Suggested already does for a team with no prior season on file. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1119 lines
57 KiB
Python
1119 lines
57 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.
|
|
"""
|
|
|
|
import datetime
|
|
import re
|
|
|
|
from django import forms
|
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
|
from django.db.models import Case, F, Q, When
|
|
from django.http import Http404, HttpResponseForbidden, HttpResponseRedirect
|
|
from django.shortcuts import get_object_or_404, render
|
|
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, EventSeries, Lineup, LineupSelection, Location, Opponent
|
|
from events.services import generate_occurrences
|
|
from events.services.attendance import member_attendance_counts, record_check_in
|
|
from events.services.lineup import UNAVAILABLE_STATUSES, cancel_scheduled_publish, publish_lineup, schedule_lineup_publish, toggle_selection
|
|
from events.tasks import notify_new_event
|
|
from management.forms import EventForm, EventSeriesForm, LocationForm, NewsForm, OpponentForm
|
|
from members.models import Member
|
|
from news.models import News
|
|
from news.services import notify_editors_of_pending_review
|
|
from notifications.services import notify_members
|
|
from teams.models import Position, StaffAssignment, Team, TeamMembership
|
|
from teams.services import eligible_roster_members
|
|
|
|
from .coach_mixins import CoachScopeMixin
|
|
from .forms import _INPUT_CLASSES, CoachRosterEditForm
|
|
|
|
#: RSVP states that count as "in" for the stat tile -- present/selected are an
|
|
#: explicit yes, maybe is still a lean-in rather than silence.
|
|
IN_STATUSES = [Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.SELECTED, Attendance.AttendanceStatus.MAYBE]
|
|
|
|
#: An explicit no -- declined or, for a published line-up, not selected.
|
|
#: Distinct from NO_RESPONSE ("silent"), which is a non-answer rather than a no.
|
|
OUT_STATUSES = [Attendance.AttendanceStatus.ABSENT, Attendance.AttendanceStatus.EXCUSED, Attendance.AttendanceStatus.NOT_SELECTED]
|
|
|
|
#: The event kinds CoachCreateEventView's tile picker offers -- social/other
|
|
#: stay desktop-only (management.forms.EventForm keeps the full list), since
|
|
#: neither has a tile here.
|
|
COACH_EVENT_KINDS = [Event.EventKind.TRAINING, Event.EventKind.GAME, Event.EventKind.TOURNAMENT, Event.EventKind.MEETING]
|
|
|
|
|
|
class _LocationPickerForm(forms.Form):
|
|
"""Just the ``location`` picker, standalone from EventForm/EventSeriesForm
|
|
-- CoachCreateEventView's own field (same name, so it POSTs into whichever
|
|
of those two forms actually validates the request) and CoachLocationCreateView's
|
|
"+ New location" popup both render this same one, so a freshly created
|
|
Location can be handed back pre-selected without reconstructing the much
|
|
bigger surrounding form just to redraw one <select>."""
|
|
|
|
location = forms.ModelChoiceField(queryset=Location.objects.none(), required=False, label=_("Location"), widget=forms.Select(attrs={"class": _INPUT_CLASSES}))
|
|
|
|
|
|
class _OpponentPickerForm(forms.Form):
|
|
"""Same idea as _LocationPickerForm, for ``opponent``."""
|
|
|
|
opponent = forms.ModelChoiceField(queryset=Opponent.objects.none(), required=False, label=_("Opponent"), widget=forms.Select(attrs={"class": _INPUT_CLASSES}))
|
|
|
|
|
|
def _location_picker(club, selected=None):
|
|
picker = _LocationPickerForm(initial={"location": selected})
|
|
picker.fields["location"].queryset = Location.objects.filter(club=club).order_by("name")
|
|
return picker
|
|
|
|
|
|
def _opponent_picker(club, selected=None):
|
|
picker = _OpponentPickerForm(initial={"opponent": selected})
|
|
picker.fields["opponent"].queryset = Opponent.objects.filter(club=club).order_by("name")
|
|
return picker
|
|
|
|
|
|
def _styled_location_form(data=None):
|
|
form = LocationForm(data)
|
|
for field in form.fields.values():
|
|
field.widget.attrs["class"] = _INPUT_CLASSES
|
|
return form
|
|
|
|
|
|
def _styled_opponent_form(data=None):
|
|
form = OpponentForm(data)
|
|
# A logo is a nice-to-have on the desktop Opponents page, not something worth a
|
|
# file-upload control on a "we just need this to exist" quick-add popup -- add
|
|
# one later from there if it matters.
|
|
del form.fields["logo"]
|
|
form.fields["name"].widget.attrs["class"] = _INPUT_CLASSES
|
|
return form
|
|
|
|
#: How long an event stays "current" (CoachTodayView's session card, and the
|
|
#: missing-line-up nudge) past the moment it starts -- events.start__gte=now
|
|
#: alone would flip to the next session the instant this one begins, while
|
|
#: the coach is still mid-practice/mid-game. Past end (when set) plus a grace
|
|
#: window; past start plus an assumed length when it isn't (most training
|
|
#: events carry no end time -- see Event.end's own help text).
|
|
STILL_CURRENT_GRACE = datetime.timedelta(minutes=30)
|
|
STILL_CURRENT_ASSUMED_DURATION = datetime.timedelta(minutes=90)
|
|
|
|
|
|
def _still_current_events(team):
|
|
"""Events for ``team`` that haven't yet reached their "still current"
|
|
cutoff -- see STILL_CURRENT_GRACE/STILL_CURRENT_ASSUMED_DURATION above."""
|
|
cutoff = Case(
|
|
When(end__isnull=False, then=F("end") + STILL_CURRENT_GRACE),
|
|
default=F("start") + STILL_CURRENT_ASSUMED_DURATION,
|
|
)
|
|
return Event.objects.filter(teams=team, cancelled=False).annotate(still_current_cutoff=cutoff).filter(still_current_cutoff__gte=timezone.now()).order_by("start")
|
|
|
|
|
|
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
|
|
out_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 = _still_current_events(team)
|
|
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()
|
|
out_count = attendances.filter(status__in=OUT_STATUSES).count()
|
|
silent_count = attendances.filter(status=Attendance.AttendanceStatus.NO_RESPONSE).count()
|
|
if silent_count > 0:
|
|
needs_you.append(
|
|
{
|
|
"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,
|
|
out_count=out_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_schedule"
|
|
|
|
#: ?filter= values this screen understands -- anything else (including no
|
|
#: param) means "Responded" (IN_STATUSES: present/selected/maybe), the
|
|
#: default view. A coach doesn't need to check in someone silent or
|
|
#: declined -- neither is expected to show up -- so those are left out of
|
|
#: the default rather than needing to be filtered away each time; each
|
|
#: still gets its own chip to review who's in either bucket.
|
|
FILTERS = {"silent", "declined"}
|
|
|
|
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 == "declined":
|
|
rows = [row for row in attendances if row.status in OUT_STATUSES]
|
|
else:
|
|
rows = [row for row in attendances if row.status in IN_STATUSES]
|
|
|
|
return super().get_context_data(
|
|
event=event,
|
|
rows=rows,
|
|
total_count=len(attendances),
|
|
responded_count=sum(1 for row in attendances if row.status in IN_STATUSES),
|
|
silent_count=sum(1 for row in attendances if row.is_silent),
|
|
declined_count=sum(1 for row in attendances if row.status in OUT_STATUSES),
|
|
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 CoachAttendanceRemindSilentView(CoachScopeMixin, LoginRequiredMixin, View):
|
|
"""Attendance sheet's "Remind silent" button -- an on-demand version of
|
|
events.tasks.send_deadline_reminders' own NO_RESPONSE nudge, for a coach
|
|
who doesn't want to wait for that once-a-day sweep. Same title/body shape
|
|
as that task, so a player who gets both isn't looking at two differently
|
|
worded pushes for the same event."""
|
|
|
|
def post(self, request, *args, **kwargs):
|
|
if self.active_team is None:
|
|
raise Http404
|
|
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)
|
|
member_ids = Attendance.objects.filter(event=event, status=Attendance.AttendanceStatus.NO_RESPONSE).values_list("member_id", flat=True)
|
|
members = list(Member.objects.filter(id__in=member_ids))
|
|
|
|
if members:
|
|
when = timezone.localtime(event.start).strftime("%a %d %b, %H:%M")
|
|
body = _("Reminder: %(kind)s on %(when)s still needs your answer.") % {"kind": event.get_kind_display(), "when": when}
|
|
notify_members(members, club=event.club, title=event.title, body=body, source=event)
|
|
notify(request, f"s|{_('Reminder sent')}|" + ngettext("%(count)d player nudged.", "%(count)d players nudged.", len(members)) % {"count": len(members)})
|
|
else:
|
|
notify(request, f"w|{_('Nobody to remind')}|{_('Everyone has already responded.')}")
|
|
|
|
return HttpResponseRedirect(reverse("mobile:coach_attendance", kwargs={"event_id": event.pk}))
|
|
|
|
|
|
class CoachCreateEventView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
|
|
"""C4 -- reuses management.forms.EventForm/EventSeriesForm as-is (their
|
|
own __init__ already scopes fields to the requester via
|
|
EventAudienceFormMixin), rather than a parallel hand-built form.
|
|
|
|
Audience is simplified from the desktop version: ``teams`` is hard-locked
|
|
to the active team (a hidden field, not a picker -- there's no "which
|
|
team" question on a screen that's already scoped to one), and ``groups``/
|
|
``club_wide`` are dropped entirely, since both widen the audience past a
|
|
single team the same way multi-team selection would. ``invited_members``/
|
|
``excluded_members`` stay, re-scoped to sensible pools (add someone not on
|
|
the roster; exclude someone who is) rather than "every club member" --
|
|
the template spells out that a genuinely multi-team event still needs the
|
|
desktop. Location/opponent are rendered via the standalone
|
|
_location_picker/_opponent_picker (see their own docstrings), each with a
|
|
"+ New" popup (CoachLocationCreateView/CoachOpponentCreateView) for when
|
|
the one needed doesn't exist yet.
|
|
|
|
One screen creates either a single Event or a recurring EventSeries --
|
|
``is_recurring`` (a plain checkbox) picks which of the two forms below
|
|
actually validates the request; the two share every audience/location/
|
|
opponent field (identical names), so nothing needs duplicating in the
|
|
template, just shown/hidden. Competition has no EventSeries equivalent
|
|
(EventSeriesForm carries no such field), so it's one-off-only.
|
|
|
|
After a successful single-event 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. A new series
|
|
mirrors management.views.EventSeriesCreateView instead: generate_occurrences
|
|
materialises its occurrences immediately (each one syncing its own
|
|
attendance the same way), but -- matching that same desktop behaviour,
|
|
not a mobile-specific gap -- nothing pushes a "new event" notification per
|
|
occurrence; a bulk-created series relies on send_deadline_reminders' own
|
|
periodic sweep instead, exactly like a rolling-horizon extension does.
|
|
"""
|
|
|
|
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 _member_pools(self):
|
|
season = current_season(self.request.club)
|
|
roster_member_ids = list(TeamMembership.objects.filter(team=self.active_team, season=season).values_list("member_id", flat=True)) if season and self.active_team else []
|
|
excluded_pool = Member.objects.filter(pk__in=roster_member_ids).order_by("last_name", "first_name")
|
|
invited_pool = eligible_roster_members(self.request.club).exclude(pk__in=roster_member_ids).order_by("last_name", "first_name")
|
|
return invited_pool, excluded_pool
|
|
|
|
def _scope_shared_fields(self, form):
|
|
# Widget swapped in *before* .queryset is set on every ModelMultiple-
|
|
# ChoiceField below -- ModelChoiceField.queryset's setter pushes
|
|
# `self.widget.choices = self.choices` as a side effect at the moment
|
|
# it's assigned, so setting the queryset first and swapping the
|
|
# widget after just discards that push, leaving the new widget's own
|
|
# .choices empty (a real, previously-undetected bug this surfaced --
|
|
# see the regression tests, CoachCreateNewsView.build_form's own
|
|
# near-identical fix for ``teams`` there, and build_series_form's own
|
|
# comment on ``weekdays`` for the plain-ChoiceField variant of the
|
|
# same trap).
|
|
#
|
|
# teams: hard-locked to the active team, not a picker -- see this
|
|
# view's own docstring. A hidden MultipleHiddenInput renders its
|
|
# `initial` on GET without any template code, and the queryset
|
|
# restriction means a tampered request still can't set another team.
|
|
form.fields["teams"].widget = forms.MultipleHiddenInput()
|
|
form.fields["teams"].queryset = Team.objects.filter(pk=self.active_team.pk) if self.active_team is not None else Team.objects.none()
|
|
# form.initial (not just field.initial) -- ModelForm.__init__ already
|
|
# populated form.initial["teams"] = [] from the new, unsaved Event/
|
|
# EventSeries instance's own (necessarily empty) m2m, and
|
|
# get_initial_for_field's dict.get(name, field.initial) only ever
|
|
# falls back to field.initial when the key is *absent*, not when it's
|
|
# merely empty -- so field.initial alone renders nothing here.
|
|
form.initial["teams"] = [self.active_team.pk] if self.active_team is not None else []
|
|
del form.fields["groups"]
|
|
if "club_wide" in form.fields:
|
|
del form.fields["club_wide"]
|
|
invited_pool, excluded_pool = self._member_pools()
|
|
form.fields["invited_members"].widget = forms.CheckboxSelectMultiple()
|
|
form.fields["invited_members"].queryset = invited_pool
|
|
form.fields["excluded_members"].widget = forms.CheckboxSelectMultiple()
|
|
form.fields["excluded_members"].queryset = excluded_pool
|
|
# location/opponent stay on the form (scope_audience_fields already
|
|
# scoped both to this club) so a submitted value still validates and
|
|
# saves correctly -- just never rendered here via {{ form.location }}/
|
|
# {{ form.opponent }}. The template renders _location_picker.html/
|
|
# _opponent_picker.html instead (same "location"/"opponent" POST
|
|
# names, same club-scoped queryset, built standalone so a freshly
|
|
# created Location/Opponent can be handed back pre-selected without
|
|
# reconstructing this whole form -- see their own docstrings).
|
|
if "title" in form.fields:
|
|
form.fields["title"].widget.attrs["class"] = _INPUT_CLASSES
|
|
|
|
def build_event_form(self, data=None):
|
|
# kind=training, not Event.kind's own model default (OTHER) -- Practice
|
|
# is the tile picker's first/most common option, and OTHER isn't even
|
|
# one of the four tiles COACH_EVENT_KINDS offers below.
|
|
instance = Event(club=self.request.club, created_by=self.me, kind=Event.EventKind.TRAINING)
|
|
form = EventForm(data, club=self.request.club, user=self.request.user, editing=False, instance=instance)
|
|
# max_referees/external_game_id have no use on a coach-created event --
|
|
# max_referees has a model default (2) but no blank=True, so the field
|
|
# is required despite it; construct_instance skips a deleted field
|
|
# entirely, leaving the instance's own default/blank.
|
|
del form.fields["max_referees"]
|
|
del form.fields["external_game_id"]
|
|
# Narrowed to the four kinds the tile picker actually offers -- social/
|
|
# other don't get their own tile, and this keeps a tampered request from
|
|
# setting one anyway (the desktop form still offers the full list).
|
|
form.fields["kind"].choices = [choice for choice in form.fields["kind"].choices if choice[0] in COACH_EVENT_KINDS]
|
|
self._scope_shared_fields(form)
|
|
for field_name in ("start", "deadline", "competition"):
|
|
form.fields[field_name].widget.attrs["class"] = _INPUT_CLASSES
|
|
return form
|
|
|
|
def build_series_form(self, data=None):
|
|
instance = EventSeries(club=self.request.club, kind=Event.EventKind.TRAINING)
|
|
form = EventSeriesForm(data, club=self.request.club, user=self.request.user, instance=instance)
|
|
# The raw-RRULE escape hatch is a desktop-only affordance -- the
|
|
# friendly frequency/interval/weekdays fields below cover the common
|
|
# weekly/monthly cases this screen is for.
|
|
del form.fields["advanced_rrule"]
|
|
# Not offered here: neither maps to a "how long since kickoff" a coach
|
|
# thinks in the way duration_hours/minutes below does.
|
|
del form.fields["gathering_minutes_before"]
|
|
form.fields["kind"].choices = [choice for choice in form.fields["kind"].choices if choice[0] in COACH_EVENT_KINDS]
|
|
self._scope_shared_fields(form)
|
|
# SelectMultiple relies on the desktop's searchable-select JS (not loaded
|
|
# here) to be usable at all -- checkboxes work without it, and there are
|
|
# only ever seven, so a tile-style has-checked toggle (see the template)
|
|
# reads better than a cramped multi-select on a phone regardless.
|
|
# choices passed straight to the widget's own constructor, not left to
|
|
# ChoiceField.choices' assignment-time push -- the field's own choices
|
|
# were already pushed onto the *original* SelectMultiple back when the
|
|
# field itself was declared, and swapping the widget here discards
|
|
# that (same trap _scope_shared_fields' own comment covers, just the
|
|
# plain-ChoiceField shape of it).
|
|
form.fields["weekdays"].widget = forms.CheckboxSelectMultiple(attrs={"class": "sr-only"}, choices=form.fields["weekdays"].choices)
|
|
for field_name in ("dtstart", "until", "frequency", "interval", "duration_hours", "duration_minutes", "deadline_minutes_before"):
|
|
form.fields[field_name].widget.attrs["class"] = _INPUT_CLASSES
|
|
return form
|
|
|
|
def get_context_data(self, **kwargs):
|
|
form = kwargs.setdefault("form", self.build_event_form())
|
|
series_form = kwargs.setdefault("series_form", self.build_series_form())
|
|
# Whichever of the two actually carries the failed submission's data
|
|
# (a validation failure always rebuilds the *other* one fresh/unbound,
|
|
# so at most one of these is ever bound) -- GET has neither bound.
|
|
bound = form if form.is_bound else series_form if series_form.is_bound else None
|
|
# title/teams/invited_members/excluded_members are identical fields on
|
|
# both forms (same name, same queryset) -- rendered once, from
|
|
# whichever form is actually bound, so a validation failure on the
|
|
# *series* form doesn't redisplay those as blank just because `form`
|
|
# itself is a fresh, never-submitted EventForm in that response.
|
|
kwargs.setdefault("shared_form", bound or form)
|
|
kwargs.setdefault("is_recurring", series_form.is_bound)
|
|
kwargs.setdefault("location_picker", _location_picker(self.request.club, selected=bound.data.get("location") if bound else None))
|
|
kwargs.setdefault("opponent_picker", _opponent_picker(self.request.club, selected=bound.data.get("opponent") if bound else None))
|
|
kwargs.setdefault("location_form", _styled_location_form())
|
|
kwargs.setdefault("opponent_form", _styled_opponent_form())
|
|
return super().get_context_data(**kwargs)
|
|
|
|
def post(self, request, *args, **kwargs):
|
|
if not self.can_manage_active_team:
|
|
return HttpResponseForbidden()
|
|
|
|
if request.POST.get("is_recurring") == "on":
|
|
series_form = self.build_series_form(request.POST)
|
|
if not series_form.is_valid():
|
|
return self.render_to_response(self.get_context_data(form=self.build_event_form(), series_form=series_form))
|
|
|
|
series = series_form.save()
|
|
# Not automatic on save -- without this the series would exist with
|
|
# zero occurrences until the extend_event_series cron next runs.
|
|
created = generate_occurrences(series)
|
|
body = ngettext("“%(series)s” created, with %(count)d occurrence scheduled.", "“%(series)s” created, with %(count)d occurrences scheduled.", len(created)) % {"series": series, "count": len(created)}
|
|
notify(request, f"s|{_('Series created')}|{body}")
|
|
return HttpResponseRedirect(reverse("mobile:coach_today"))
|
|
|
|
form = self.build_event_form(request.POST)
|
|
if not form.is_valid():
|
|
return self.render_to_response(self.get_context_data(form=form, series_form=self.build_series_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 CoachLocationCreateView(CoachScopeMixin, LoginRequiredMixin, View):
|
|
"""New event's "+ New location" popup. The modal's own <form> targets
|
|
just #location-modal-body (hx-swap="innerHTML") -- on a validation
|
|
failure that's the whole response, re-showing the fields with errors. On
|
|
success the response also carries an out-of-band #location-picker swap
|
|
(the standard htmx way to update a second area from one request) so the
|
|
freshly created Location shows up pre-selected on the field the modal was
|
|
opened from, plus an HX-Trigger the modal listens for to close itself --
|
|
all without touching (or losing progress in) the rest of the in-progress
|
|
event/series form the modal is sitting on top of.
|
|
"""
|
|
|
|
def post(self, request, *args, **kwargs):
|
|
if not self.can_manage_active_team:
|
|
return HttpResponseForbidden()
|
|
|
|
form = _styled_location_form(request.POST)
|
|
if not form.is_valid():
|
|
return render(request, "mobile/coach/_location_modal_fields.html", {"location_form": form})
|
|
|
|
location = form.save(commit=False)
|
|
location.club = request.club
|
|
location.save()
|
|
response = render(
|
|
request,
|
|
"mobile/coach/_location_created_response.html",
|
|
{"location_form": _styled_location_form(), "location_picker": _location_picker(request.club, selected=location.pk)},
|
|
)
|
|
response["HX-Trigger"] = "location-created"
|
|
return response
|
|
|
|
|
|
class CoachOpponentCreateView(CoachScopeMixin, LoginRequiredMixin, View):
|
|
"""New event's "+ New opponent" popup -- same shape as
|
|
CoachLocationCreateView, for Opponent instead."""
|
|
|
|
def post(self, request, *args, **kwargs):
|
|
if not self.can_manage_active_team:
|
|
return HttpResponseForbidden()
|
|
|
|
form = _styled_opponent_form(request.POST)
|
|
if not form.is_valid():
|
|
return render(request, "mobile/coach/_opponent_modal_fields.html", {"opponent_form": form})
|
|
|
|
opponent = form.save(commit=False)
|
|
opponent.club = request.club
|
|
opponent.save()
|
|
response = render(
|
|
request,
|
|
"mobile/coach/_opponent_created_response.html",
|
|
{"opponent_form": _styled_opponent_form(), "opponent_picker": _opponent_picker(request.club, selected=opponent.pk)},
|
|
)
|
|
response["HX-Trigger"] = "opponent-created"
|
|
return response
|
|
|
|
|
|
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.
|
|
#
|
|
# Widget swapped in *before* .queryset is set -- ModelChoiceField.
|
|
# queryset's setter pushes `self.widget.choices = self.choices` as a
|
|
# side effect at the moment it's assigned; setting the queryset first
|
|
# and swapping the widget after discards that push, leaving the new
|
|
# widget's own .choices empty and the checkbox list rendering nothing
|
|
# at all (a real, previously-undetected bug -- see the regression
|
|
# test, and mobile.coach_views.CoachCreateEventView._scope_shared_
|
|
# fields' own near-identical comment for the ModelMultipleChoiceField
|
|
# version of the same trap).
|
|
form.fields["teams"].widget = forms.CheckboxSelectMultiple()
|
|
form.fields["teams"].queryset = Team.objects.filter(pk__in=[team.pk for team in self.managed_teams])
|
|
form.fields["teams"].required = True
|
|
if self.active_team is not None and data is None:
|
|
# form.initial, not field.initial -- ModelForm.__init__ already set
|
|
# form.initial["teams"] = [] from the new, unsaved News instance's
|
|
# own (necessarily empty) m2m, and dict.get(name, field.initial)
|
|
# only falls back to field.initial when the key is *absent*, not
|
|
# merely empty, so field.initial alone silently renders unchecked.
|
|
form.initial["teams"] = [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 player added here starts with no jersey number/position set --
|
|
the coach picks those afterward from the player's own row on the Squad
|
|
screen (CoachRosterMemberView), same "blank is a normal, expected state"
|
|
TeamMembership.position's own help_text already documents.
|
|
|
|
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" is two real, computed sources unioned together: whoever was
|
|
on this team last season, plus whoever's on the closest younger team
|
|
*this* season (see _feeder_team -- a guess from team naming, since
|
|
neither Club nor Team carries a real age-group field to link them
|
|
properly). "Age eligible" from the mock still isn't built -- there's no
|
|
birth-date cutoff to compare against, and faking that filter would just
|
|
mean it silently matched nothing. A plain first/last-name search (?q=)
|
|
narrows the pool further, ANDed with whichever filter chip is active --
|
|
useful once a club's eligible-member pool outgrows a single screenful.
|
|
"""
|
|
|
|
template_name = "mobile/coach/add_player.html"
|
|
screen_title = _("Add players")
|
|
active_tab = "coach_squad"
|
|
|
|
#: ?filter= values this screen understands -- anything else (including no
|
|
#: param at all) means "All".
|
|
FILTERS = {"suggested", "no_team"}
|
|
|
|
#: Matches a "U<number>" youth age-group marker in a team's name/short
|
|
#: name (e.g. "U14", "u16 boys") -- the only age-group signal available
|
|
#: anywhere on Team today.
|
|
AGE_GROUP_RE = re.compile(r"u(\d+)", re.IGNORECASE)
|
|
|
|
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 _age_group(self, team):
|
|
match = self.AGE_GROUP_RE.search(team.name) or self.AGE_GROUP_RE.search(team.short_name)
|
|
return int(match.group(1)) if match else None
|
|
|
|
def _feeder_team(self):
|
|
"""The club's own team with the closest smaller age-group number
|
|
than the active team's, if either carries one -- a guess (see this
|
|
view's own docstring), so it silently returns None for a club that
|
|
doesn't name teams "U<N>"."""
|
|
active_age = self._age_group(self.active_team)
|
|
if active_age is None:
|
|
return None
|
|
|
|
feeder, feeder_age = None, None
|
|
for team in Team.objects.filter(club=self.request.club).exclude(pk=self.active_team.pk):
|
|
age = self._age_group(team)
|
|
if age is not None and age < active_age and (feeder_age is None or age > feeder_age):
|
|
feeder, feeder_age = team, age
|
|
return feeder
|
|
|
|
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 = ""
|
|
search_query = self.request.GET.get("q", "").strip()
|
|
|
|
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":
|
|
suggested_ids = set()
|
|
previous_season = Season.before(self.request.club, season)
|
|
if previous_season is not None:
|
|
suggested_ids.update(pool.filter(team_memberships__team=self.active_team, team_memberships__season=previous_season).values_list("pk", flat=True))
|
|
feeder_team = self._feeder_team()
|
|
if feeder_team is not None:
|
|
suggested_ids.update(pool.filter(team_memberships__team=feeder_team, team_memberships__season=season).values_list("pk", flat=True))
|
|
pool = pool.filter(pk__in=suggested_ids)
|
|
|
|
if search_query:
|
|
pool = pool.filter(Q(first_name__icontains=search_query) | Q(last_name__icontains=search_query))
|
|
|
|
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,
|
|
search_query=search_query,
|
|
**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 CoachAddStaffView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
|
|
"""Squad screen's staff "Add" entry point -- the same bulk-checkbox shape
|
|
as CoachAddPlayerView/C6, with one addition: StaffAssignment.position is
|
|
required (unlike a roster spot's optional one), so there's a single
|
|
position picker shared by however many candidates get checked, rather
|
|
than a per-row picker that wouldn't fit this screen. Good enough for the
|
|
common case (adding one or more assistants to the same role at once);
|
|
assigning several people to different positions in one visit still means
|
|
visiting this screen more than once.
|
|
"""
|
|
|
|
template_name = "mobile/coach/add_staff.html"
|
|
screen_title = _("Add staff")
|
|
active_tab = "coach_squad"
|
|
|
|
def get(self, request, *args, **kwargs):
|
|
if not self.can_manage_active_team:
|
|
return HttpResponseRedirect(reverse("mobile:coach_squad"))
|
|
return super().get(request, *args, **kwargs)
|
|
|
|
def _candidate_pool(self, season):
|
|
taken = StaffAssignment.objects.filter(team=self.active_team, season=season).values_list("member_id", flat=True)
|
|
return eligible_roster_members(self.request.club).exclude(pk__in=taken)
|
|
|
|
def get_context_data(self, **kwargs):
|
|
season = current_season(self.request.club)
|
|
candidates = []
|
|
positions = Position.objects.none()
|
|
|
|
if self.active_team is not None and season is not None:
|
|
candidates = list(self._candidate_pool(season).order_by("last_name", "first_name"))
|
|
positions = Position.objects.filter(club=self.request.club, staff_position=True)
|
|
|
|
return super().get_context_data(candidates=candidates, positions=positions, **kwargs)
|
|
|
|
def post(self, request, *args, **kwargs):
|
|
if not self.can_manage_active_team:
|
|
return HttpResponseForbidden()
|
|
|
|
season = current_season(request.club)
|
|
if self.active_team is None or season is None:
|
|
return HttpResponseForbidden()
|
|
|
|
position = Position.objects.filter(club=request.club, staff_position=True, pk=request.POST.get("position")).first()
|
|
if position is None:
|
|
notify(request, f"e|{_('Could not add staff')}|{_('Pick a position first.')}")
|
|
return HttpResponseRedirect(reverse("mobile:coach_add_staff"))
|
|
|
|
pool_ids = {str(pk) for pk in self._candidate_pool(season).values_list("pk", flat=True)}
|
|
added = 0
|
|
for member_id in request.POST.getlist("member"):
|
|
if member_id not in pool_ids:
|
|
continue
|
|
StaffAssignment.objects.get_or_create(team=self.active_team, season=season, member_id=member_id, defaults={"position": position})
|
|
added += 1
|
|
|
|
if added:
|
|
body = ngettext("%(count)d staff member added.", "%(count)d staff members added.", added) % {"count": added}
|
|
notify(request, f"s|{_('Staff updated')}|{body}")
|
|
return HttpResponseRedirect(reverse("mobile:coach_squad"))
|
|
|
|
|
|
class CoachLineupView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
|
|
"""C3 -- a game's line-up, kept deliberately simple: a plain yes/no pick
|
|
per available roster player, grouped by their roster position ("category")
|
|
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_schedule"
|
|
|
|
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. Each roster row links through to CoachRosterMemberView
|
|
for stats/contact/edit/remove; staff rows stay plain (no per-row action
|
|
yet beyond the "Add" entry point below, which reuses CoachAddStaffView).
|
|
"""
|
|
|
|
template_name = "mobile/coach/squad.html"
|
|
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 CoachRosterMemberView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
|
|
"""Squad screen's per-player detail sheet: attendance stats for the
|
|
season, tap-to-call buttons (the player's own phone/emergency phone, plus
|
|
each guardian's if they're a child -- Member.guardians is only ever
|
|
non-empty for one), and -- for whoever manages this team -- the same
|
|
position/jersey/captaincy edit TeamMembershipForm exposes on desktop,
|
|
plus a remove-from-roster action. Read-only (no edit form, no remove
|
|
button) for staff without a management position, same hide-don't-disable
|
|
rule as everywhere else in coach mode.
|
|
"""
|
|
|
|
template_name = "mobile/coach/roster_member.html"
|
|
screen_title = _("Player")
|
|
active_tab = "coach_squad"
|
|
|
|
def get_membership(self):
|
|
if self.active_team is None:
|
|
raise Http404
|
|
return get_object_or_404(TeamMembership.objects.filter(team=self.active_team).select_related("member", "position"), pk=self.kwargs["membership_pk"])
|
|
|
|
def build_form(self, membership, data=None):
|
|
return CoachRosterEditForm(data, instance=membership, club=self.request.club, team=self.active_team, season=membership.season)
|
|
|
|
def get_context_data(self, **kwargs):
|
|
membership = self.get_membership()
|
|
member = membership.member
|
|
|
|
kwargs.setdefault("form", self.build_form(membership) if self.can_manage_active_team else None)
|
|
return super().get_context_data(
|
|
membership=membership,
|
|
member=member,
|
|
guardians=member.guardians,
|
|
attendance_counts=member_attendance_counts(member, membership.season),
|
|
**kwargs,
|
|
)
|
|
|
|
def post(self, request, *args, **kwargs):
|
|
membership = self.get_membership()
|
|
if not self.can_manage_active_team:
|
|
return HttpResponseForbidden()
|
|
|
|
form = self.build_form(membership, request.POST)
|
|
if not form.is_valid():
|
|
return self.render_to_response(self.get_context_data(form=form))
|
|
|
|
form.save()
|
|
notify(request, f"s|{_('Player updated')}|" + _("“%(member)s” has been updated.") % {"member": membership.member})
|
|
return HttpResponseRedirect(reverse("mobile:coach_roster_member", kwargs={"membership_pk": membership.pk}))
|
|
|
|
|
|
class CoachRosterRemoveView(CoachScopeMixin, LoginRequiredMixin, View):
|
|
def post(self, request, *args, **kwargs):
|
|
if self.active_team is None:
|
|
raise Http404
|
|
if not self.can_manage_active_team:
|
|
return HttpResponseForbidden()
|
|
|
|
membership = get_object_or_404(TeamMembership.objects.filter(team=self.active_team), pk=kwargs["membership_pk"])
|
|
member = membership.member
|
|
membership.delete()
|
|
|
|
notify(request, f"w|{_('Player removed')}|" + _("“%(member)s” removed from the roster.") % {"member": member})
|
|
return HttpResponseRedirect(reverse("mobile:coach_squad"))
|
|
|
|
|
|
class CoachStaffRemoveView(CoachScopeMixin, LoginRequiredMixin, View):
|
|
"""Squad screen's per-staff-row remove action. A manager can't remove
|
|
their own StaffAssignment this way -- self-removal would strand them off
|
|
a team they're actively viewing, with no one obviously left to undo it;
|
|
that stays a desktop-only action (management.views.TeamStaffRemoveView),
|
|
which any *other* admin/manager can still reach."""
|
|
|
|
def post(self, request, *args, **kwargs):
|
|
if self.active_team is None:
|
|
raise Http404
|
|
if not self.can_manage_active_team:
|
|
return HttpResponseForbidden()
|
|
|
|
assignment = get_object_or_404(StaffAssignment.objects.filter(team=self.active_team), pk=kwargs["assignment_pk"])
|
|
if self.me is not None and assignment.member_id == self.me.pk:
|
|
return HttpResponseForbidden()
|
|
|
|
member = assignment.member
|
|
assignment.delete()
|
|
|
|
notify(request, f"w|{_('Staff removed')}|" + _("“%(member)s” removed from staff.") % {"member": member})
|
|
return HttpResponseRedirect(reverse("mobile:coach_squad"))
|
|
|
|
|
|
class CoachScheduleView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
|
|
"""Bottom-tab "Schedule" -- every upcoming event for the active team, full
|
|
stop (not Today's own "just the next session" scope). Each row jumps
|
|
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)
|