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:
@@ -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"))
|
||||
|
||||
83
mobile/templates/mobile/coach/event_form.html
Normal file
83
mobile/templates/mobile/coach/event_form.html
Normal 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 & 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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user