Add Coach mode C4: create event, reusing the desktop's own EventForm

management.forms.EventForm already scopes its teams field to
teams_managed_by(user, club) via EventAudienceFormMixin -- exactly the
restriction a coach needs, so it's reused as-is rather than duplicated.
Only a subset of the desktop form's fields is rendered (title, kind, teams,
location, start, answers-close deadline); the rest stays unset, all of it
optional on the model except max_referees, which is dropped from the form
entirely so its own default (2) applies instead of demanding a value this
screen has no field for.

After a successful save, replicates management.views.EventCreateView's own
notify_new_event.delay(...) call by hand, since this isn't a CreateView --
attendance sync itself is automatic via events/signals.py.

"Repeat weekly" from the design mock isn't wired up this stage -- the
recurring-series machinery is a separate form with its own fields; adding
it is later work, not something to fake with an inert toggle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
2026-08-21 22:28:44 +02:00
parent 6a9a6204ed
commit 995f7ecfc9
7 changed files with 259 additions and 1 deletions

View File

@@ -5,6 +5,7 @@ club/season plumbing already factored into club.services.access -- see
mobile/coach_mixins.py's CoachScopeMixin for the shared scaffolding. mobile/coach_mixins.py's CoachScopeMixin for the shared scaffolding.
""" """
from django import forms
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
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
@@ -17,9 +18,12 @@ from club.services.access import current_season
from controlpanel.messages import notify from controlpanel.messages import notify
from events.models import Attendance, Event from events.models import Attendance, Event
from events.services.attendance import record_check_in from events.services.attendance import record_check_in
from events.tasks import notify_new_event
from management.forms import EventForm
from teams.models import TeamMembership from teams.models import TeamMembership
from .coach_mixins import CoachScopeMixin from .coach_mixins import CoachScopeMixin
from .forms import _INPUT_CLASSES
#: RSVP states that count as "in" for the stat tile -- present/selected are an #: RSVP states that count as "in" for the stat tile -- present/selected are an
#: explicit yes, maybe is still a lean-in rather than silence. #: explicit yes, maybe is still a lean-in rather than silence.
@@ -174,3 +178,76 @@ class CoachAttendanceView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
body = _("%(count)d players checked in.") % {"count": checked_in} body = _("%(count)d players checked in.") % {"count": checked_in}
notify(request, f"s|{title}|{body}") notify(request, f"s|{title}|{body}")
return HttpResponseRedirect(reverse("mobile:coach_today")) return HttpResponseRedirect(reverse("mobile:coach_today"))
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.
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.
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.
"""
template_name = "mobile/coach/event_form.html"
screen_title = _("New event")
active_tab = "coach_today"
def get(self, request, *args, **kwargs):
if not self.can_manage_active_team:
return HttpResponseRedirect(reverse("mobile:coach_today"))
return super().get(request, *args, **kwargs)
def build_form(self, data=None):
instance = Event(club=self.request.club, created_by=self.me)
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.
del form.fields["max_referees"]
# 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"):
form.fields[field_name].widget.attrs["class"] = _INPUT_CLASSES
return form
def get_context_data(self, **kwargs):
kwargs.setdefault("form", self.build_form())
return super().get_context_data(**kwargs)
def post(self, request, *args, **kwargs):
if not self.can_manage_active_team:
return HttpResponseForbidden()
form = self.build_form(request.POST)
if not form.is_valid():
return self.render_to_response(self.get_context_data(form=form))
event = form.save()
body = _("%(event)s” created.") % {"event": event}
notify(request, f"s|{_('Event created')}|{body}")
# A deliberately-planned single event, same as the desktop create
# flow -- see notify_new_event's own docstring for why a recurring
# series' occurrences aren't wired to this.
notify_new_event.delay(str(event.pk))
return HttpResponseRedirect(reverse("mobile:coach_today"))

View File

@@ -0,0 +1,83 @@
{% extends "mobile/coach/base.html" %}
{% load i18n %}
{% 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
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 %}
{% block content %}
<div class="-mx-4 -mt-5 flex items-center gap-3 border-b border-line bg-white px-4 py-3">
<a class="font-display text-sm font-extrabold tracking-wide text-muted uppercase" href="{% url "mobile:coach_today" %}">{% trans "Cancel" %}</a>
<span class="min-w-0 flex-1 truncate text-center font-display text-lg font-extrabold text-ink uppercase">{% trans "New event" %}</span>
<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 %}
<form id="coach-event-form" method="post" action="{% url "mobile:coach_create_event" %}">
{% csrf_token %}
<div class="grid grid-cols-3 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="other">
{% trans "Other" %}
</label>
</div>
<div class="m-card flex flex-col p-4">
<div>
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ form.title.id_for_label }}">{% trans "Title" %}</label>
{{ form.title }}
{% for error in form.title.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
</div>
<div 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 &amp; time" %}</label>
{{ form.start }}
{% for error in form.start.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
</div>
<div 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>
</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>
{% 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>
{% endblock content %}

View File

@@ -16,6 +16,10 @@
<p class="mt-1 text-sm text-muted">{% trans "Once you're assigned to a team's staff, its schedule and roster will show up here." %}</p> <p class="mt-1 text-sm text-muted">{% trans "Once you're assigned to a team's staff, its schedule and roster will show up here." %}</p>
</div> </div>
{% else %} {% else %}
{% if can_manage_active_team %}
<a class="btn btn-dark w-full" href="{% url "mobile:coach_create_event" %}">{% trans "New event" %}</a>
{% endif %}
<div class="grid grid-cols-3 gap-2.5"> <div class="grid grid-cols-3 gap-2.5">
<div class="m-card p-3 text-center"> <div class="m-card p-3 text-center">
<div class="font-display text-2xl leading-none font-extrabold text-ink">{{ squad_count }}</div> <div class="font-display text-2xl leading-none font-extrabold text-ink">{{ squad_count }}</div>

View File

@@ -1967,3 +1967,85 @@ class CoachAttendanceViewTests(TestCase):
response = self.client.get(reverse("mobile:coach_attendance", kwargs={"event_id": other_event.pk}), HTTP_HOST="ajax-united.rosterchief.app") response = self.client.get(reverse("mobile:coach_attendance", kwargs={"event_id": other_event.pk}), HTTP_HOST="ajax-united.rosterchief.app")
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 CoachCreateEventViewTests(TestCase):
"""C4 -- reuses management.forms.EventForm as-is; see CoachCreateEventView's
own docstring for what's scoped down from the design mock."""
@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):
start = timezone.localtime(timezone.now() + datetime.timedelta(days=5)).strftime("%Y-%m-%dT%H:%M")
data = {"kind": "training", "title": "Extra practice", "start": start, "teams": [str(self.team.pk)]}
data.update(overrides)
return self.client.post(reverse("mobile:coach_create_event"), data, HTTP_HOST="ajax-united.rosterchief.app")
def test_requires_login(self):
response = self.client.get(reverse("mobile:coach_create_event"), HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.status_code, 302)
def test_get_redirects_a_non_managing_staffer(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.client.get(reverse("mobile:coach_create_event"), HTTP_HOST="ajax-united.rosterchief.app")
self.assertRedirects(response, reverse("mobile:coach_today"), fetch_redirect_response=False)
def test_teams_field_is_scoped_to_managed_teams(self):
other_team = Team.objects.create(club=self.club, name="U14", short_name="U14")
self.client.force_login(self.user)
response = self.client.get(reverse("mobile:coach_create_event"), HTTP_HOST="ajax-united.rosterchief.app")
team_choices = list(response.context["form"].fields["teams"].queryset)
self.assertEqual(team_choices, [self.team])
self.assertNotIn(other_team, team_choices)
def test_valid_post_creates_the_event_and_redirects(self):
self.client.force_login(self.user)
response = self._post(title="Extra practice")
event = Event.objects.get(title="Extra practice")
self.assertEqual(event.club, self.club)
self.assertEqual(event.kind, Event.EventKind.TRAINING)
self.assertEqual(event.created_by, self.member)
self.assertIn(self.team, event.teams.all())
self.assertRedirects(response, reverse("mobile:coach_today"), fetch_redirect_response=False)
def test_non_managing_staff_cannot_post(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(title="Blocked practice")
self.assertEqual(response.status_code, 403)
self.assertFalse(Event.objects.filter(title="Blocked practice").exists())
def test_missing_title_reshows_the_form_with_errors(self):
self.client.force_login(self.user)
response = self._post(title="")
self.assertEqual(response.status_code, 200)
self.assertFalse(Event.objects.filter(kind=Event.EventKind.TRAINING).exists())
self.assertTrue(response.context["form"].errors)

View File

@@ -25,4 +25,5 @@ urlpatterns = [
# Coach mode (C1-C6). # Coach mode (C1-C6).
path("coach/", coach_views.CoachTodayView.as_view(), name="coach_today"), path("coach/", coach_views.CoachTodayView.as_view(), name="coach_today"),
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/events/new/", coach_views.CoachCreateEventView.as_view(), name="coach_create_event"),
] ]

View File

@@ -3453,6 +3453,9 @@
.-mt-4 { .-mt-4 {
margin-top: calc(var(--spacing) * -4); margin-top: calc(var(--spacing) * -4);
} }
.-mt-5 {
margin-top: calc(var(--spacing) * -5);
}
.mt-0 { .mt-0 {
margin-top: 0; margin-top: 0;
} }
@@ -3917,6 +3920,9 @@
.h-11 { .h-11 {
height: calc(var(--spacing) * 11); height: calc(var(--spacing) * 11);
} }
.h-12 {
height: calc(var(--spacing) * 12);
}
.h-14 { .h-14 {
height: calc(var(--spacing) * 14); height: calc(var(--spacing) * 14);
} }
@@ -5528,6 +5534,11 @@
outline-style: none; outline-style: none;
} }
} }
.has-checked\:text-white {
&:has(*:checked) {
color: var(--color-white);
}
}
.sm\:col-span-2 { .sm\:col-span-2 {
@media (width >= 40rem) { @media (width >= 40rem) {
grid-column: span 2 / span 2; grid-column: span 2 / span 2;

File diff suppressed because one or more lines are too long