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

@@ -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."""