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:
@@ -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
|
||||
|
||||
38
events/migrations/0020_event_max_referees_eventreferee.py
Normal file
38
events/migrations/0020_event_max_referees_eventreferee.py
Normal 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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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
147
events/services/referees.py
Normal 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
|
||||
276
events/tests.py
276
events/tests.py
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user