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.
"""
from django import forms
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import Http404, HttpResponseForbidden, HttpResponseRedirect
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 events.models import Attendance, Event
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 .coach_mixins import CoachScopeMixin
from .forms import _INPUT_CLASSES
#: 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.
@@ -174,3 +178,76 @@ class CoachAttendanceView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
body = _("%(count)d players checked in.") % {"count": checked_in}
notify(request, f"s|{title}|{body}")
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>
</div>
{% 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="m-card p-3 text-center">
<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")
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).
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/events/new/", coach_views.CoachCreateEventView.as_view(), name="coach_create_event"),
]

View File

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

File diff suppressed because one or more lines are too long