Let eligible referees self-serve sign-up for games needing one

Creating (or re-teaming) a home game for a club-managed team now auto-
invites every eligible referee (teams.RefereeProfile) via a new
RefereeSignup model, notifying them the same way news/events already do.
They see it as its own distinct row on the mobile Calendar (own accent
colour) and can accept or decline right there -- accepting routes through
the existing capacity-checked assign_referee (assigned_by=None marks it
self-service), so it lands as a real EventReferee row with no separate sync
step. The desktop referee-management screen and event detail page both
surface pending invites and flag self-signed-up referees distinctly from
admin assignments.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
2026-08-22 17:19:48 +02:00
parent 135580d83e
commit 747514f1ff
17 changed files with 596 additions and 18 deletions

View File

@@ -0,0 +1,50 @@
{% load i18n lucide %}
{% comment %}
One referee sign-up row on M3's Calendar -- a home game the signed-in
account is eligible (and, once responded, confirmed) to referee for.
Merged into the same chronological list as the account's own RSVP rows
(mobile/_calendar_row.html), but in the referee accent (assets/mobile.
css's --color-referee/.pill-referee) so it reads as a different kind of
commitment at a glance -- and scoped to self.me only (mobile/views.py's
CalendarView), never managed_people, since a referee is an adult acting
on their own behalf, not something a parent does for a child.
Expects ``row`` ({event, referee_signup}) in scope. Accept/Decline are
two small forms, not a single multi-button one, so hx-boost="false" can
sit on each without a formaction/htmx-boost mismatch (see event_detail.
html's own top-of-file comment for that failure mode).
{% endcomment %}
<div class="flex items-center gap-3 bg-white px-4 py-3">
<div class="w-9.5 shrink-0 text-center">
<div class="font-mono text-[10px] tracking-wide text-muted uppercase">{{ row.event.start|date:"D" }}</div>
<div class="font-display text-2xl leading-none font-extrabold text-ink">{{ row.event.start|date:"d" }}</div>
</div>
<div class="w-[3px] shrink-0 self-stretch rounded-full" style="background: var(--color-referee)"></div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1 text-sm font-semibold text-ink">
{% lucide "flag" size=13 stroke_width=2.4 class="shrink-0 text-referee" %}
<span class="truncate">{% trans "Referee" %} &middot; {{ row.event.title }}</span>
</div>
<div class="truncate text-xs text-muted">
{{ row.event.start|date:"H:i" }}
{% for team in row.event.teams.all %}&middot; {{ team.name }}{% endfor %}
{% if row.event.location %}&middot; {{ row.event.location.name }}{% endif %}
</div>
</div>
{% if row.referee_signup.status == "accepted" %}
<span class="pill pill-referee shrink-0">{% trans "Confirmed" %}</span>
{% else %}
<div class="flex shrink-0 gap-1.5">
<form method="post" action="{% url "mobile:referee_signup_respond" row.referee_signup.pk %}" hx-boost="false">
{% csrf_token %}
<input type="hidden" name="response" value="accept">
<button type="submit" class="pill pill-referee">{% trans "I'll ref" %}</button>
</form>
<form method="post" action="{% url "mobile:referee_signup_respond" row.referee_signup.pk %}" hx-boost="false">
{% csrf_token %}
<input type="hidden" name="response" value="decline">
<button type="submit" class="pill pill-neutral">{% trans "Can't" %}</button>
</form>
</div>
{% endif %}
</div>

View File

@@ -7,7 +7,10 @@
everything further out grouped by calendar month (see CalendarView's own
docstring -- later_months is a list of {month_start, rows}). No person
switcher and no club-wide toggle here -- always every event
self.managed_people is invited to, full stop. A real, working ?kind=
self.managed_people is invited to, plus (merged into the same
chronological list, own accent colour) any home game self.me is
eligible to referee -- see mobile/_calendar_referee_row.html. A real,
working ?kind=
filter (All/Games/Practices) stands in for the design mock's "Games only"
pill; its List/Month toggle isn't reproduced -- that's a second view
mode, not a filter. -mx-4 (on the row groups only, not this filter row)
@@ -60,7 +63,7 @@
<div class="sticky top-0 z-10 bg-paper px-4 py-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "This week" %}</div>
<div class="flex flex-col gap-px bg-line">
{% for row in this_week %}
{% include "mobile/_calendar_row.html" %}
{% if row.referee_signup %}{% include "mobile/_calendar_referee_row.html" %}{% else %}{% include "mobile/_calendar_row.html" %}{% endif %}
{% endfor %}
</div>
</div>
@@ -71,7 +74,7 @@
<div class="sticky top-0 z-10 bg-paper px-4 py-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Next week" %}</div>
<div class="flex flex-col gap-px bg-line">
{% for row in next_week %}
{% include "mobile/_calendar_row.html" %}
{% if row.referee_signup %}{% include "mobile/_calendar_referee_row.html" %}{% else %}{% include "mobile/_calendar_row.html" %}{% endif %}
{% endfor %}
</div>
</div>
@@ -82,7 +85,7 @@
<div class="sticky top-0 z-10 bg-paper px-4 py-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{{ month.month_start|date:"F Y" }}</div>
<div class="flex flex-col gap-px bg-line">
{% for row in month.rows %}
{% include "mobile/_calendar_row.html" %}
{% if row.referee_signup %}{% include "mobile/_calendar_referee_row.html" %}{% else %}{% include "mobile/_calendar_row.html" %}{% endif %}
{% endfor %}
</div>
</div>

View File

@@ -9,11 +9,11 @@ from django.utils import timezone, translation
from icalendar import Calendar as ICalCalendar
from club.models import Club, ClubMembership, DuesInvoice, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
from events.models import Attendance, Event, Lineup, LineupSelection
from events.models import Attendance, Event, EventReferee, Lineup, LineupSelection, Location, RefereeSignup
from members.models import Family, FamilyMembership, Member
from news.models import News
from notifications.models import Notification
from teams.models import Position, StaffAssignment, Team, TeamMembership
from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership
from .models import CalendarFeedToken, PushSubscription
from .services.icons import render_fallback_icon
@@ -1008,6 +1008,145 @@ class CalendarViewTests(TestCase):
self.assertIn(event, self._events_in_context(response))
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class CalendarRefereeSignupTests(TestCase):
"""M3's Calendar merges in the signed-in account's own referee sign-ups
(self.me only, never managed_people) -- see mobile/_calendar_referee_row.
html and CalendarView's own docstring."""
@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="ref@example.com", password="pw-secret-123")
cls.member = Member.objects.create(first_name="Ref", last_name="Eree", email="ref@example.com", user=cls.user)
ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season)
cls.home_ground = Location.objects.create(club=cls.club, name="Home Rink", address="1 St", city="Town", zip_code="1000", country="BE", is_home=True)
cls.team = Team.objects.create(club=cls.club, name="U16", short_name="U16")
cls.level = RefereeLevel.objects.create(club=cls.club, name="Regional")
cls.level.teams.add(cls.team)
RefereeProfile.objects.create(member=cls.member, level=cls.level, valid_until=today + datetime.timedelta(days=30))
cls.game = Event.objects.create(club=cls.club, title="Home game", kind=Event.EventKind.GAME, location=cls.home_ground, start=timezone.now() + datetime.timedelta(days=1))
cls.game.teams.add(cls.team) # triggers sync_referee_invites via events/signals.py
def _get(self, **params):
url = reverse("mobile:calendar")
if params:
url += "?" + "&".join(f"{key}={value}" for key, value in params.items())
return self.client.get(url, HTTP_HOST="ajax-united.rosterchief.app")
def test_invited_game_shows_up_on_the_calendar(self):
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, "Referee")
self.assertContains(response, "I'll ref")
def test_declined_signup_is_not_shown(self):
signup = RefereeSignup.objects.get(event=self.game, member=self.member)
signup.status = RefereeSignup.Status.DECLINED
signup.save()
self.client.force_login(self.user)
response = self._get()
self.assertNotContains(response, "I'll ref")
def test_accepted_signup_shows_a_confirmed_pill_with_no_actions(self):
signup = RefereeSignup.objects.get(event=self.game, member=self.member)
signup.status = RefereeSignup.Status.ACCEPTED
signup.save()
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, "Confirmed")
self.assertNotContains(response, "I'll ref")
def test_training_filter_excludes_referee_rows(self):
self.client.force_login(self.user)
response = self._get(kind="training")
self.assertNotContains(response, "I'll ref")
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class RefereeSignupRespondViewTests(TestCase):
"""mobile:referee_signup_respond -- Accept/Decline from the Calendar."""
@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="ref@example.com", password="pw-secret-123")
cls.member = Member.objects.create(first_name="Ref", last_name="Eree", email="ref@example.com", user=cls.user)
ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season)
cls.home_ground = Location.objects.create(club=cls.club, name="Home Rink", address="1 St", city="Town", zip_code="1000", country="BE", is_home=True)
cls.team = Team.objects.create(club=cls.club, name="U16", short_name="U16")
cls.level = RefereeLevel.objects.create(club=cls.club, name="Regional")
cls.level.teams.add(cls.team)
RefereeProfile.objects.create(member=cls.member, level=cls.level, valid_until=today + datetime.timedelta(days=30))
cls.game = Event.objects.create(club=cls.club, title="Home game", kind=Event.EventKind.GAME, location=cls.home_ground, start=timezone.now() + datetime.timedelta(days=1))
cls.game.teams.add(cls.team)
def _post(self, response_value):
signup = RefereeSignup.objects.get(event=self.game, member=self.member)
return self.client.post(reverse("mobile:referee_signup_respond", kwargs={"signup_id": signup.pk}), {"response": response_value}, HTTP_HOST="ajax-united.rosterchief.app")
def test_requires_login(self):
response = self._post("accept")
self.assertEqual(response.status_code, 302)
def test_accept_creates_a_real_assignment(self):
self.client.force_login(self.user)
response = self._post("accept")
self.assertRedirects(response, reverse("mobile:calendar"), fetch_redirect_response=False)
self.assertTrue(EventReferee.objects.filter(event=self.game, member=self.member).exists())
signup = RefereeSignup.objects.get(event=self.game, member=self.member)
self.assertEqual(signup.status, RefereeSignup.Status.ACCEPTED)
def test_accept_shows_an_error_when_the_game_is_already_full(self):
self.game.max_referees = 0
self.game.save(update_fields=["max_referees"])
self.client.force_login(self.user)
response = self._post("accept")
self.assertRedirects(response, reverse("mobile:calendar"), fetch_redirect_response=False)
self.assertFalse(EventReferee.objects.filter(event=self.game, member=self.member).exists())
signup = RefereeSignup.objects.get(event=self.game, member=self.member)
self.assertEqual(signup.status, RefereeSignup.Status.INVITED)
def test_decline_marks_the_signup_declined(self):
self.client.force_login(self.user)
response = self._post("decline")
self.assertRedirects(response, reverse("mobile:calendar"), fetch_redirect_response=False)
signup = RefereeSignup.objects.get(event=self.game, member=self.member)
self.assertEqual(signup.status, RefereeSignup.Status.DECLINED)
def test_cannot_respond_to_someone_elses_signup(self):
stranger_user = User.objects.create_user(email="stranger@example.com", password="pw-secret-123")
Member.objects.create(first_name="Not", last_name="You", email="stranger@example.com", user=stranger_user)
self.client.force_login(stranger_user)
response = self._post("accept")
self.assertEqual(response.status_code, 404)
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class EventDetailScreenTests(TestCase):
"""M2 -- design_handoff_rosterchief_platform/README.md's M2 section,

View File

@@ -14,6 +14,7 @@ urlpatterns = [
# Member mode (M1-M7).
path("", views.HomeView.as_view(), name="home"),
path("calendar/", views.CalendarView.as_view(), name="calendar"),
path("referee-signups/<uuid:signup_id>/respond/", views.RefereeSignupRespondView.as_view(), name="referee_signup_respond"),
path("events/<uuid:pk>/", views.EventDetailView.as_view(), name="event_detail"),
path("news/", views.NewsListView.as_view(), name="news_list"),
path("news/<slug:slug>/", views.NewsDetailView.as_view(), name="news_detail"),

View File

@@ -27,9 +27,10 @@ from club.services.fees import open_dues_rows
from club.services.onboarding import checklist_for
from club.services.sponsors import active_sponsors
from controlpanel.messages import notify
from events.models import Attendance, Event, Lineup
from events.models import Attendance, Event, Lineup, RefereeSignup
from events.services.calendar import week_bounds
from events.services.lineup import notify_dropout, selected_members_by_position
from events.services.referees import RefereeAssignmentError, accept_referee_signup, decline_referee_signup
from members.models import FamilyMembership, Member
from members.views import ClubScopedPublicMixin
from news.models import News
@@ -348,6 +349,31 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
for attendance in attendances
]
# Referee sign-ups are scoped to self.me only, never managed_people --
# a referee is an adult with their own account, not something a
# parent does on a child's behalf, unlike every RSVP row above.
# Merged into the same chronological list (own dict shape, no
# pill_class/member) rather than a separate section, so it reads on
# the actual day it falls on -- distinct styling is what sets it
# apart (mobile/_calendar_referee_row.html), not a different place
# on the screen. Declined invites are dropped; accepted ones stay
# visible as a confirmed commitment.
if self.me is not None and kind_filter != "training":
signups = (
RefereeSignup.objects.filter(
member=self.me,
event__club=self.request.club,
event__cancelled=False,
event__start__gte=now,
status__in=[RefereeSignup.Status.INVITED, RefereeSignup.Status.ACCEPTED],
)
.select_related("event", "event__location", "event__opponent")
.prefetch_related("event__teams")
)
rows += [{"event": signup.event, "referee_signup": signup} for signup in signups]
rows.sort(key=lambda row: row["event"].start)
this_week, next_week, later_rows = [], [], []
for row in rows:
event_date = timezone.localtime(row["event"].start).date()
@@ -368,6 +394,33 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
return super().get_context_data(this_week=this_week, next_week=next_week, later_months=later_months, kind_filter=kind_filter if kind_filter in self.KIND_FILTERS else "", **kwargs)
class RefereeSignupRespondView(PersonScopeMixin, LoginRequiredMixin, View):
"""Accept/decline a referee invite from a Calendar row
(mobile/_calendar_referee_row.html) -- routes through
events.services.referees.accept_referee_signup/decline_referee_signup,
so capacity is enforced in the one place the desktop admin flow already
enforces it, and the referee-management screen sees the result with no
separate sync step."""
def post(self, request, *args, **kwargs):
signup = get_object_or_404(RefereeSignup, pk=kwargs["signup_id"], member=self.me, event__club=request.club)
response = request.POST.get("response")
if response == "accept":
try:
accept_referee_signup(signup)
notify(request, f"s|{_('Referee sign-up confirmed')}|{_('Thanks for signing up -- see you there.')}")
except RefereeAssignmentError as exc:
notify(request, f"e|{_('Could not sign you up')}|{exc}")
elif response == "decline":
decline_referee_signup(signup)
notify(request, f"s|{_('Declined')}|{_('No problem -- thanks for letting us know.')}")
else:
return HttpResponseBadRequest(_("Unknown response."))
return HttpResponseRedirect(reverse("mobile:calendar"))
class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
"""M2 -- design_handoff_rosterchief_platform/README.md's M2 section:
"answer for several". A hero header (no event photos in this codebase --