New event: auto-link the active team, opponent/competition, quick-add popups, recurring series

- teams is now hard-locked to the active team (a hidden field, not a
  picker) -- there's no "which team" question on a screen already scoped
  to one. groups/club_wide are dropped for the same reason (both widen
  the audience past a single team). invited_members/excluded_members stay,
  re-scoped to sensible pools (add someone off the roster; exclude someone
  on it) instead of "every club member", with a note that a genuinely
  multi-team event still needs the desktop.
- Opponent and competition are now rendered (game-only) -- opponent via a
  standalone picker with its own "+ New" popup, competition via EventForm's
  existing club-agnostic Competition list.
- "+ New location"/"+ New opponent" popups create a Location/Opponent
  scoped to the club without leaving the screen: the modal's own htmx
  request creates the row and hands back the picker pre-selected via an
  out-of-band swap, so the rest of the in-progress form is never touched.
- A "This repeats" toggle switches the same screen to build an EventSeries
  instead of a single Event (frequency/interval/weekdays/duration/until),
  reusing EventSeriesForm and generate_occurrences the same way
  management.views.EventSeriesCreateView does -- including that view's own
  behaviour of not sending a per-occurrence notification (the bulk
  send_deadline_reminders sweep covers it instead, same as a rolling-
  horizon extension).
- Confirmed (and covered with a test) that a single event created this way
  still fires notify_new_event, notifying whoever's freshly invited.

Along the way, found and fixed a real, previously-undetected rendering bug
this surfaced: swapping a multi-choice field's widget to CheckboxSelectMultiple
*after* setting its queryset/choices silently drops what the queryset/choices
setter had already pushed onto the old widget, rendering an empty checkbox
list. Hit this for invited_members/excluded_members and weekdays here, and
found the same pre-existing bug in CoachCreateNewsView's own team checkboxes
(the New Post screen's team picker has been rendering empty) -- fixed all of
them (widget swapped in before queryset/choices, not after), with rendering
(not just queryset) regression tests for each.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 15:16:35 +02:00
parent 5d54f1cdcc
commit bf62759bfc
12 changed files with 871 additions and 106 deletions

View File

@@ -11,7 +11,7 @@ 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
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
@@ -22,11 +22,12 @@ 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.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, NewsForm
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
@@ -50,6 +51,52 @@ OUT_STATUSES = [Attendance.AttendanceStatus.ABSENT, Attendance.AttendanceStatus.
#: 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
@@ -289,24 +336,40 @@ class CoachAttendanceRemindSilentView(CoachScopeMixin, LoginRequiredMixin, View)
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.
"""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.
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.
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.
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.
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"
@@ -318,42 +381,145 @@ class CoachCreateEventView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
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)
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 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.
# 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]
# 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"):
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):
kwargs.setdefault("form", self.build_form())
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()
form = self.build_form(request.POST)
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))
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}
@@ -365,6 +531,62 @@ class CoachCreateEventView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
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
@@ -412,11 +634,26 @@ class CoachCreateNewsView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
# 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
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]
# 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