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.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Case, F, Q, When from django.db.models import Case, F, Q, When
from django.http import Http404, HttpResponseForbidden, HttpResponseRedirect 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.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.dateparse import parse_datetime 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.models import Season
from club.services.access import can_add_news, current_season from club.services.access import can_add_news, current_season
from controlpanel.messages import notify 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.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.services.lineup import UNAVAILABLE_STATUSES, cancel_scheduled_publish, publish_lineup, schedule_lineup_publish, toggle_selection
from events.tasks import notify_new_event 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 members.models import Member
from news.models import News from news.models import News
from news.services import notify_editors_of_pending_review 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. #: neither has a tile here.
COACH_EVENT_KINDS = [Event.EventKind.TRAINING, Event.EventKind.GAME, Event.EventKind.TOURNAMENT, Event.EventKind.MEETING] 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 #: 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 #: 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 #: 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): class CoachCreateEventView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
"""C4 -- reuses management.forms.EventForm as-is: its own __init__ already """C4 -- reuses management.forms.EventForm/EventSeriesForm as-is (their
scopes ``teams`` to teams_managed_by(user, club) via EventAudienceFormMixin, own __init__ already scopes fields to the requester via
exactly the restriction a coach needs, so there's nothing to re-scope. EventAudienceFormMixin), rather than a parallel hand-built form.
Only a subset of the mock's fields is rendered in the template (Title, Audience is simplified from the desktop version: ``teams`` is hard-locked
Kind, Teams, Location, Start, Answers close) -- everything else EventForm to the active team (a hidden field, not a picker -- there's no "which
carries (groups/club_wide/invited & excluded members/opponent/ team" question on a screen that's already scoped to one), and ``groups``/
competition/external id) stays unrendered and simply unset; all of it is ``club_wide`` are dropped entirely, since both widen the audience past a
optional on the model, so an unrendered field validates cleanly empty. single team the same way multi-team selection would. ``invited_members``/
"Repeat weekly" from the mock isn't built this stage -- the recurring- ``excluded_members`` stay, re-scoped to sensible pools (add someone not on
series machinery (EventSeriesForm) is a separate form with its own the roster; exclude someone who is) rather than "every club member" --
fields; wiring it in is later work, not something to fake with an inert the template spells out that a genuinely multi-team event still needs the
toggle here. 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 One screen creates either a single Event or a recurring EventSeries --
management.views.EventCreateView.form_valid makes -- attendance sync is ``is_recurring`` (a plain checkbox) picks which of the two forms below
automatic via events/signals.py, only the notification dispatch needs actually validates the request; the two share every audience/location/
replicating by hand for a view that isn't a CreateView. 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" template_name = "mobile/coach/event_form.html"
@@ -318,42 +381,145 @@ class CoachCreateEventView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
return HttpResponseRedirect(reverse("mobile:coach_today")) return HttpResponseRedirect(reverse("mobile:coach_today"))
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
def build_form(self, data=None): def _member_pools(self):
instance = Event(club=self.request.club, created_by=self.me) 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) 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 # max_referees/external_game_id have no use on a coach-created event --
# field is required despite it -- delete it rather than render a # max_referees has a model default (2) but no blank=True, so the field
# referee-count control this screen has no use for; construct_instance # is required despite it; construct_instance skips a deleted field
# skips deleted fields entirely, leaving the instance's own default. # entirely, leaving the instance's own default/blank.
del form.fields["max_referees"] del form.fields["max_referees"]
del form.fields["external_game_id"]
# Narrowed to the four kinds the tile picker actually offers -- social/ # 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 # 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). # 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] 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 self._scope_shared_fields(form)
# widget, not loaded here -- plain checkboxes work without it and for field_name in ("start", "deadline", "competition"):
# read better on a phone regardless. form.fields[field_name].widget.attrs["class"] = _INPUT_CLASSES
form.fields["teams"].widget = forms.CheckboxSelectMultiple() return form
if self.active_team is not None and data is None:
form.fields["teams"].initial = [self.active_team.pk] def build_series_form(self, data=None):
# Same input styling as mobile.forms.MemberProfileForm (M6) -- one instance = EventSeries(club=self.request.club, kind=Event.EventKind.TRAINING)
# visual language for every text/date field across the app, not a form = EventSeriesForm(data, club=self.request.club, user=self.request.user, instance=instance)
# diverging one for this screen. # The raw-RRULE escape hatch is a desktop-only affordance -- the
for field_name in ("title", "start", "location", "deadline"): # 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 form.fields[field_name].widget.attrs["class"] = _INPUT_CLASSES
return form return form
def get_context_data(self, **kwargs): 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) return super().get_context_data(**kwargs)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
if not self.can_manage_active_team: if not self.can_manage_active_team:
return HttpResponseForbidden() 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(): 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() event = form.save()
body = _("%(event)s” created.") % {"event": event} body = _("%(event)s” created.") % {"event": event}
@@ -365,6 +531,62 @@ class CoachCreateEventView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
return HttpResponseRedirect(reverse("mobile:coach_today")) 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): class CoachCreateNewsView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
"""C5 -- reuses management.forms.NewsForm, re-scoped to the coach's own """C5 -- reuses management.forms.NewsForm, re-scoped to the coach's own
managed team(s). NewsForm.__init__ defaults ``teams`` to every club team 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 # 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 # (which News.teams's own help_text defines as "club-wide", an
# editor/admin-only claim), and never any other team in the club. # 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"].queryset = Team.objects.filter(pk__in=[team.pk for team in self.managed_teams])
form.fields["teams"].required = True form.fields["teams"].required = True
form.fields["teams"].widget = forms.CheckboxSelectMultiple()
if self.active_team is not None and data is None: 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"): for field_name in ("title", "body"):
form.fields[field_name].widget.attrs["class"] = _INPUT_CLASSES form.fields[field_name].widget.attrs["class"] = _INPUT_CLASSES
return form return form

View File

@@ -0,0 +1,2 @@
{% include "mobile/coach/_location_modal_fields.html" with location_form=location_form %}
{% include "mobile/coach/_location_picker.html" with location_picker=location_picker oob=True %}

View File

@@ -0,0 +1,31 @@
{% load i18n %}
<div class="flex flex-col gap-3">
{% for error in location_form.non_field_errors %}<p class="text-xs text-club-dark">{{ error }}</p>{% endfor %}
<div>
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ location_form.name.id_for_label }}">{% trans "Name" %}</label>
{{ location_form.name }}
{% for error in location_form.name.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
</div>
<div>
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ location_form.address.id_for_label }}">{% trans "Address" %}</label>
{{ location_form.address }}
{% for error in location_form.address.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ location_form.city.id_for_label }}">{% trans "City" %}</label>
{{ location_form.city }}
{% for error in location_form.city.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
</div>
<div>
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ location_form.zip_code.id_for_label }}">{% trans "Zip code" %}</label>
{{ location_form.zip_code }}
{% for error in location_form.zip_code.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
</div>
</div>
<div>
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ location_form.country.id_for_label }}">{% trans "Country" %}</label>
{{ location_form.country }}
{% for error in location_form.country.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
</div>
</div>

View File

@@ -0,0 +1,8 @@
{% load i18n %}
<div id="location-picker" {% if oob %}hx-swap-oob="true"{% endif %}>
<div class="mb-1 flex items-center justify-between">
<label class="text-xs font-semibold text-muted" for="{{ location_picker.location.id_for_label }}">{% trans "Location" %}</label>
<button type="button" class="font-display text-[11px] font-extrabold tracking-wide text-club uppercase" @click="showLocationModal = true">{% trans "+ New" %}</button>
</div>
{{ location_picker.location }}
</div>

View File

@@ -0,0 +1,2 @@
{% include "mobile/coach/_opponent_modal_fields.html" with opponent_form=opponent_form %}
{% include "mobile/coach/_opponent_picker.html" with opponent_picker=opponent_picker oob=True %}

View File

@@ -0,0 +1,9 @@
{% load i18n %}
<div class="flex flex-col gap-3">
{% for error in opponent_form.non_field_errors %}<p class="text-xs text-club-dark">{{ error }}</p>{% endfor %}
<div>
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ opponent_form.name.id_for_label }}">{% trans "Name" %}</label>
{{ opponent_form.name }}
{% for error in opponent_form.name.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
</div>
</div>

View File

@@ -0,0 +1,8 @@
{% load i18n %}
<div id="opponent-picker" {% if oob %}hx-swap-oob="true"{% endif %}>
<div class="mb-1 flex items-center justify-between">
<label class="text-xs font-semibold text-muted" for="{{ opponent_picker.opponent.id_for_label }}">{% trans "Opponent" %}</label>
<button type="button" class="font-display text-[11px] font-extrabold tracking-wide text-club uppercase" @click="showOpponentModal = true">{% trans "+ New" %}</button>
</div>
{{ opponent_picker.opponent }}
</div>

View File

@@ -1,12 +1,14 @@
{% extends "mobile/coach/base.html" %} {% extends "mobile/coach/base.html" %}
{% load i18n %} {% load i18n lucide %}
{% comment %} {% comment %}
C4 -- design_handoff_rosterchief_platform/README.md's C4 section: three C4 -- design_handoff_rosterchief_platform/README.md's C4 section, grown
event-type tiles, title/date/location, "who" (teams), and an answers- past the mock: a kind picker, title/location/date, opponent+competition
close row. See CoachCreateEventView's own docstring for what's scoped for a game, "who" (invited/excluded members on top of the auto-linked
down from the mock and why (no group/opponent/competition fields, no active team), a "this repeats" toggle building an EventSeries instead of
"Repeat weekly" yet). The white sticky Cancel/title/Create bar breaks out a single Event, and "+ New" popups for location/opponent. See
CoachCreateEventView's own docstring for the shared-fields/two-forms
design this renders. The white sticky Cancel/title/Create bar breaks out
of the sheet's own padding (-mx-4 -mt-5), same trick calendar_feed_ of the sheet's own padding (-mx-4 -mt-5), same trick calendar_feed_
settings.html/payments.html use for their own back-button bars. settings.html/payments.html use for their own back-button bars.
{% endcomment %} {% endcomment %}
@@ -18,71 +20,191 @@
<button class="font-display text-sm font-extrabold tracking-wide text-club uppercase" type="submit" form="coach-event-form">{% trans "Create" %}</button> <button class="font-display text-sm font-extrabold tracking-wide text-club uppercase" type="submit" form="coach-event-form">{% trans "Create" %}</button>
</div> </div>
{% if form.non_field_errors %} {% for error in form.non_field_errors %}
<div class="m-card border border-danger-border bg-danger-bg p-3 text-sm text-club-dark"> <div class="m-card border border-danger-border bg-danger-bg p-3 text-sm text-club-dark"><p>{{ error }}</p></div>
{% for error in form.non_field_errors %}<p>{{ error }}</p>{% endfor %} {% endfor %}
</div> {% for error in series_form.non_field_errors %}
{% endif %} <div class="m-card border border-danger-border bg-danger-bg p-3 text-sm text-club-dark"><p>{{ error }}</p></div>
{% endfor %}
<form id="coach-event-form" class="flex flex-col gap-4" method="post" action="{% url "mobile:coach_create_event" %}" hx-boost="false"> <div x-data="{ kind: '{{ shared_form.kind.value|default:"training" }}', isRecurring: {% if is_recurring %}true{% else %}false{% endif %}, showLocationModal: false, showOpponentModal: false }">
{% csrf_token %} <form id="coach-event-form" class="flex flex-col gap-4" method="post" action="{% url "mobile:coach_create_event" %}" hx-boost="false">
{% csrf_token %}
{{ shared_form.teams }}
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<label class="flex h-12 items-center justify-center rounded-lg border border-line bg-white font-display text-xs font-extrabold tracking-wide text-muted uppercase has-checked:border-ink has-checked:bg-ink has-checked:text-white"> <label class="flex h-12 items-center justify-center rounded-lg border border-line bg-white font-display text-xs font-extrabold tracking-wide text-muted uppercase has-checked:border-ink has-checked:bg-ink has-checked:text-white">
<input class="sr-only" type="radio" name="kind" value="training" checked> <input class="sr-only" type="radio" name="kind" value="training" x-model="kind">
{% trans "Practice" %} {% trans "Practice" %}
</label> </label>
<label class="flex h-12 items-center justify-center rounded-lg border border-line bg-white font-display text-xs font-extrabold tracking-wide text-muted uppercase has-checked:border-ink has-checked:bg-ink has-checked:text-white"> <label class="flex h-12 items-center justify-center rounded-lg border border-line bg-white font-display text-xs font-extrabold tracking-wide text-muted uppercase has-checked:border-ink has-checked:bg-ink has-checked:text-white">
<input class="sr-only" type="radio" name="kind" value="game"> <input class="sr-only" type="radio" name="kind" value="game" x-model="kind">
{% trans "Game" %} {% trans "Game" %}
</label> </label>
<label class="flex h-12 items-center justify-center rounded-lg border border-line bg-white font-display text-xs font-extrabold tracking-wide text-muted uppercase has-checked:border-ink has-checked:bg-ink has-checked:text-white"> <label class="flex h-12 items-center justify-center rounded-lg border border-line bg-white font-display text-xs font-extrabold tracking-wide text-muted uppercase has-checked:border-ink has-checked:bg-ink has-checked:text-white">
<input class="sr-only" type="radio" name="kind" value="tournament"> <input class="sr-only" type="radio" name="kind" value="tournament" x-model="kind">
{% trans "Tournament" %} {% trans "Tournament" %}
</label> </label>
<label class="flex h-12 items-center justify-center rounded-lg border border-line bg-white font-display text-xs font-extrabold tracking-wide text-muted uppercase has-checked:border-ink has-checked:bg-ink has-checked:text-white"> <label class="flex h-12 items-center justify-center rounded-lg border border-line bg-white font-display text-xs font-extrabold tracking-wide text-muted uppercase has-checked:border-ink has-checked:bg-ink has-checked:text-white">
<input class="sr-only" type="radio" name="kind" value="meeting"> <input class="sr-only" type="radio" name="kind" value="meeting" x-model="kind">
{% trans "Meeting" %} {% trans "Meeting" %}
</label> </label>
</div>
{% for error in form.kind.errors %}<p class="text-xs text-club-dark">{{ error }}</p>{% endfor %}
<div class="m-card flex flex-col p-4">
<div>
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ form.title.id_for_label }}">{% trans "Title" %}</label>
{{ form.title }}
{% for error in form.title.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
</div> </div>
<div class="my-3 h-px bg-rule"></div> {% for error in form.kind.errors %}<p class="text-xs text-club-dark">{{ error }}</p>{% endfor %}
<div> {% for error in series_form.kind.errors %}<p class="text-xs text-club-dark">{{ error }}</p>{% endfor %}
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ form.start.id_for_label }}">{% trans "Date &amp; time" %}</label>
{{ form.start }} <div class="m-card flex items-center justify-between p-4">
{% for error in form.start.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %} <span class="text-sm font-semibold text-ink">{% trans "This repeats" %}</span>
<input class="h-5 w-5 shrink-0 accent-ink" type="checkbox" name="is_recurring" x-model="isRecurring">
</div> </div>
<div class="my-3 h-px bg-rule"></div>
<div> <div class="m-card flex flex-col p-4">
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ form.location.id_for_label }}">{% trans "Location" %}</label> <div>
{{ form.location }} <label class="mb-1 block text-xs font-semibold text-muted" for="{{ shared_form.title.id_for_label }}">{% trans "Title" %}</label>
{{ shared_form.title }}
{% for error in shared_form.title.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
</div>
<div x-show="!isRecurring">
<div class="my-3 h-px bg-rule"></div>
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ form.start.id_for_label }}">{% trans "Date &amp; time" %}</label>
{{ form.start }}
{% for error in form.start.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
</div>
<div x-show="isRecurring" x-cloak>
<div class="my-3 h-px bg-rule"></div>
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ series_form.dtstart.id_for_label }}">{% trans "First occurrence" %}</label>
{{ series_form.dtstart }}
{% for error in series_form.dtstart.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
</div>
<div class="my-3 h-px bg-rule"></div>
<div>
{% include "mobile/coach/_location_picker.html" %}
</div>
</div>
<div x-show="kind === 'game'" x-cloak class="flex flex-col gap-4">
<div class="m-card p-4">
{% include "mobile/coach/_opponent_picker.html" %}
</div>
<div x-show="!isRecurring" class="m-card p-4">
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ form.competition.id_for_label }}">{% trans "Competition" %}</label>
{{ form.competition }}
</div>
</div>
<div x-show="isRecurring" x-cloak class="m-card flex flex-col p-4">
<div class="mb-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Repeats" %}</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ series_form.frequency.id_for_label }}">{% trans "Frequency" %}</label>
{{ series_form.frequency }}
</div>
<div>
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ series_form.interval.id_for_label }}">{% trans "Every" %}</label>
{{ series_form.interval }}
</div>
</div>
<div class="mt-3">
<label class="mb-1 block text-xs font-semibold text-muted">{% trans "On (weekly only)" %}</label>
<div class="flex flex-wrap gap-2">
{% for checkbox in series_form.weekdays %}
<label class="flex h-9 items-center rounded-lg border border-line bg-white px-3 text-xs font-semibold text-ink has-checked:border-ink has-checked:bg-ink has-checked:text-white">
{{ checkbox.tag }} {{ checkbox.choice_label }}
</label>
{% endfor %}
</div>
{% for error in series_form.weekdays.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
</div>
<div class="my-3 h-px bg-rule"></div>
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ series_form.until.id_for_label }}">{% trans "Repeats until" %}</label>
{{ series_form.until }}
<p class="mt-1 text-xs text-dim">{% trans "Leave blank to keep repeating with no end date." %}</p>
<div class="my-3 h-px bg-rule"></div>
<label class="mb-1 block text-xs font-semibold text-muted">{% trans "Length of each occurrence" %}</label>
<div class="grid grid-cols-2 gap-3">
<div>
{{ series_form.duration_hours }}
<p class="mt-1 text-xs text-dim">{% trans "Hours" %}</p>
</div>
<div>
{{ series_form.duration_minutes }}
<p class="mt-1 text-xs text-dim">{% trans "Minutes" %}</p>
</div>
</div>
</div>
<div class="m-card p-4">
<div class="mb-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Who" %}</div>
<p class="mb-3 text-xs text-dim">{% blocktrans with team=active_team.name %}Automatically for {{ team }}. Need more than one team? Create it from the management panel instead.{% endblocktrans %}</p>
{% if shared_form.excluded_members.field.queryset.exists %}
<div class="mb-3">
<div class="mb-1 text-xs font-semibold text-muted">{% trans "Exclude from the roster" %}</div>
<div class="flex flex-col gap-2">
{% for checkbox in shared_form.excluded_members %}
<label class="flex items-center gap-2 text-sm text-ink">{{ checkbox.tag }} {{ checkbox.choice_label }}</label>
{% endfor %}
</div>
</div>
{% endif %}
{% if shared_form.invited_members.field.queryset.exists %}
<details>
<summary class="cursor-pointer text-xs font-semibold text-muted">{% trans "Add someone not on the roster" %}</summary>
<div class="mt-2 flex flex-col gap-2">
{% for checkbox in shared_form.invited_members %}
<label class="flex items-center gap-2 text-sm text-ink">{{ checkbox.tag }} {{ checkbox.choice_label }}</label>
{% endfor %}
</div>
</details>
{% endif %}
</div>
<div x-show="!isRecurring" class="m-card p-4">
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ form.deadline.id_for_label }}">{% trans "Answers close" %}</label>
{{ form.deadline }}
<p class="mt-1 text-xs text-dim">{% trans "Leave blank to keep answers open until the event starts." %}</p>
</div>
<div x-show="isRecurring" x-cloak class="m-card p-4">
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ series_form.deadline_minutes_before.id_for_label }}">{% trans "Answers close (minutes before each occurrence)" %}</label>
{{ series_form.deadline_minutes_before }}
<p class="mt-1 text-xs text-dim">{% trans "Leave blank to keep answers open until each occurrence starts." %}</p>
</div>
</form>
<div class="fixed inset-0 z-30 flex items-end bg-black/60" x-show="showLocationModal" x-cloak @click.self="showLocationModal = false" @location-created.window="showLocationModal = false">
<div class="w-full rounded-t-2xl bg-white p-4" @click.stop>
<div class="mb-3 flex items-center justify-between">
<span class="font-display text-sm font-extrabold tracking-wide text-ink uppercase">{% trans "New location" %}</span>
<button type="button" class="text-dim" @click="showLocationModal = false" aria-label="{% trans "Close" %}">{% lucide "x" size=18 %}</button>
</div>
<form hx-post="{% url "mobile:coach_location_create" %}" hx-target="#location-modal-body" hx-swap="innerHTML" hx-boost="false">
{% csrf_token %}
<div id="location-modal-body">
{% include "mobile/coach/_location_modal_fields.html" %}
</div>
<button class="btn btn-dark mt-3 w-full" type="submit">{% trans "Add location" %}</button>
</form>
</div> </div>
</div> </div>
<div class="m-card p-4"> <div class="fixed inset-0 z-30 flex items-end bg-black/60" x-show="showOpponentModal" x-cloak @click.self="showOpponentModal = false" @opponent-created.window="showOpponentModal = false">
<div class="mb-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Who" %}</div> <div class="w-full rounded-t-2xl bg-white p-4" @click.stop>
<div class="flex flex-col gap-2"> <div class="mb-3 flex items-center justify-between">
{% for checkbox in form.teams %} <span class="font-display text-sm font-extrabold tracking-wide text-ink uppercase">{% trans "New opponent" %}</span>
<label class="flex items-center gap-2 text-sm text-ink"> <button type="button" class="text-dim" @click="showOpponentModal = false" aria-label="{% trans "Close" %}">{% lucide "x" size=18 %}</button>
{{ checkbox.tag }} </div>
{{ checkbox.choice_label }} <form hx-post="{% url "mobile:coach_opponent_create" %}" hx-target="#opponent-modal-body" hx-swap="innerHTML" hx-boost="false">
</label> {% csrf_token %}
{% endfor %} <div id="opponent-modal-body">
{% include "mobile/coach/_opponent_modal_fields.html" %}
</div>
<button class="btn btn-dark mt-3 w-full" type="submit">{% trans "Add opponent" %}</button>
</form>
</div> </div>
{% for error in form.teams.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
</div> </div>
</div>
<div class="m-card p-4">
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ form.deadline.id_for_label }}">{% trans "Answers close" %}</label>
{{ form.deadline }}
<p class="mt-1 text-xs text-dim">{% trans "Leave blank to keep answers open until the event starts." %}</p>
</div>
</form>
{% endblock content %} {% endblock content %}

View File

@@ -1,6 +1,7 @@
import datetime import datetime
from decimal import Decimal from decimal import Decimal
from django import forms
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
@@ -9,7 +10,7 @@ from django.utils import timezone, translation
from icalendar import Calendar as ICalCalendar from icalendar import Calendar as ICalCalendar
from club.models import Club, ClubMembership, DuesInvoice, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor from club.models import Club, ClubMembership, DuesInvoice, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
from events.models import Attendance, Event, EventReferee, Lineup, LineupSelection, Location, RefereeSignup from events.models import Attendance, Competition, Event, EventReferee, EventSeries, Lineup, LineupSelection, Location, Opponent, RefereeSignup
from events.services.attendance import record_check_in from events.services.attendance import record_check_in
from members.models import Family, FamilyMembership, Member from members.models import Family, FamilyMembership, Member
from news.models import News from news.models import News
@@ -3392,6 +3393,147 @@ class CoachAttendanceRemindSilentViewTests(TestCase):
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class CoachLocationCreateViewTests(TestCase):
"""New event's "+ New location" popup -- see CoachLocationCreateView's
own docstring for the primary-target/out-of-band-swap shape."""
@classmethod
def setUpTestData(cls):
cls.club = make_club()
today = timezone.localdate()
cls.season = Season.objects.create(club=cls.club, start_date=today - datetime.timedelta(days=30), end_date=today + datetime.timedelta(days=300))
cls.user = User.objects.create_user(email="coach@example.com", password="pw-secret-123")
cls.member = Member.objects.create(first_name="Sam", last_name="Coach", email="coach@example.com", user=cls.user)
cls.team = Team.objects.create(club=cls.club, name="U16", short_name="U16")
cls.position = Position.objects.create(club=cls.club, name="Head coach", short_name="HC", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=cls.team, member=cls.member, season=cls.season, position=cls.position)
def _post(self, **overrides):
data = {"name": "Sportoase", "address": "1 Main St", "city": "Antwerp", "zip_code": "2000", "country": "BE"}
data.update(overrides)
return self.client.post(reverse("mobile:coach_location_create"), data, HTTP_HOST="ajax-united.rosterchief.app")
def test_requires_login(self):
response = self._post()
self.assertEqual(response.status_code, 302)
def test_non_managing_staff_cannot_create_a_location(self):
physio_position = Position.objects.create(club=self.club, name="Physio", short_name="PHY", staff_position=True, management_position=False)
physio_user = User.objects.create_user(email="physio@example.com", password="pw-secret-123")
physio_member = Member.objects.create(first_name="Pat", last_name="Physio", user=physio_user)
StaffAssignment.objects.create(team=self.team, member=physio_member, season=self.season, position=physio_position)
self.client.force_login(physio_user)
response = self._post()
self.assertEqual(response.status_code, 403)
self.assertFalse(Location.objects.filter(name="Sportoase").exists())
def test_creates_a_location_scoped_to_the_club(self):
self.client.force_login(self.user)
response = self._post()
location = Location.objects.get(name="Sportoase")
self.assertEqual(location.club, self.club)
self.assertEqual(response.status_code, 200)
self.assertEqual(response["HX-Trigger"], "location-created")
def test_success_response_carries_an_out_of_band_picker_with_it_selected(self):
self.client.force_login(self.user)
response = self._post()
location = Location.objects.get(name="Sportoase")
body = response.content.decode()
self.assertIn('id="location-picker"', body)
self.assertIn("hx-swap-oob", body)
self.assertIn(f'value="{location.pk}" selected', body)
def test_invalid_submission_reshows_the_modal_fields_with_errors(self):
self.client.force_login(self.user)
response = self._post(name="")
self.assertEqual(response.status_code, 200)
self.assertNotIn("HX-Trigger", response)
self.assertFalse(Location.objects.filter(city="Antwerp").exists())
self.assertContains(response, "This field is required")
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class CoachOpponentCreateViewTests(TestCase):
"""New event's "+ New opponent" popup -- same shape as
CoachLocationCreateViewTests, for Opponent instead."""
@classmethod
def setUpTestData(cls):
cls.club = make_club()
today = timezone.localdate()
cls.season = Season.objects.create(club=cls.club, start_date=today - datetime.timedelta(days=30), end_date=today + datetime.timedelta(days=300))
cls.user = User.objects.create_user(email="coach@example.com", password="pw-secret-123")
cls.member = Member.objects.create(first_name="Sam", last_name="Coach", email="coach@example.com", user=cls.user)
cls.team = Team.objects.create(club=cls.club, name="U16", short_name="U16")
cls.position = Position.objects.create(club=cls.club, name="Head coach", short_name="HC", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=cls.team, member=cls.member, season=cls.season, position=cls.position)
def _post(self, **overrides):
data = {"name": "Rival FC"}
data.update(overrides)
return self.client.post(reverse("mobile:coach_opponent_create"), data, HTTP_HOST="ajax-united.rosterchief.app")
def test_requires_login(self):
response = self._post()
self.assertEqual(response.status_code, 302)
def test_non_managing_staff_cannot_create_an_opponent(self):
physio_position = Position.objects.create(club=self.club, name="Physio", short_name="PHY", staff_position=True, management_position=False)
physio_user = User.objects.create_user(email="physio@example.com", password="pw-secret-123")
physio_member = Member.objects.create(first_name="Pat", last_name="Physio", user=physio_user)
StaffAssignment.objects.create(team=self.team, member=physio_member, season=self.season, position=physio_position)
self.client.force_login(physio_user)
response = self._post()
self.assertEqual(response.status_code, 403)
self.assertFalse(Opponent.objects.filter(name="Rival FC").exists())
def test_creates_an_opponent_scoped_to_the_club_without_a_logo_field(self):
self.client.force_login(self.user)
response = self._post()
opponent = Opponent.objects.get(name="Rival FC")
self.assertEqual(opponent.club, self.club)
self.assertEqual(response.status_code, 200)
self.assertEqual(response["HX-Trigger"], "opponent-created")
self.assertNotContains(response, 'name="logo"')
def test_success_response_carries_an_out_of_band_picker_with_it_selected(self):
self.client.force_login(self.user)
response = self._post()
opponent = Opponent.objects.get(name="Rival FC")
body = response.content.decode()
self.assertIn('id="opponent-picker"', body)
self.assertIn("hx-swap-oob", body)
self.assertIn(f'value="{opponent.pk}" selected', body)
def test_invalid_submission_reshows_the_modal_fields_with_errors(self):
self.client.force_login(self.user)
response = self._post(name="")
self.assertEqual(response.status_code, 200)
self.assertNotIn("HX-Trigger", response)
self.assertFalse(Opponent.objects.exists())
self.assertContains(response, "This field is required")
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"]) @override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class CoachCreateEventViewTests(TestCase): class CoachCreateEventViewTests(TestCase):
"""C4 -- reuses management.forms.EventForm as-is; see CoachCreateEventView's """C4 -- reuses management.forms.EventForm as-is; see CoachCreateEventView's
@@ -3498,6 +3640,192 @@ class CoachCreateEventViewTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertFalse(Event.objects.filter(title=f"Not a {kind} event").exists()) self.assertFalse(Event.objects.filter(title=f"Not a {kind} event").exists())
def test_teams_is_hidden_and_locked_to_the_active_team(self):
self.client.force_login(self.user)
response = self.client.get(reverse("mobile:coach_create_event"), HTTP_HOST="ajax-united.rosterchief.app")
self.assertIsInstance(response.context["form"].fields["teams"].widget, forms.MultipleHiddenInput)
self.assertEqual(response.context["form"].initial["teams"], [self.team.pk])
self.assertContains(response, f'<input type="hidden" name="teams" value="{self.team.pk}"')
self.assertNotContains(response, 'name="teams" type="checkbox"')
def test_kind_defaults_to_training_not_the_model_default(self):
# Event.kind's own model default is "other", which isn't even one of
# the four tiles this screen offers -- regression check for the same
# "unsaved instance already set form.initial" trap teams hits above.
self.client.force_login(self.user)
response = self.client.get(reverse("mobile:coach_create_event"), HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.context["form"].initial.get("kind"), "training")
self.assertContains(response, "kind: 'training'")
def test_groups_and_club_wide_are_not_offered(self):
self.client.force_login(self.user)
response = self.client.get(reverse("mobile:coach_create_event"), HTTP_HOST="ajax-united.rosterchief.app")
self.assertNotIn("groups", response.context["form"].fields)
self.assertNotIn("club_wide", response.context["form"].fields)
def test_can_set_opponent_and_competition_for_a_game(self):
opponent = Opponent.objects.create(club=self.club, name="Rival FC")
Competition.objects.create(name="Regional League", module="none")
self.client.force_login(self.user)
self._post(kind="game", title="Away game", opponent=str(opponent.pk), competition="Regional League")
event = Event.objects.get(title="Away game")
self.assertEqual(event.opponent, opponent)
self.assertEqual(event.competition, "Regional League")
def test_invited_members_pool_excludes_the_current_roster(self):
on_roster = Member.objects.create(first_name="On", last_name="Roster")
TeamMembership.objects.create(team=self.team, member=on_roster, season=self.season)
off_roster = Member.objects.create(first_name="Off", last_name="Roster")
ClubMembership.objects.create(club=self.club, member=off_roster, season=self.season, status=ClubMembership.StatusChoices.ACTIVE, kind=ClubMembership.Kind.MEMBER)
self.client.force_login(self.user)
response = self.client.get(reverse("mobile:coach_create_event"), HTTP_HOST="ajax-united.rosterchief.app")
pool = response.context["form"].fields["invited_members"].queryset
self.assertIn(off_roster, pool)
self.assertNotIn(on_roster, pool)
def test_excluded_members_pool_is_the_current_roster(self):
on_roster = Member.objects.create(first_name="On", last_name="Roster")
TeamMembership.objects.create(team=self.team, member=on_roster, season=self.season)
self.client.force_login(self.user)
response = self.client.get(reverse("mobile:coach_create_event"), HTTP_HOST="ajax-united.rosterchief.app")
pool = response.context["form"].fields["excluded_members"].queryset
self.assertIn(on_roster, pool)
def test_invited_and_excluded_members_actually_render_as_checkboxes(self):
# Regression: swapping a ModelMultipleChoiceField's widget *after*
# setting its queryset silently drops the choices the queryset-setter
# already pushed onto the old widget -- the checkbox list would
# render completely empty despite the queryset (and POST handling)
# being correct, so a plain queryset-only assertion (like the two
# tests above) can't catch this. See _scope_shared_fields' own
# comment for the mechanism.
on_roster = Member.objects.create(first_name="On", last_name="Roster")
TeamMembership.objects.create(team=self.team, member=on_roster, season=self.season)
off_roster = Member.objects.create(first_name="Off", last_name="Roster")
ClubMembership.objects.create(club=self.club, member=off_roster, season=self.season, status=ClubMembership.StatusChoices.ACTIVE, kind=ClubMembership.Kind.MEMBER)
self.client.force_login(self.user)
response = self.client.get(reverse("mobile:coach_create_event"), HTTP_HOST="ajax-united.rosterchief.app")
self.assertContains(response, "On Roster")
self.assertContains(response, "Off Roster")
def test_can_invite_an_extra_member_and_exclude_a_roster_member(self):
rostered = Member.objects.create(first_name="Rostered", last_name="Player")
TeamMembership.objects.create(team=self.team, member=rostered, season=self.season)
guest = Member.objects.create(first_name="Guest", last_name="Player")
ClubMembership.objects.create(club=self.club, member=guest, season=self.season, status=ClubMembership.StatusChoices.ACTIVE, kind=ClubMembership.Kind.MEMBER)
self.client.force_login(self.user)
self._post(title="Call-up practice", excluded_members=[str(rostered.pk)], invited_members=[str(guest.pk)])
event = Event.objects.get(title="Call-up practice")
self.assertFalse(Attendance.objects.filter(event=event, member=rostered).exists())
self.assertTrue(Attendance.objects.filter(event=event, member=guest).exists())
def test_valid_post_notifies_the_invited_roster(self):
rostered = Member.objects.create(first_name="Rostered", last_name="Player", email="rostered@example.com")
TeamMembership.objects.create(team=self.team, member=rostered, season=self.season)
self.client.force_login(self.user)
response = self._post(title="Notify practice")
self.assertRedirects(response, reverse("mobile:coach_today"), fetch_redirect_response=False)
event = Event.objects.get(title="Notify practice")
self.assertTrue(Notification.objects.filter(member=rostered, title=event.title).exists())
def test_weekday_checkboxes_actually_render(self):
# Same widget-swap-after-choices trap as invited/excluded_members
# above, for the plain MultipleChoiceField shape of it -- see
# build_series_form's own comment.
self.client.force_login(self.user)
response = self.client.get(reverse("mobile:coach_create_event"), HTTP_HOST="ajax-united.rosterchief.app")
self.assertContains(response, 'value="MO"')
self.assertContains(response, 'value="SU"')
def test_recurring_post_creates_a_series_with_occurrences(self):
self.client.force_login(self.user)
dtstart = timezone.localtime(timezone.now() + datetime.timedelta(days=1)).strftime("%Y-%m-%dT%H:%M")
response = self.client.post(
reverse("mobile:coach_create_event"),
{
"is_recurring": "on",
"kind": "training",
"title": "Weekly practice",
"teams": [str(self.team.pk)],
"dtstart": dtstart,
"frequency": "weekly",
"interval": "1",
"weekdays": ["MO", "WE"],
},
HTTP_HOST="ajax-united.rosterchief.app",
)
self.assertRedirects(response, reverse("mobile:coach_today"), fetch_redirect_response=False)
series = EventSeries.objects.get(title="Weekly practice")
self.assertEqual(series.club, self.club)
self.assertIn(self.team, series.teams.all())
self.assertTrue(series.occurrences.exists())
def test_recurring_series_does_not_send_a_new_event_notification(self):
rostered = Member.objects.create(first_name="Rostered", last_name="Player", email="rostered@example.com")
TeamMembership.objects.create(team=self.team, member=rostered, season=self.season)
self.client.force_login(self.user)
dtstart = timezone.localtime(timezone.now() + datetime.timedelta(days=1)).strftime("%Y-%m-%dT%H:%M")
self.client.post(
reverse("mobile:coach_create_event"),
{
"is_recurring": "on",
"kind": "training",
"title": "Silent series",
"teams": [str(self.team.pk)],
"dtstart": dtstart,
"frequency": "weekly",
"interval": "1",
"weekdays": ["MO"],
},
HTTP_HOST="ajax-united.rosterchief.app",
)
# Matches management.views.EventSeriesCreateView's own behaviour --
# not a mobile-specific gap. Occurrences still get their own
# attendance rows (so there's something to notify about later via
# send_deadline_reminders), just no immediate per-occurrence push.
self.assertFalse(Notification.objects.filter(member=rostered).exists())
series = EventSeries.objects.get(title="Silent series")
occurrence = series.occurrences.first()
self.assertIsNotNone(occurrence)
self.assertTrue(Attendance.objects.filter(event=occurrence, member=rostered).exists())
def test_recurring_weekly_requires_a_weekday(self):
self.client.force_login(self.user)
dtstart = timezone.localtime(timezone.now() + datetime.timedelta(days=1)).strftime("%Y-%m-%dT%H:%M")
response = self.client.post(
reverse("mobile:coach_create_event"),
{"is_recurring": "on", "kind": "training", "title": "No weekday", "teams": [str(self.team.pk)], "dtstart": dtstart, "frequency": "weekly", "interval": "1"},
HTTP_HOST="ajax-united.rosterchief.app",
)
self.assertEqual(response.status_code, 200)
self.assertFalse(EventSeries.objects.filter(title="No weekday").exists())
def test_missing_title_reshows_the_form_with_errors(self): def test_missing_title_reshows_the_form_with_errors(self):
self.client.force_login(self.user) self.client.force_login(self.user)
@@ -3556,6 +3884,14 @@ class CoachCreateNewsViewTests(TestCase):
self.assertEqual(team_choices, [self.team]) self.assertEqual(team_choices, [self.team])
self.assertNotIn(other_team, team_choices) self.assertNotIn(other_team, team_choices)
def test_teams_field_is_prechecked_with_the_active_team(self):
self.client.force_login(self.user)
response = self.client.get(reverse("mobile:coach_create_news"), HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.context["form"].initial.get("teams"), [self.team.pk])
self.assertContains(response, f'value="{self.team.pk}" id="id_teams_0" checked')
def test_valid_post_creates_a_pending_review_post_scoped_to_the_team(self): def test_valid_post_creates_a_pending_review_post_scoped_to_the_team(self):
self.client.force_login(self.user) self.client.force_login(self.user)

View File

@@ -32,6 +32,8 @@ urlpatterns = [
path("coach/attendance/<uuid:event_id>/", coach_views.CoachAttendanceView.as_view(), name="coach_attendance"), path("coach/attendance/<uuid:event_id>/", coach_views.CoachAttendanceView.as_view(), name="coach_attendance"),
path("coach/attendance/<uuid:event_id>/remind-silent/", coach_views.CoachAttendanceRemindSilentView.as_view(), name="coach_attendance_remind_silent"), path("coach/attendance/<uuid:event_id>/remind-silent/", coach_views.CoachAttendanceRemindSilentView.as_view(), name="coach_attendance_remind_silent"),
path("coach/events/new/", coach_views.CoachCreateEventView.as_view(), name="coach_create_event"), path("coach/events/new/", coach_views.CoachCreateEventView.as_view(), name="coach_create_event"),
path("coach/locations/new/", coach_views.CoachLocationCreateView.as_view(), name="coach_location_create"),
path("coach/opponents/new/", coach_views.CoachOpponentCreateView.as_view(), name="coach_opponent_create"),
path("coach/news/new/", coach_views.CoachCreateNewsView.as_view(), name="coach_create_news"), path("coach/news/new/", coach_views.CoachCreateNewsView.as_view(), name="coach_create_news"),
path("coach/roster/add/", coach_views.CoachAddPlayerView.as_view(), name="coach_add_player"), path("coach/roster/add/", coach_views.CoachAddPlayerView.as_view(), name="coach_add_player"),
path("coach/staff/add/", coach_views.CoachAddStaffView.as_view(), name="coach_add_staff"), path("coach/staff/add/", coach_views.CoachAddStaffView.as_view(), name="coach_add_staff"),

View File

@@ -47,6 +47,7 @@
--radius-md: 0.375rem; --radius-md: 0.375rem;
--radius-lg: 0.5rem; --radius-lg: 0.5rem;
--radius-xl: 0.75rem; --radius-xl: 0.75rem;
--radius-2xl: 1rem;
--ease-out: cubic-bezier(0, 0, 0.2, 1); --ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; --animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
@@ -3234,6 +3235,9 @@
.z-20 { .z-20 {
z-index: 20; z-index: 20;
} }
.z-30 {
z-index: 30;
}
.z-50 { .z-50 {
z-index: 50; z-index: 50;
} }
@@ -4495,6 +4499,10 @@
.rounded-xl { .rounded-xl {
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
} }
.rounded-t-2xl {
border-top-left-radius: var(--radius-2xl);
border-top-right-radius: var(--radius-2xl);
}
.border { .border {
border-style: var(--tw-border-style); border-style: var(--tw-border-style);
border-width: 1px; border-width: 1px;

File diff suppressed because one or more lines are too long