Add referee assignment/eligibility system, team bulk-add, member Groups, and referee management dashboard with PDF export

Builds the referee workflow end to end: club-defined RefereeLevel/RefereeProfile
eligibility tied to teams, EventReferee assignment (member or external, with
fee/km payment tracking), an admin dashboard with KPI tiles, date-grouped game
tiles and range filters, and a downloadable payment form PDF modeled on the
club's existing paper document (using Club.legal_name when set). Also lands
team roster bulk-add, member mass-upload with family linking, and the
members.Group model, developed alongside this work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 21:39:19 +02:00
parent 86e28c317f
commit 309bd4d83e
54 changed files with 4574 additions and 88 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, EventSeries, Location, Opponent
from .models import Attendance, Competition, Event, EventReferee, EventSeries, Location, Opponent
@admin.register(Opponent)
@@ -25,6 +25,12 @@ class AttendanceInline(admin.TabularInline):
raw_id_fields = ["member"]
class EventRefereeInline(admin.TabularInline):
model = EventReferee
extra = 0
raw_id_fields = ["member", "assigned_by"]
@admin.register(EventSeries)
class EventSeriesAdmin(admin.ModelAdmin):
list_display = ["title", "kind", "rrule", "dtstart", "until", "generated_until", "club"]
@@ -52,7 +58,7 @@ class EventAdminForm(forms.ModelForm):
fields = [
"title", "kind", "season", "series", "detached", "cancelled", "teams", "invited_members", "excluded_members",
"start", "end", "gathering", "deadline", "location", "opponent",
"competition", "external_game_id", "score_for", "score_against", "is_live",
"competition", "external_game_id", "score_for", "score_against", "is_live", "max_referees",
]
def __init__(self, *args, **kwargs):
@@ -77,14 +83,14 @@ class EventAdmin(admin.ModelAdmin):
search_fields = ["title"]
date_hierarchy = "start"
autocomplete_fields = ["season", "series", "location", "opponent", "teams", "invited_members", "excluded_members"]
inlines = [AttendanceInline]
inlines = [AttendanceInline, EventRefereeInline]
fieldsets = [
[None, {"fields": ["title", "kind", "season"]}],
[_("Series"), {"fields": ["series", "detached", "cancelled"]}],
[_("Audience"), {"fields": ["teams", "invited_members", "excluded_members"]}],
[_("When"), {"fields": ["start", "end", "gathering", "deadline"]}],
[_("Where"), {"fields": ["location", "opponent"]}],
[_("Game"), {"fields": ["competition", "external_game_id", "score_for", "score_against", "is_live"]}],
[_("Game"), {"fields": ["competition", "external_game_id", "score_for", "score_against", "is_live", "max_referees"]}],
]
@@ -102,3 +108,18 @@ class AttendanceAdmin(admin.ModelAdmin):
list_filter = ["status", "showed_up", "event__kind"]
search_fields = ["event__title", "member__first_name", "member__last_name"]
raw_id_fields = ["event", "member"]
@admin.register(EventReferee)
class EventRefereeAdmin(admin.ModelAdmin):
list_display = ["event", "display_name", "fee", "km", "total_payable", "assigned_by"]
search_fields = ["event__title", "member__first_name", "member__last_name", "external_name"]
raw_id_fields = ["event", "member", "assigned_by"]
@admin.display(description=_("referee"))
def display_name(self, obj):
return obj.display_name
@admin.display(description=_("total payable"))
def total_payable(self, obj):
return obj.total_payable

View File

@@ -0,0 +1,38 @@
# Generated by Django 6.0.6 on 2026-08-08 21:55
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0019_competition_sport_type'),
('members', '0004_group_groupmembership_and_more'),
]
operations = [
migrations.AddField(
model_name='event',
name='max_referees',
field=models.PositiveSmallIntegerField(default=2, help_text='How many referees can be assigned to this game. Only meaningful for home games -- ignored otherwise.', verbose_name='max referees'),
),
migrations.CreateModel(
name='EventReferee',
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)),
('assigned_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='members.member', verbose_name='assigned by')),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='referees', to='events.event', verbose_name='event')),
('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='referee_assignments', to='members.member', verbose_name='member')),
],
options={
'verbose_name': 'event referee',
'verbose_name_plural': 'event referees',
'ordering': ['event', 'member__last_name', 'member__first_name'],
'constraints': [models.UniqueConstraint(fields=('event', 'member'), name='unique_referee_per_event')],
},
),
]

View File

@@ -0,0 +1,45 @@
# Generated by Django 6.0.6 on 2026-08-09 18:21
import django.db.models.deletion
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0020_event_max_referees_eventreferee'),
('members', '0004_group_groupmembership_and_more'),
]
operations = [
migrations.AddField(
model_name='eventreferee',
name='external_name',
field=models.CharField(blank=True, help_text="For a referee who isn't a club member (e.g. federation-appointed) -- logged by name only.", max_length=255, verbose_name='external referee name'),
),
migrations.AddField(
model_name='eventreferee',
name='fee',
field=models.DecimalField(blank=True, decimal_places=2, default=Decimal('0.00'), max_digits=8, verbose_name='fee'),
),
migrations.AddField(
model_name='eventreferee',
name='km',
field=models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True, verbose_name='kilometers'),
),
migrations.AddField(
model_name='eventreferee',
name='km_rate',
field=models.DecimalField(blank=True, decimal_places=4, help_text='Reimbursement rate per kilometer, e.g. 0.4230.', max_digits=6, null=True, verbose_name='rate per km'),
),
migrations.AlterField(
model_name='eventreferee',
name='member',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='referee_assignments', to='members.member', verbose_name='member'),
),
migrations.AddConstraint(
model_name='eventreferee',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('member__isnull', False), ('external_name', '')), models.Q(('member__isnull', True), models.Q(('external_name', ''), _negated=True)), _connector='OR'), name='event_referee_member_xor_external_name'),
),
]

View File

@@ -1,3 +1,5 @@
from decimal import Decimal
from django.conf import settings
from django.db import models
from django.db.models import Q
@@ -86,6 +88,8 @@ class Event(ClubScopedModel):
score_against = models.PositiveSmallIntegerField(_("score (opponent)"), null=True, blank=True)
is_live = models.BooleanField(_("live"), default=False, help_text=_("The game is currently in progress."))
max_referees = models.PositiveSmallIntegerField(_("max referees"), default=2, help_text=_("How many referees can be assigned to this game. Only meaningful for home games -- ignored otherwise."))
class Meta:
verbose_name = _("event")
verbose_name_plural = _("events")
@@ -172,6 +176,65 @@ class Attendance(UUIDModel):
return f"{self.event} - {self.member}"
class EventReferee(UUIDModel):
"""One referee assigned to one (home) game -- either a club member
(``member`` set) or an external referee logged by name only
(``external_name`` set, e.g. a federation-appointed referee the club
still needs to pay/log) -- never both, never neither. ``assigned_by`` is
required for now -- assignment is admin-only; a future self-service
sign-up would make it nullable to mean "the referee signed themself up"
rather than adding a parallel model. See events.services.referees for the
home-game gate, the ``Event.max_referees`` capacity ceiling, and the
(non-blocking) schedule conflict check.
``fee``/``km``/``km_rate`` back the referee payment form (PDF export):
what the club owes this referee for this game, and the mileage rate used
to compute the travel portion -- snapshotted per assignment (not read
from a live club-wide setting) so a rate change later doesn't rewrite an
already-issued form's numbers.
"""
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="referees", verbose_name=_("event"))
member = models.ForeignKey(Member, on_delete=models.CASCADE, null=True, blank=True, related_name="referee_assignments", verbose_name=_("member"))
external_name = models.CharField(_("external referee name"), max_length=255, blank=True, help_text=_("For a referee who isn't a club member (e.g. federation-appointed) -- logged by name only."))
assigned_by = models.ForeignKey(Member, on_delete=models.SET_NULL, null=True, related_name="+", verbose_name=_("assigned by"))
fee = models.DecimalField(_("fee"), max_digits=8, decimal_places=2, default=Decimal("0.00"), blank=True)
km = models.DecimalField(_("kilometers"), max_digits=6, decimal_places=1, null=True, blank=True)
km_rate = models.DecimalField(_("rate per km"), max_digits=6, decimal_places=4, null=True, blank=True, help_text=_("Reimbursement rate per kilometer, e.g. 0.4230."))
class Meta:
verbose_name = _("event referee")
verbose_name_plural = _("event referees")
ordering = ["event", "member__last_name", "member__first_name"]
constraints = [
models.UniqueConstraint(fields=["event", "member"], name="unique_referee_per_event"),
models.CheckConstraint(
condition=(Q(member__isnull=False) & Q(external_name="")) | (Q(member__isnull=True) & ~Q(external_name="")),
name="event_referee_member_xor_external_name",
),
]
def __str__(self):
return f"{self.event} - {self.display_name}"
@property
def display_name(self) -> str:
return str(self.member) if self.member_id else self.external_name
@property
def is_external(self) -> bool:
return self.member_id is None
@property
def km_total(self) -> Decimal:
return (self.km or Decimal("0")) * (self.km_rate or Decimal("0"))
@property
def total_payable(self) -> Decimal:
return (self.fee or Decimal("0")) + self.km_total
class Competition(models.Model):
"""A competition has a name with a specific URL to fetch data from. These are managed centrally."""

147
events/services/referees.py Normal file
View File

@@ -0,0 +1,147 @@
"""Assigning referees to home games.
Eligibility comes from teams.RefereeProfile -- a per-member level + validity
(unrelated to members.Group), with the *level* (teams.RefereeLevel) owning
which teams it qualifies for. A profile with no level, or one whose validity
has lapsed, is not eligible for anything -- see RefereeProfile.eligible_teams,
the single definition this module reads through. Availability conflicts (the
member is expected elsewhere at an overlapping time) are surfaced as a
warning only -- never a hard block, since a human may know the two
commitments don't actually clash (enough travel time, one is optional).
Capacity (Event.max_referees) *is* a hard ceiling, enforced here for both
staff-assignment and any future self-service sign-up.
A team can opt out of all of this entirely (Team.referee_management =
FEDERATION): its home games never need club-arranged referees, so they're
excluded from eligibility, assignment, and every referee-facing screen --
see needs_referee_management().
Not every referee is a club member -- add_external_referee logs one by name
only (still counts against Event.max_referees, still capacity-checked), for
e.g. a federation-appointed referee the club still needs to pay.
"""
import datetime
from decimal import Decimal
from django.db import transaction
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from events.models import Event, EventReferee
from events.services.attendance import effective_members
from members.models import Member
from teams.models import Team
# Used only to give an event with no explicit `end` a time window for the
# overlap check below -- never written back to the event itself.
ASSUMED_EVENT_DURATION = datetime.timedelta(hours=2)
class RefereeAssignmentError(Exception):
"""A referee could not be assigned to a game."""
def event_window(event):
"""(start, end) for overlap purposes -- events.end may be blank."""
return event.start, event.end or (event.start + ASSUMED_EVENT_DURATION)
def needs_referee_management(event) -> bool:
"""Whether this event is one the club should be arranging referees for at
all: a home game with at least one club-managed team. A federation-managed
team's home games are entirely out of scope for the referee tools -- the
federation appoints referees for those, not the club."""
return event.is_home_game and event.teams.filter(referee_management=Team.RefereeManagement.CLUB).exists()
def eligible_referees(event):
"""Members who could referee `event`: their RefereeProfile has a level
qualifying for one of its club-managed teams, and is currently valid,
minus whoever is already assigned. Empty unless needs_referee_management."""
if not needs_referee_management(event):
return Member.objects.none()
team_ids = list(event.teams.filter(referee_management=Team.RefereeManagement.CLUB).values_list("id", flat=True))
assigned_ids = event.referees.values_list("member_id", flat=True)
today = timezone.localdate()
return (
Member.objects.filter(referee_profile__level__teams__id__in=team_ids, referee_profile__valid_until__gte=today)
.exclude(pk__in=assigned_ids)
.distinct()
)
def conflicting_events(member, event):
"""Other events in this club overlapping `event`'s time window where
`member` is part of the expected audience -- informational only, never
blocks an assignment."""
start, end = event_window(event)
candidates = Event.objects.filter(club=event.club).exclude(pk=event.pk).filter(start__lt=end)
conflicts = []
for candidate in candidates:
_candidate_start, candidate_end = event_window(candidate)
if candidate_end > start and effective_members(candidate).filter(pk=member.pk).exists():
conflicts.append(candidate)
return conflicts
def _lock_and_check_capacity(event):
"""Row-locks `event` and raises RefereeAssignmentError if it's not a
club-managed home game or is already at Event.max_referees. Shared by
assign_referee/add_external_referee so two admins acting at the same
moment can't both squeeze past the ceiling."""
event = Event.objects.select_for_update().get(pk=event.pk)
if not needs_referee_management(event):
raise RefereeAssignmentError(_("Referees can only be assigned to home games for club-managed teams."))
if event.referees.count() >= event.max_referees:
raise RefereeAssignmentError(_("This game already has its maximum of %(max)s referee(s).") % {"max": event.max_referees})
return event
@transaction.atomic
def assign_referee(event, member, *, assigned_by):
"""Assign `member` to referee `event`. Raises RefereeAssignmentError if
it's not a club-managed home game, the game is already at
Event.max_referees, or `member` is already assigned."""
event = _lock_and_check_capacity(event)
if event.referees.filter(member=member).exists():
raise RefereeAssignmentError(_("%(member)s is already assigned to this game.") % {"member": member})
return EventReferee.objects.create(event=event, member=member, assigned_by=assigned_by)
@transaction.atomic
def add_external_referee(event, name, *, assigned_by):
"""Log a non-member referee (e.g. federation-appointed) by name only.
Same capacity/home-game rules as assign_referee -- an external slot still
counts against Event.max_referees."""
name = name.strip()
if not name:
raise RefereeAssignmentError(_("A name is required for an external referee."))
event = _lock_and_check_capacity(event)
return EventReferee.objects.create(event=event, external_name=name, assigned_by=assigned_by)
def remove_referee(referee):
referee.delete()
def set_referee_fee(referee, *, fee=None, km=None, km_rate=None):
"""Update one referee assignment's payment details -- what the club owes
for this game, split into a flat fee and a mileage component. Any
argument left as None clears that field rather than leaving it
unchanged, matching how the edit form always submits all three."""
referee.fee = fee if fee is not None else Decimal("0.00")
referee.km = km
referee.km_rate = km_rate
referee.save(update_fields=["fee", "km", "km_rate"])
return referee

View File

@@ -1,4 +1,5 @@
from datetime import timedelta
from decimal import Decimal
from io import StringIO
from django.core.exceptions import ValidationError
@@ -10,10 +11,10 @@ from waffle import get_waffle_flag_model
from club.models import Club, Season
from members.models import Member
from teams.models import Position, Team, TeamMembership
from teams.models import Position, RefereeLevel, RefereeProfile, Team, TeamMembership
from .admin import EventAdminForm
from .models import Attendance, Competition, Event, EventSeries, Location, Opponent
from .models import Attendance, Competition, Event, EventReferee, EventSeries, Location, Opponent
from .services import (
cancel_occurrence,
detach_occurrence,
@@ -28,6 +29,7 @@ from .services import (
team_no_shows,
)
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
class EventsTestBase(TestCase):
@@ -893,3 +895,273 @@ class RBIHFImportPlanTests(EventsTestBase):
self.assertEqual(result, {"created": 0, "updated": 0, "deleted": 1})
self.assertFalse(Event.objects.filter(club=self.club, external_game_id="5002").exists())
class EventRefereeModelTests(EventsTestBase):
def test_str(self):
event = self.make_event(title="Cup Final")
referee = Member.objects.create(first_name="Ref", last_name="Eree")
assignment = EventReferee.objects.create(event=event, member=referee, assigned_by=self.alice)
self.assertEqual(str(assignment), "Cup Final - Ref Eree")
def test_member_unique_per_event(self):
event = self.make_event()
referee = Member.objects.create(first_name="Ref", last_name="Eree")
EventReferee.objects.create(event=event, member=referee, assigned_by=self.alice)
with self.assertRaises(IntegrityError):
EventReferee.objects.create(event=event, member=referee, assigned_by=self.alice)
def test_max_referees_defaults_to_two(self):
event = self.make_event()
self.assertEqual(event.max_referees, 2)
def test_deleting_the_assigner_keeps_the_assignment(self):
event = self.make_event()
referee = Member.objects.create(first_name="Ref", last_name="Eree")
assignment = EventReferee.objects.create(event=event, member=referee, assigned_by=self.alice)
self.alice.delete()
assignment.refresh_from_db()
self.assertIsNone(assignment.assigned_by)
self.assertEqual(EventReferee.objects.filter(pk=assignment.pk).count(), 1)
class RefereeServiceTests(EventsTestBase):
"""events.services.referees -- eligibility (level + validity, derived via
RefereeProfile.eligible_teams), conflict detection (a soft warning, never
a block), and the max_referees hard ceiling."""
def setUp(self):
super().setUp()
self.home_ground = Location.objects.create(club=self.club, name="Home Ground", address="1 St", city="Town", zip_code="1000", country="BE", is_home=True)
self.away_ground = Location.objects.create(club=self.club, name="Away Ground", address="2 St", city="Town", zip_code="1000", country="BE")
self.level = RefereeLevel.objects.create(club=self.club, name="Regional")
self.level.teams.add(self.team)
self.referee = Member.objects.create(first_name="Ref", last_name="Eree")
self.referee_profile = self.make_eligible_profile(self.referee)
def make_eligible_profile(self, member, level=None):
return RefereeProfile.objects.create(member=member, level=level or self.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)
kwargs.setdefault("teams", None)
teams = kwargs.pop("teams")
event = self.make_event(**kwargs)
event.teams.add(teams or self.team)
return event
def test_eligible_referees_returns_qualified_members_for_a_home_game(self):
game = self.make_home_game()
self.assertEqual(set(eligible_referees(game)), {self.referee})
def test_eligible_referees_empty_for_an_away_game(self):
game = self.make_home_game(location=self.away_ground)
self.assertEqual(set(eligible_referees(game)), set())
def test_needs_referee_management_true_for_a_club_managed_home_game(self):
game = self.make_home_game()
self.assertTrue(needs_referee_management(game))
def test_needs_referee_management_false_for_an_away_game(self):
game = self.make_home_game(location=self.away_ground)
self.assertFalse(needs_referee_management(game))
def test_needs_referee_management_false_for_a_federation_managed_team(self):
self.team.referee_management = Team.RefereeManagement.FEDERATION
self.team.save(update_fields=["referee_management"])
game = self.make_home_game()
self.assertFalse(needs_referee_management(game))
def test_eligible_referees_empty_for_a_federation_managed_team(self):
self.team.referee_management = Team.RefereeManagement.FEDERATION
self.team.save(update_fields=["referee_management"])
game = self.make_home_game()
self.assertEqual(set(eligible_referees(game)), set())
def test_assign_referee_rejects_a_federation_managed_team(self):
self.team.referee_management = Team.RefereeManagement.FEDERATION
self.team.save(update_fields=["referee_management"])
game = self.make_home_game()
with self.assertRaises(RefereeAssignmentError):
assign_referee(game, self.referee, assigned_by=self.alice)
def test_eligible_referees_empty_for_a_non_game_kind(self):
practice = self.make_event(kind=Event.EventKind.TRAINING, location=self.home_ground)
practice.teams.add(self.team)
self.assertEqual(set(eligible_referees(practice)), set())
def test_eligible_referees_excludes_someone_already_assigned(self):
game = self.make_home_game()
EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.alice)
self.assertEqual(set(eligible_referees(game)), set())
def test_eligible_referees_ignores_someone_not_eligible_for_this_team(self):
other_team = Team.objects.create(club=self.club, name="Second Team", short_name="2nd")
unrelated_referee = Member.objects.create(first_name="Not", last_name="Eligible")
RefereeProfile.objects.create(member=unrelated_referee) # no level set
game = self.make_home_game(teams=other_team)
self.assertEqual(set(eligible_referees(game)), set())
def test_eligible_referees_ignores_an_expired_profile(self):
expired_referee = Member.objects.create(first_name="Ex", last_name="Pired")
RefereeProfile.objects.create(member=expired_referee, level=self.level, valid_until=timezone.localdate() - timedelta(days=1))
game = self.make_home_game()
self.assertNotIn(expired_referee, set(eligible_referees(game)))
def test_conflicting_events_finds_an_overlapping_expected_attendance(self):
# Alice is on self.team's roster (EventsTestBase.setUp), so she's part
# of any event's effective audience that includes self.team.
game = self.make_home_game(start=self.future)
other_training = self.make_event(title="Other training", start=self.future, end=self.future + timedelta(hours=1))
other_training.teams.add(self.team)
conflicts = conflicting_events(self.alice, game)
self.assertIn(other_training, conflicts)
def test_conflicting_events_empty_when_nothing_overlaps(self):
game = self.make_home_game(start=self.future)
later = self.make_event(title="Much later", start=self.future + timedelta(days=5))
later.teams.add(self.team)
self.assertEqual(conflicting_events(self.alice, game), [])
def test_conflicting_events_assumes_a_duration_when_end_is_blank(self):
# Neither event has an explicit `end` -- the 2-hour assumed window
# (events.services.referees.ASSUMED_EVENT_DURATION) is what makes these
# two (30 minutes apart) overlap.
game = self.make_home_game(start=self.future)
nearby = self.make_event(title="Nearby", start=self.future + timedelta(minutes=30))
nearby.teams.add(self.team)
self.assertIn(nearby, conflicting_events(self.alice, game))
def test_conflicting_events_does_not_block_assignment(self):
# Soft warning only -- assign_referee succeeds regardless of conflicts.
game = self.make_home_game(start=self.future)
other_training = self.make_event(title="Other training", start=self.future, end=self.future + timedelta(hours=1))
other_training.teams.add(self.team)
self.assertTrue(conflicting_events(self.alice, game))
self.make_eligible_profile(self.alice)
assign_referee(game, self.alice, assigned_by=self.bob)
self.assertTrue(EventReferee.objects.filter(event=game, member=self.alice).exists())
def test_assign_referee_creates_the_row(self):
game = self.make_home_game()
assignment = assign_referee(game, self.referee, assigned_by=self.alice)
self.assertEqual(assignment.event, game)
self.assertEqual(assignment.member, self.referee)
self.assertEqual(assignment.assigned_by, self.alice)
def test_assign_referee_rejects_an_away_game(self):
game = self.make_home_game(location=self.away_ground)
with self.assertRaises(RefereeAssignmentError):
assign_referee(game, self.referee, assigned_by=self.alice)
def test_assign_referee_rejects_a_non_game(self):
practice = self.make_event(kind=Event.EventKind.TRAINING, location=self.home_ground)
practice.teams.add(self.team)
with self.assertRaises(RefereeAssignmentError):
assign_referee(practice, self.referee, assigned_by=self.alice)
def test_assign_referee_rejects_once_at_capacity(self):
game = self.make_home_game(max_referees=1)
assign_referee(game, self.referee, assigned_by=self.alice)
second_referee = Member.objects.create(first_name="Second", last_name="Ref")
self.make_eligible_profile(second_referee)
with self.assertRaises(RefereeAssignmentError):
assign_referee(game, second_referee, assigned_by=self.alice)
self.assertEqual(game.referees.count(), 1)
def test_assign_referee_rejects_the_same_member_twice(self):
game = self.make_home_game()
assign_referee(game, self.referee, assigned_by=self.alice)
with self.assertRaises(RefereeAssignmentError):
assign_referee(game, self.referee, assigned_by=self.alice)
def test_remove_referee_deletes_the_assignment(self):
game = self.make_home_game()
assignment = assign_referee(game, self.referee, assigned_by=self.alice)
remove_referee(assignment)
self.assertFalse(EventReferee.objects.filter(event=game, member=self.referee).exists())
def test_add_external_referee_creates_a_memberless_row(self):
game = self.make_home_game()
assignment = add_external_referee(game, "Guest Referee", assigned_by=self.alice)
self.assertIsNone(assignment.member)
self.assertEqual(assignment.external_name, "Guest Referee")
self.assertTrue(assignment.is_external)
self.assertEqual(assignment.display_name, "Guest Referee")
def test_add_external_referee_strips_the_name(self):
game = self.make_home_game()
assignment = add_external_referee(game, " Guest Referee ", assigned_by=self.alice)
self.assertEqual(assignment.external_name, "Guest Referee")
def test_add_external_referee_rejects_a_blank_name(self):
game = self.make_home_game()
with self.assertRaises(RefereeAssignmentError):
add_external_referee(game, " ", assigned_by=self.alice)
def test_add_external_referee_counts_against_capacity(self):
game = self.make_home_game(max_referees=1)
add_external_referee(game, "Guest Referee", assigned_by=self.alice)
with self.assertRaises(RefereeAssignmentError):
assign_referee(game, self.referee, assigned_by=self.alice)
def test_add_external_referee_rejects_an_away_game(self):
game = self.make_home_game(location=self.away_ground)
with self.assertRaises(RefereeAssignmentError):
add_external_referee(game, "Guest Referee", assigned_by=self.alice)
def test_set_referee_fee_computes_totals(self):
game = self.make_home_game()
assignment = assign_referee(game, self.referee, assigned_by=self.alice)
set_referee_fee(assignment, fee=Decimal("25.00"), km=Decimal("40"), km_rate=Decimal("0.35"))
assignment.refresh_from_db()
self.assertEqual(assignment.km_total, Decimal("14.00"))
self.assertEqual(assignment.total_payable, Decimal("39.00"))
def test_set_referee_fee_without_km_has_no_km_total(self):
game = self.make_home_game()
assignment = assign_referee(game, self.referee, assigned_by=self.alice)
set_referee_fee(assignment, fee=Decimal("25.00"))
assignment.refresh_from_db()
self.assertEqual(assignment.km_total, Decimal("0"))
self.assertEqual(assignment.total_payable, Decimal("25.00"))