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

@@ -73,6 +73,14 @@
--color-ice: #14b8e8;
--color-ice-ink: #04212c;
/* Referee sign-up accent -- deliberately distinct from game/training/other's
club-red/info-blue/warn-amber (mobile/_calendar_row.html) so a "needs a
referee" row stands out from the member's own RSVP rows at a glance. */
--color-referee: #7c4dff;
--color-referee-bg: #f1ecff;
--color-referee-border: #d9caff;
--color-referee-text: #5b2fd6;
--color-row-sel: #fff7f8;
--color-row-focus: #f4f9ff;
--color-row-warn: #fffdf6;
@@ -393,6 +401,12 @@
border: 1px solid #dde1e7;
}
.pill-referee {
background: var(--color-referee-bg);
color: var(--color-referee-text);
border: 1px solid var(--color-referee-border);
}
/* --- Person switcher chips (Home's own -- see mobile/templates/mobile/home.html) --- */
.person-chip {

View File

@@ -2,7 +2,7 @@ from django import forms
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from .models import Attendance, Competition, Event, EventReferee, EventSeries, Lineup, LineupSelection, Location, Opponent
from .models import Attendance, Competition, Event, EventReferee, EventSeries, Lineup, LineupSelection, Location, Opponent, RefereeSignup
@admin.register(Opponent)
@@ -138,3 +138,11 @@ class EventRefereeAdmin(admin.ModelAdmin):
@admin.display(description=_("total payable"))
def total_payable(self, obj):
return obj.total_payable
@admin.register(RefereeSignup)
class RefereeSignupAdmin(admin.ModelAdmin):
list_display = ["event", "member", "status", "responded_at"]
list_filter = ["status"]
search_fields = ["event__title", "member__first_name", "member__last_name"]
raw_id_fields = ["event", "member"]

View File

@@ -0,0 +1,34 @@
# Generated by Django 6.0.6 on 2026-08-22 14:32
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0025_remove_lineupunit_lineup_lineupselection_and_more'),
('members', '0006_parentclaim_submitted_by_user'),
]
operations = [
migrations.CreateModel(
name='RefereeSignup',
fields=[
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('status', models.CharField(choices=[('invited', 'invited'), ('accepted', 'accepted'), ('declined', 'declined')], default='invited', max_length=20, verbose_name='status')),
('responded_at', models.DateTimeField(blank=True, null=True, verbose_name='responded at')),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='referee_signups', to='events.event', verbose_name='event')),
('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='referee_signups', to='members.member', verbose_name='member')),
],
options={
'verbose_name': 'referee sign-up',
'verbose_name_plural': 'referee sign-ups',
'ordering': ['event', 'member__last_name', 'member__first_name'],
'constraints': [models.UniqueConstraint(fields=('event', 'member'), name='unique_referee_signup_per_event_per_member')],
},
),
]

View File

@@ -304,6 +304,42 @@ class EventReferee(UUIDModel):
return (self.fee or Decimal("0")) + self.km_total
class RefereeSignup(UUIDModel):
"""One eligible referee's invite/response for one home game -- the
self-service counterpart to admin assignment (EventReferee). Created
automatically (events.services.referees.sync_referee_invites, wired from
events/signals.py) the moment a home game needs a club-arranged referee,
for every currently-eligible member (teams.RefereeProfile). Accepting
(events.services.referees.accept_referee_signup) creates a real
EventReferee row via the same capacity-checked assign_referee every admin
assignment goes through (assigned_by=None marks it self-service) -- this
table only ever tracks the invite/response, never payment/capacity, so
the desktop referee-management screen stays the single source of truth
for who's actually refereeing.
"""
class Status(models.TextChoices):
INVITED = "invited", _("invited")
ACCEPTED = "accepted", _("accepted")
DECLINED = "declined", _("declined")
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="referee_signups", verbose_name=_("event"))
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="referee_signups", verbose_name=_("member"))
status = models.CharField(_("status"), max_length=20, choices=Status.choices, default=Status.INVITED)
responded_at = models.DateTimeField(_("responded at"), null=True, blank=True)
class Meta:
verbose_name = _("referee sign-up")
verbose_name_plural = _("referee sign-ups")
ordering = ["event", "member__last_name", "member__first_name"]
constraints = [
models.UniqueConstraint(fields=["event", "member"], name="unique_referee_signup_per_event_per_member"),
]
def __str__(self):
return f"{self.event} - {self.member} ({self.status})"
class Competition(models.Model):
"""A competition has a name with a specific URL to fetch data from. These are managed centrally."""

View File

@@ -27,9 +27,10 @@ from django.db import transaction
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from events.models import ASSUMED_EVENT_DURATION, Event, EventReferee
from events.models import ASSUMED_EVENT_DURATION, Event, EventReferee, RefereeSignup
from events.services.attendance import effective_members
from members.models import Member
from notifications.services import notify_members
from teams.models import RefereeLevel, Team
@@ -147,3 +148,56 @@ def set_referee_fee(referee, *, fee=None, km=None, km_rate=None):
referee.km_rate = km_rate
referee.save(update_fields=["fee", "km", "km_rate"])
return referee
def sync_referee_invites(event):
"""Invites every currently-eligible, not-yet-invited referee to `event` --
the referee-facing counterpart to events.sync_event_attendances, wired
from the same signal points (events/signals.py's post_save(Event) and
the Event.teams m2m). Idempotent: only creates rows for members with no
RefereeSignup at all yet for this event, so an unrelated edit (or a
second team added later) never re-notifies someone who already
responded -- or re-invites someone who already said no."""
if not needs_referee_management(event):
return []
already_invited_ids = set(RefereeSignup.objects.filter(event=event).values_list("member_id", flat=True))
new_members = [member for member in eligible_referees(event) if member.pk not in already_invited_ids]
if not new_members:
return []
RefereeSignup.objects.bulk_create([RefereeSignup(event=event, member=member) for member in new_members])
body = _("%(event)s needs a referee -- can you take it?") % {"event": event.title}
notify_members(new_members, club=event.club, title=_("Referee needed"), body=body, source=event)
return new_members
@transaction.atomic
def accept_referee_signup(signup):
"""Confirms `signup`'s member as an actual referee for the game --
routed through the exact same assign_referee every admin assignment
uses (assigned_by=None marks it self-service), so capacity is enforced
once, in one place, and the desktop referee-management screen sees the
new assignment with no separate sync step. Raises RefereeAssignmentError
unchanged if the game's already full -- `signup` is left INVITED so the
referee can retry if a slot frees up."""
assign_referee(signup.event, signup.member, assigned_by=None)
signup.status = RefereeSignup.Status.ACCEPTED
signup.responded_at = timezone.now()
signup.save(update_fields=["status", "responded_at"])
return signup
@transaction.atomic
def decline_referee_signup(signup):
"""Declines `signup`. If they'd already accepted, also removes their
self-service EventReferee row (assigned_by is None -- never touches an
admin-made assignment for the same member/event, though assign_referee's
own "already assigned" guard makes that pairing impossible anyway) so
the desktop screen stops counting someone who's since said they can't
make it after all."""
EventReferee.objects.filter(event=signup.event, member=signup.member, assigned_by__isnull=True).delete()
signup.status = RefereeSignup.Status.DECLINED
signup.responded_at = timezone.now()
signup.save(update_fields=["status", "responded_at"])
return signup

View File

@@ -21,6 +21,7 @@ from django.utils.translation import gettext_lazy as _
from club.models import ClubMembership
from events.models import Event, EventSeries
from events.services import sync_event_attendances
from events.services.referees import sync_referee_invites
from members.models import Group, GroupMembership
from teams.models import Team, TeamMembership
@@ -50,6 +51,7 @@ def validate_groups_same_club(sender, instance, action, reverse, pk_set, **kwarg
@receiver(post_save, sender=Event)
def sync_on_event_save(sender, instance, **kwargs):
sync_event_attendances(instance)
sync_referee_invites(instance)
@receiver(m2m_changed, sender=Event.teams.through)
@@ -68,6 +70,18 @@ def sync_on_audience_change(sender, instance, action, reverse, model, pk_set, **
sync_event_attendances(event)
@receiver(m2m_changed, sender=Event.teams.through)
def sync_referee_invites_on_teams_change(sender, instance, action, reverse, **kwargs):
# Referee eligibility only depends on `teams` (needs_referee_management),
# not groups/invited/excluded_members -- a separate, narrower receiver
# rather than folding into sync_on_audience_change above, which is about
# attendance specifically. reverse (team.scheduled_events.add(...)) isn't
# a used path -- see validate_teams_same_club's own comment.
if action not in M2M_SYNC_ACTIONS or reverse or not isinstance(instance, Event):
return
sync_referee_invites(instance)
@receiver(post_save, sender=TeamMembership)
@receiver(post_delete, sender=TeamMembership)
def sync_on_roster_change(sender, instance, **kwargs):

View File

@@ -19,7 +19,7 @@ from notifications.models import Notification
from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership
from .admin import EventAdminForm
from .models import Attendance, Competition, Event, EventReferee, EventSeries, Lineup, LineupSelection, Location, Opponent
from .models import Attendance, Competition, Event, EventReferee, EventSeries, Lineup, LineupSelection, Location, Opponent, RefereeSignup
from .services import (
cancel_occurrence,
detach_occurrence,
@@ -36,7 +36,19 @@ from .services import (
from .services.calendar import add_months, month_bounds, month_grid, season_grid, week_bounds, week_grid
from .services.lineup import notify_dropout, publish_lineup, selected_members_by_position, toggle_selection
from .services.rbihf_import import RBIHFImportError, apply_plan, build_plan, extract_team_id, parse_fixtures, suggested_location, suggested_opponent
from .services.referees import RefereeAssignmentError, add_external_referee, assign_referee, conflicting_events, eligible_referees, needs_referee_management, remove_referee, set_referee_fee
from .services.referees import (
RefereeAssignmentError,
accept_referee_signup,
add_external_referee,
assign_referee,
conflicting_events,
decline_referee_signup,
eligible_referees,
needs_referee_management,
remove_referee,
set_referee_fee,
sync_referee_invites,
)
from .tasks import send_deadline_reminders
@@ -1488,6 +1500,109 @@ class RefereeServiceTests(EventsTestBase):
self.assertEqual(assignment.total_payable, Decimal("25.00"))
class RefereeSignupServiceTests(EventsTestBase):
"""events.services.referees.sync_referee_invites/accept_referee_signup/
decline_referee_signup -- the self-service sign-up flow, layered on top
of the same assign_referee/eligible_referees the admin flow uses."""
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.home_ground = Location.objects.create(club=cls.club, name="Home Ground", address="1 St", city="Town", zip_code="1000", country="BE", is_home=True)
cls.level = RefereeLevel.objects.create(club=cls.club, name="Regional")
cls.level.teams.add(cls.team)
cls.referee = Member.objects.create(first_name="Ref", last_name="Eree")
cls.referee_profile = RefereeProfile.objects.create(member=cls.referee, level=cls.level, valid_until=timezone.localdate() + timedelta(days=30))
def make_home_game(self, **kwargs):
kwargs.setdefault("kind", Event.EventKind.GAME)
kwargs.setdefault("location", self.home_ground)
event = self.make_event(**kwargs)
event.teams.add(self.team)
return event
def test_creating_a_home_game_invites_eligible_referees(self):
# sync_referee_invites is wired from events/signals.py's post_save(Event)
# -- make_home_game's own .save() (via make_event) already triggers it.
game = self.make_home_game()
self.assertTrue(RefereeSignup.objects.filter(event=game, member=self.referee, status=RefereeSignup.Status.INVITED).exists())
def test_creating_a_home_game_notifies_eligible_referees(self):
self.make_home_game()
self.assertTrue(Notification.objects.filter(member=self.referee, title="Referee needed").exists())
def test_sync_does_not_reinvite_someone_who_already_responded(self):
game = self.make_home_game()
signup = RefereeSignup.objects.get(event=game, member=self.referee)
signup.status = RefereeSignup.Status.DECLINED
signup.responded_at = timezone.now()
signup.save()
sync_referee_invites(game)
signup.refresh_from_db()
self.assertEqual(signup.status, RefereeSignup.Status.DECLINED)
def test_sync_is_a_noop_for_an_away_game(self):
away_ground = Location.objects.create(club=self.club, name="Away Ground", address="2 St", city="Town", zip_code="1000", country="BE")
game = self.make_home_game(location=away_ground)
self.assertFalse(RefereeSignup.objects.filter(event=game).exists())
def test_accept_creates_a_real_event_referee_assignment(self):
game = self.make_home_game()
signup = RefereeSignup.objects.get(event=game, member=self.referee)
accept_referee_signup(signup)
assignment = EventReferee.objects.get(event=game, member=self.referee)
self.assertIsNone(assignment.assigned_by)
signup.refresh_from_db()
self.assertEqual(signup.status, RefereeSignup.Status.ACCEPTED)
self.assertIsNotNone(signup.responded_at)
def test_accept_raises_when_the_game_is_already_full(self):
game = self.make_home_game(max_referees=1)
add_external_referee(game, "Guest Referee", assigned_by=None)
signup = RefereeSignup.objects.get(event=game, member=self.referee)
with self.assertRaises(RefereeAssignmentError):
accept_referee_signup(signup)
signup.refresh_from_db()
self.assertEqual(signup.status, RefereeSignup.Status.INVITED)
def test_decline_marks_the_signup_declined(self):
game = self.make_home_game()
signup = RefereeSignup.objects.get(event=game, member=self.referee)
decline_referee_signup(signup)
signup.refresh_from_db()
self.assertEqual(signup.status, RefereeSignup.Status.DECLINED)
self.assertIsNotNone(signup.responded_at)
def test_decline_after_accepting_removes_the_self_service_assignment(self):
game = self.make_home_game()
signup = RefereeSignup.objects.get(event=game, member=self.referee)
accept_referee_signup(signup)
decline_referee_signup(signup)
self.assertFalse(EventReferee.objects.filter(event=game, member=self.referee).exists())
def test_decline_never_removes_an_admin_made_assignment_for_the_same_member(self):
game = self.make_home_game()
signup = RefereeSignup.objects.get(event=game, member=self.referee)
EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.alice)
decline_referee_signup(signup)
self.assertTrue(EventReferee.objects.filter(event=game, member=self.referee, assigned_by=self.alice).exists())
class CalendarGridTests(EventsTestBase):
"""events.services.calendar -- date-range math and grid layout behind the
Events page's Week/Month/Season views."""

View File

@@ -3,8 +3,16 @@
Shared by the event detail page and the referee management dashboard --
context: event, referees (EventReferee rows -- fee/km/km_rate read straight
off the model instance, no separate form object needed), referee_candidates,
referees_full, can_manage_referees, and an optional next_url to return to
after a POST (defaults to the event detail page when blank).
referees_full, pending_signups (RefereeSignup rows still INVITED -- the
self-service counterpart, events.services.referees.sync_referee_invites/
accept_referee_signup), can_manage_referees, and an optional next_url to
return to after a POST (defaults to the event detail page when blank).
A referee row with no assigned_by (events.services.referees.
accept_referee_signup passes assigned_by=None) is one who signed
themselves up rather than one an admin picked -- flagged "Self sign-up"
instead of "Assigned by ..." so that stays visible here without a
separate screen.
The fee is entered right here, inline, rather than behind a "Fee" button
that opened a second (and on the dashboard, nested-inside-a-dialog) modal
@@ -33,7 +41,11 @@
{% if referee.is_external %}<span class="badge badge-neutral badge-xs">{% trans "External" %}</span>{% endif %}
{% if can_manage_referees and not referee.fee %}<span class="badge badge-warning badge-xs">{% trans "Fee not set" %}</span>{% endif %}
</div>
{% if referee.assigned_by %}<span class="text-xs text-muted">{% blocktrans with name=referee.assigned_by %}Assigned by {{ name }}{% endblocktrans %}</span>{% endif %}
{% if referee.assigned_by %}
<span class="text-xs text-muted">{% blocktrans with name=referee.assigned_by %}Assigned by {{ name }}{% endblocktrans %}</span>
{% elif not referee.is_external %}
<span class="badge badge-outline badge-xs w-fit">{% trans "Self sign-up" %}</span>
{% endif %}
</div>
{% if can_manage_referees %}
@@ -76,6 +88,13 @@
{% endfor %}
</ul>
{% if pending_signups %}
<p class="mt-2 text-xs text-muted">
{% blocktrans count counter=pending_signups|length %}Invited, no response yet: {% plural %}Invited, no response yet: {% endblocktrans %}
{% for signup in pending_signups %}{{ signup.member }}{% if not forloop.last %}, {% endif %}{% endfor %}
</p>
{% endif %}
{% if can_manage_referees and not referees_full %}
<div class="mt-3 flex flex-col gap-2 border-t border-rule pt-3">
<span class="font-mono text-[11px] tracking-[.08em] text-dim uppercase">{% trans "Add a referee" %}</span>

View File

@@ -90,7 +90,7 @@
{% if game.opponent %}{% trans "vs" %} {{ game.opponent }}{% endif %}
</h3>
<div class="mb-4 font-mono text-sm text-muted">{{ game.start|date:"j M Y H:i" }}</div>
{% include "management/_referee_assignment_panel.html" with event=game referees=game.referee_rows referee_candidates=game.referee_candidates referees_full=game.referees_full can_manage_referees=True next_url=request.get_full_path %}
{% include "management/_referee_assignment_panel.html" with event=game referees=game.referee_rows referee_candidates=game.referee_candidates referees_full=game.referees_full pending_signups=game.pending_signups can_manage_referees=True next_url=request.get_full_path %}
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>

View File

@@ -23,7 +23,7 @@ from billing.services.dues import record_payment, subscribe
from club.models import Club, ClubMembership, ClubRole, DuesInvoice, FeePayment, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
from club.services.invoicing import DuesInvoicePDFError
from club.services.onboarding import mark_complete
from events.models import Attendance, Competition, Event, EventReferee, EventSeries, Location, Opponent
from events.models import Attendance, Competition, Event, EventReferee, EventSeries, Location, Opponent, RefereeSignup
from events.services.rbihf_import import RBIHFImportError
from events.services.recurrence import detach_occurrence, generate_occurrences
from management.bulk_import import TEMPLATE_COLUMNS
@@ -6554,6 +6554,39 @@ class RefereeManagementDashboardTests(ManagementTestBase):
self.assertRedirects(response, reverse("management:event_detail", args=[game.pk]))
def test_shows_a_pending_self_service_invite(self):
# make_game()'s own event.teams.add(...) already triggers
# events.services.referees.sync_referee_invites via events/signals.py --
# self.referee (eligible for self.team) gets invited automatically.
game = self.make_game()
self.client.force_login(self.admin_user)
response = self.club_get("referee_management")
self.assertTrue(RefereeSignup.objects.filter(event=game, member=self.referee, status=RefereeSignup.Status.INVITED).exists())
self.assertContains(response, "Invited, no response yet")
self.assertContains(response, str(self.referee))
def test_no_pending_invite_shown_once_the_game_is_full(self):
game = self.make_game(max_referees=1)
EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member)
other_referee = Member.objects.create(first_name="Other", last_name="Ref")
RefereeSignup.objects.create(event=game, member=other_referee, status=RefereeSignup.Status.INVITED)
self.client.force_login(self.admin_user)
response = self.club_get("referee_management")
self.assertNotContains(response, "Invited, no response yet")
def test_self_signed_up_referee_is_flagged_distinctly_from_an_admin_assignment(self):
game = self.make_game()
EventReferee.objects.create(event=game, member=self.referee, assigned_by=None)
self.client.force_login(self.admin_user)
response = self.club_get("referee_management")
self.assertContains(response, "Self sign-up")
class FeatureGatedSectionsTests(ManagementTestBase):
"""The Shop and Forms sections are still stubs (StubListMixin) and, on top

View File

@@ -35,7 +35,7 @@ from club.services.onboarding import annotate_onboarding_status, approve_all_cle
from controlpanel.messages import notify
from controlpanel.mixins import RedirectOnInvalidMixin
from controlpanel.services.statistics import club_attention, club_charts, club_statistics, unrostered_members
from events.models import Attendance, Event, EventReferee, EventSeries, Location, Opponent
from events.models import Attendance, Event, EventReferee, EventSeries, Location, Opponent, RefereeSignup
from events.services.attendance import member_attendance_counts, member_attendance_sparkline, player_attendance_rankings, players_who_missed_recent_practices, team_attendance_rate, team_no_shows
from events.services.calendar import add_months, month_bounds, month_grid, season_grid, week_bounds, week_grid
from events.services.competitions import CompetitionFetchError, fetch_game_info
@@ -2501,6 +2501,7 @@ class EventDetailView(ClubStaffRequiredMixin, DetailView):
referees = []
referee_candidates = []
referees_full = False
pending_signups = []
if referee_management_needed:
referees = list(event.referees.select_related("member", "assigned_by").order_by("member__last_name", "member__first_name"))
referees_full = len(referees) >= event.max_referees
@@ -2510,6 +2511,8 @@ class EventDetailView(ClubStaffRequiredMixin, DetailView):
candidate.has_conflict = bool(conflicts)
candidate.conflict_titles = ", ".join(conflict.title for conflict in conflicts)
referee_candidates.append(candidate)
if can_manage_referees:
pending_signups = list(event.referee_signups.filter(status=RefereeSignup.Status.INVITED).select_related("member"))
return super().get_context_data(
can_manage=can_manage,
@@ -2521,6 +2524,7 @@ class EventDetailView(ClubStaffRequiredMixin, DetailView):
referees=referees,
referee_candidates=referee_candidates,
referees_full=referees_full,
pending_signups=pending_signups,
**kwargs,
)
@@ -2745,6 +2749,7 @@ class RefereeManagementDashboardView(MemberAdminRequiredMixin, TemplateView):
game.referees_full = len(game.referee_rows) >= game.max_referees
game.referee_candidates = []
game.fees_pending = any(not referee.fee for referee in game.referee_rows)
game.pending_signups = list(game.referee_signups.filter(status=RefereeSignup.Status.INVITED).select_related("member")) if not game.referees_full else []
if not game.referee_rows:
kpi_no_referee += 1
elif not game.referees_full:

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 --

File diff suppressed because one or more lines are too long