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:
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
31
mobile/templates/mobile/coach/_location_modal_fields.html
Normal file
31
mobile/templates/mobile/coach/_location_modal_fields.html
Normal 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>
|
||||
8
mobile/templates/mobile/coach/_location_picker.html
Normal file
8
mobile/templates/mobile/coach/_location_picker.html
Normal 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>
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
8
mobile/templates/mobile/coach/_opponent_picker.html
Normal file
8
mobile/templates/mobile/coach/_opponent_picker.html
Normal 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>
|
||||
@@ -1,12 +1,14 @@
|
||||
{% extends "mobile/coach/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load i18n lucide %}
|
||||
|
||||
{% comment %}
|
||||
C4 -- design_handoff_rosterchief_platform/README.md's C4 section: three
|
||||
event-type tiles, title/date/location, "who" (teams), and an answers-
|
||||
close row. See CoachCreateEventView's own docstring for what's scoped
|
||||
down from the mock and why (no group/opponent/competition fields, no
|
||||
"Repeat weekly" yet). The white sticky Cancel/title/Create bar breaks out
|
||||
C4 -- design_handoff_rosterchief_platform/README.md's C4 section, grown
|
||||
past the mock: a kind picker, title/location/date, opponent+competition
|
||||
for a game, "who" (invited/excluded members on top of the auto-linked
|
||||
active team), a "this repeats" toggle building an EventSeries instead of
|
||||
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_
|
||||
settings.html/payments.html use for their own back-button bars.
|
||||
{% 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>
|
||||
</div>
|
||||
|
||||
{% if form.non_field_errors %}
|
||||
<div class="m-card border border-danger-border bg-danger-bg p-3 text-sm text-club-dark">
|
||||
{% for error in form.non_field_errors %}<p>{{ error }}</p>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% 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"><p>{{ error }}</p></div>
|
||||
{% endfor %}
|
||||
{% for error in series_form.non_field_errors %}
|
||||
<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">
|
||||
{% csrf_token %}
|
||||
<div x-data="{ kind: '{{ shared_form.kind.value|default:"training" }}', isRecurring: {% if is_recurring %}true{% else %}false{% endif %}, showLocationModal: false, showOpponentModal: false }">
|
||||
<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">
|
||||
<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>
|
||||
{% trans "Practice" %}
|
||||
</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">
|
||||
<input class="sr-only" type="radio" name="kind" value="game">
|
||||
{% trans "Game" %}
|
||||
</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">
|
||||
<input class="sr-only" type="radio" name="kind" value="tournament">
|
||||
{% trans "Tournament" %}
|
||||
</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">
|
||||
<input class="sr-only" type="radio" name="kind" value="meeting">
|
||||
{% trans "Meeting" %}
|
||||
</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 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">
|
||||
<input class="sr-only" type="radio" name="kind" value="training" x-model="kind">
|
||||
{% trans "Practice" %}
|
||||
</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">
|
||||
<input class="sr-only" type="radio" name="kind" value="game" x-model="kind">
|
||||
{% trans "Game" %}
|
||||
</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">
|
||||
<input class="sr-only" type="radio" name="kind" value="tournament" x-model="kind">
|
||||
{% trans "Tournament" %}
|
||||
</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">
|
||||
<input class="sr-only" type="radio" name="kind" value="meeting" x-model="kind">
|
||||
{% trans "Meeting" %}
|
||||
</label>
|
||||
</div>
|
||||
<div class="my-3 h-px bg-rule"></div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ form.start.id_for_label }}">{% trans "Date & time" %}</label>
|
||||
{{ form.start }}
|
||||
{% for error in form.start.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
|
||||
{% for error in form.kind.errors %}<p class="text-xs text-club-dark">{{ error }}</p>{% endfor %}
|
||||
{% for error in series_form.kind.errors %}<p class="text-xs text-club-dark">{{ error }}</p>{% endfor %}
|
||||
|
||||
<div class="m-card flex items-center justify-between p-4">
|
||||
<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 class="my-3 h-px bg-rule"></div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ form.location.id_for_label }}">{% trans "Location" %}</label>
|
||||
{{ form.location }}
|
||||
|
||||
<div class="m-card flex flex-col p-4">
|
||||
<div>
|
||||
<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 & 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 class="m-card p-4">
|
||||
<div class="mb-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Who" %}</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
{% for checkbox in form.teams %}
|
||||
<label class="flex items-center gap-2 text-sm text-ink">
|
||||
{{ checkbox.tag }}
|
||||
{{ checkbox.choice_label }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
<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="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 opponent" %}</span>
|
||||
<button type="button" class="text-dim" @click="showOpponentModal = false" aria-label="{% trans "Close" %}">{% lucide "x" size=18 %}</button>
|
||||
</div>
|
||||
<form hx-post="{% url "mobile:coach_opponent_create" %}" hx-target="#opponent-modal-body" hx-swap="innerHTML" hx-boost="false">
|
||||
{% csrf_token %}
|
||||
<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>
|
||||
{% for error in form.teams.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
|
||||
</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>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
338
mobile/tests.py
338
mobile/tests.py
@@ -1,6 +1,7 @@
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from django import forms
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase, override_settings
|
||||
@@ -9,7 +10,7 @@ from django.utils import timezone, translation
|
||||
from icalendar import Calendar as ICalCalendar
|
||||
|
||||
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 members.models import Family, FamilyMembership, Member
|
||||
from news.models import News
|
||||
@@ -3392,6 +3393,147 @@ class CoachAttendanceRemindSilentViewTests(TestCase):
|
||||
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"])
|
||||
class CoachCreateEventViewTests(TestCase):
|
||||
"""C4 -- reuses management.forms.EventForm as-is; see CoachCreateEventView's
|
||||
@@ -3498,6 +3640,192 @@ class CoachCreateEventViewTests(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
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):
|
||||
self.client.force_login(self.user)
|
||||
|
||||
@@ -3556,6 +3884,14 @@ class CoachCreateNewsViewTests(TestCase):
|
||||
self.assertEqual(team_choices, [self.team])
|
||||
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):
|
||||
self.client.force_login(self.user)
|
||||
|
||||
|
||||
@@ -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>/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/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/roster/add/", coach_views.CoachAddPlayerView.as_view(), name="coach_add_player"),
|
||||
path("coach/staff/add/", coach_views.CoachAddStaffView.as_view(), name="coach_add_staff"),
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-xl: 0.75rem;
|
||||
--radius-2xl: 1rem;
|
||||
--ease-out: cubic-bezier(0, 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;
|
||||
@@ -3234,6 +3235,9 @@
|
||||
.z-20 {
|
||||
z-index: 20;
|
||||
}
|
||||
.z-30 {
|
||||
z-index: 30;
|
||||
}
|
||||
.z-50 {
|
||||
z-index: 50;
|
||||
}
|
||||
@@ -4495,6 +4499,10 @@
|
||||
.rounded-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-style: var(--tw-border-style);
|
||||
border-width: 1px;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user