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.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
|
||||||
|
|||||||
@@ -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" %}
|
{% 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 %}
|
||||||
|
|
||||||
|
<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">
|
<form id="coach-event-form" class="flex flex-col gap-4" method="post" action="{% url "mobile:coach_create_event" %}" hx-boost="false">
|
||||||
{% csrf_token %}
|
{% 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>
|
</div>
|
||||||
{% for error in form.kind.errors %}<p class="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="m-card flex flex-col p-4">
|
<div class="m-card flex flex-col p-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ form.title.id_for_label }}">{% trans "Title" %}</label>
|
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ shared_form.title.id_for_label }}">{% trans "Title" %}</label>
|
||||||
{{ form.title }}
|
{{ shared_form.title }}
|
||||||
{% for error in form.title.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
|
{% for error in shared_form.title.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div x-show="!isRecurring">
|
||||||
<div class="my-3 h-px bg-rule"></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>
|
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ form.start.id_for_label }}">{% trans "Date & time" %}</label>
|
||||||
{{ form.start }}
|
{{ form.start }}
|
||||||
{% for error in form.start.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
|
{% for error in form.start.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
|
||||||
</div>
|
</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 class="my-3 h-px bg-rule"></div>
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ form.location.id_for_label }}">{% trans "Location" %}</label>
|
{% include "mobile/coach/_location_picker.html" %}
|
||||||
{{ form.location }}
|
</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>
|
</div>
|
||||||
|
|
||||||
<div class="m-card p-4">
|
<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="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">
|
<div class="flex flex-col gap-2">
|
||||||
{% for checkbox in form.teams %}
|
{% for checkbox in shared_form.excluded_members %}
|
||||||
<label class="flex items-center gap-2 text-sm text-ink">
|
<label class="flex items-center gap-2 text-sm text-ink">{{ checkbox.tag }} {{ checkbox.choice_label }}</label>
|
||||||
{{ checkbox.tag }}
|
|
||||||
{{ checkbox.choice_label }}
|
|
||||||
</label>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% for error in form.teams.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
|
</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>
|
||||||
|
|
||||||
<div class="m-card p-4">
|
<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>
|
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ form.deadline.id_for_label }}">{% trans "Answers close" %}</label>
|
||||||
{{ form.deadline }}
|
{{ form.deadline }}
|
||||||
<p class="mt-1 text-xs text-dim">{% trans "Leave blank to keep answers open until the event starts." %}</p>
|
<p class="mt-1 text-xs text-dim">{% trans "Leave blank to keep answers open until the event starts." %}</p>
|
||||||
</div>
|
</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>
|
</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="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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
338
mobile/tests.py
338
mobile/tests.py
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user