Files
RosterChief/events/tests.py
Bernard Siebens e737de9140 Fix the referee PDF's card background, make locations searchable, and add game end times to the API
Referee PDF: the info-card background used CSS color-mix(), which WeasyPrint
doesn't support -- the rule was silently dropped, leaving the card with no
background at all. Computed in Python instead (management/pdf.py) and baked
into the template as a plain hex value; the tint is based on the club's
primary_color, falling back to secondary_color when primary is itself (near)
black or white, where a straight tint would be invisible or too harsh.

Event forms: the location picker now shows "Name — City" (plus the country
when it isn't Belgium) and is searchable by name or city, reusing the
existing single-select searchable-select.js widget.

Games API: GameOut now carries `end` (explicit, or start + 2h when a GAME was
saved without one -- Event.save() sets this, never overwriting an explicit
end; other event kinds are untouched). /games/upcoming/ now includes
anything not yet finished rather than only things that haven't started, so a
game already in progress keeps showing up until its window closes; `status`
was adjusted to match so a game returned there never calls itself
"finished".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 23:44:11 +02:00

1194 lines
53 KiB
Python

from datetime import timedelta
from decimal import Decimal
from io import StringIO
from django.core.exceptions import ValidationError
from django.core.management import call_command
from django.db import IntegrityError
from django.test import TestCase
from django.utils import timezone
from waffle import get_waffle_flag_model
from club.models import Club, Season
from members.models import Member
from teams.models import Position, RefereeLevel, RefereeProfile, Team, TeamMembership
from .admin import EventAdminForm
from .models import Attendance, Competition, Event, EventReferee, EventSeries, Location, Opponent
from .services import (
cancel_occurrence,
detach_occurrence,
effective_members,
generate_occurrences,
occurrence_datetimes,
player_attendance_rankings,
players_who_missed_recent_practices,
propagate_series,
record_check_in,
team_attendance_rate,
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):
def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
today = timezone.localdate()
self.season = Season.objects.create(
club=self.club,
start_date=today - timedelta(days=30),
end_date=today + timedelta(days=300),
)
self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st")
self.position = Position.objects.create(club=self.club, name="Forward", short_name="FW")
self.alice = Member.objects.create(first_name="Alice", last_name="Ash")
self.bob = Member.objects.create(first_name="Bob", last_name="Birch")
TeamMembership.objects.create(team=self.team, member=self.alice, season=self.season, position=self.position)
TeamMembership.objects.create(team=self.team, member=self.bob, season=self.season, position=self.position)
self.future = timezone.now() + timedelta(days=7)
def make_event(self, **kwargs):
kwargs.setdefault("club", self.club)
kwargs.setdefault("title", "Training")
kwargs.setdefault("start", self.future)
if "season" not in kwargs:
kwargs["season"] = self.season
return Event.objects.create(**kwargs)
def attendee_ids(self, event):
return set(event.attendances.values_list("member_id", flat=True))
class EventModelTests(EventsTestBase):
def test_str_methods(self):
opponent = Opponent.objects.create(club=self.club, name="Rivals FC")
location = Location.objects.create(club=self.club, name="Arena", address="1 St", city="Town", zip_code="1000", country="BE")
event = self.make_event(title="Big Match")
attendance = Attendance.objects.create(event=event, member=self.alice)
self.assertEqual(str(opponent), "Rivals FC")
self.assertEqual(str(location), "Arena")
self.assertEqual(str(event), "Big Match")
self.assertEqual(str(attendance), "Big Match - Alice Ash")
def test_attendance_is_unique_per_event_and_member(self):
event = self.make_event()
Attendance.objects.create(event=event, member=self.alice)
with self.assertRaises(IntegrityError):
Attendance.objects.create(event=event, member=self.alice)
def test_is_home_game(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)
away_ground = Location.objects.create(club=self.club, name="Away Ground", address="2 St", city="Town", zip_code="1000", country="BE")
home_game = self.make_event(kind=Event.EventKind.GAME, location=home_ground)
away_game = self.make_event(kind=Event.EventKind.GAME, location=away_ground)
game_with_no_location = self.make_event(kind=Event.EventKind.GAME)
training_at_home_ground = self.make_event(kind=Event.EventKind.TRAINING, location=home_ground)
self.assertTrue(home_game.is_home_game)
self.assertFalse(away_game.is_home_game)
self.assertFalse(game_with_no_location.is_home_game)
self.assertFalse(training_at_home_ground.is_home_game)
def test_a_game_with_no_end_gets_a_two_hour_default_on_save(self):
game = self.make_event(kind=Event.EventKind.GAME, start=self.future)
self.assertEqual(game.end, self.future + timedelta(hours=2))
def test_an_explicit_end_is_not_overridden(self):
explicit_end = self.future + timedelta(hours=5)
game = self.make_event(kind=Event.EventKind.GAME, start=self.future, end=explicit_end)
self.assertEqual(game.end, explicit_end)
def test_a_non_game_event_is_left_with_no_end(self):
training = self.make_event(kind=Event.EventKind.TRAINING, start=self.future)
self.assertIsNone(training.end)
def test_re_saving_a_game_does_not_move_its_end(self):
game = self.make_event(kind=Event.EventKind.GAME, start=self.future)
original_end = game.end
game.title = "Renamed"
game.save()
self.assertEqual(game.end, original_end)
class CompetitionModelTests(EventsTestBase):
def test_sport_type_defaults_to_other(self):
competition = Competition.objects.create(name="Local League", module="events.competition.other")
self.assertEqual(competition.sport_type, Club.SportType.OTHER)
def test_the_seeded_hockey_competitions_are_backfilled_as_ice_hockey(self):
# Migration 0019's data migration backfills the two competitions seeded
# by 0017 (both real ice hockey leagues) rather than leaving them at the
# generic "other" default.
for name in ["RBIHF", "CEHL"]:
with self.subTest(name=name):
self.assertEqual(Competition.objects.get(name=name).sport_type, Club.SportType.ICE_HOCKEY)
class EventAdminFormCompetitionTests(EventsTestBase):
"""`competition` is a plain CharField, but the admin should only ever offer
competitions this club is actually allowed to use -- see events/admin.py."""
def setUp(self):
super().setUp()
Flag = get_waffle_flag_model()
self.active_flag = Flag.objects.create(name="active-competition")
self.active_flag.clubs.add(self.club)
self.inactive_flag = Flag.objects.create(name="inactive-competition")
self.active_competition = Competition.objects.create(name="Active Cup", module="events.competition.active", flag=self.active_flag)
self.inactive_competition = Competition.objects.create(name="Inactive Cup", module="events.competition.inactive", flag=self.inactive_flag)
self.flagless_competition = Competition.objects.create(name="Flagless Cup", module="events.competition.flagless")
def test_a_flagless_competition_never_appears(self):
form = EventAdminForm(instance=Event())
choices = dict(form.fields["competition"].choices)
self.assertNotIn("Flagless Cup", choices)
def test_a_brand_new_event_offers_every_flagged_competition_unfiltered(self):
# The club is unknown until the event is actually saved (see
# EventClubScopeTests for the same "club_id is None pre-save" timing issue
# elsewhere), so a new event can't be filtered by club yet -- it falls back
# to showing everything with a flag rather than crashing or showing nothing.
form = EventAdminForm(instance=Event())
choices = dict(form.fields["competition"].choices)
self.assertIn("Active Cup", choices)
self.assertIn("Inactive Cup", choices)
def test_an_existing_event_only_offers_competitions_active_for_its_club(self):
event = self.make_event(kind=Event.EventKind.GAME)
form = EventAdminForm(instance=event)
choices = dict(form.fields["competition"].choices)
self.assertIn("Active Cup", choices)
self.assertNotIn("Inactive Cup", choices)
self.assertNotIn("Flagless Cup", choices)
class EffectiveMembersTests(EventsTestBase):
def test_union_of_team_invited_minus_excluded(self):
carol = Member.objects.create(first_name="Carol", last_name="Cedar")
event = self.make_event()
event.teams.set([self.team])
event.invited_members.set([carol])
event.excluded_members.set([self.bob])
self.assertEqual(
set(effective_members(event).values_list("id", flat=True)),
{self.alice.id, carol.id},
)
def test_season_is_derived_from_start_date(self):
event = self.make_event(season=None)
event.teams.set([self.team])
self.assertEqual(self.attendee_ids(event), {self.alice.id, self.bob.id})
def test_no_covering_season_yields_no_team_members(self):
event = self.make_event(season=None, start=timezone.now() + timedelta(days=5000))
event.teams.set([self.team])
self.assertEqual(event.attendances.count(), 0)
class AttendanceSyncTests(EventsTestBase):
def test_setting_teams_creates_attendance_for_roster(self):
event = self.make_event()
event.teams.set([self.team])
self.assertEqual(self.attendee_ids(event), {self.alice.id, self.bob.id})
def test_invited_member_gets_attendance(self):
carol = Member.objects.create(first_name="Carol", last_name="Cedar")
event = self.make_event()
event.invited_members.set([carol])
self.assertEqual(self.attendee_ids(event), {carol.id})
def test_reverse_invited_relation_syncs(self):
carol = Member.objects.create(first_name="Carol", last_name="Cedar")
event = self.make_event()
carol.invited_to_events.add(event)
self.assertIn(carol.id, self.attendee_ids(event))
def test_excluding_member_removes_attendance(self):
event = self.make_event()
event.teams.set([self.team])
event.excluded_members.set([self.alice])
self.assertEqual(self.attendee_ids(event), {self.bob.id})
def test_hard_reconcile_removes_even_responded_rows(self):
event = self.make_event()
event.teams.set([self.team])
attendance = event.attendances.get(member=self.alice)
attendance.status = Attendance.AttendanceStatus.PRESENT
attendance.save()
event.excluded_members.set([self.alice])
self.assertFalse(event.attendances.filter(member=self.alice).exists())
def test_past_event_is_not_synced(self):
event = self.make_event(start=timezone.now() - timedelta(days=1))
event.teams.set([self.team])
self.assertEqual(event.attendances.count(), 0)
class RosterChangeSyncTests(EventsTestBase):
def test_adding_roster_member_syncs_future_events(self):
event = self.make_event()
event.teams.set([self.team])
dave = Member.objects.create(first_name="Dave", last_name="Dogwood")
TeamMembership.objects.create(team=self.team, member=dave, season=self.season, position=self.position)
self.assertIn(dave.id, self.attendee_ids(event))
def test_removing_roster_member_syncs_future_events(self):
event = self.make_event()
event.teams.set([self.team])
TeamMembership.objects.get(team=self.team, member=self.alice).delete()
self.assertNotIn(self.alice.id, self.attendee_ids(event))
def test_roster_change_leaves_past_events_untouched(self):
past = self.make_event(start=timezone.now() - timedelta(days=1))
past.teams.add(self.team)
Attendance.objects.create(event=past, member=self.alice)
TeamMembership.objects.get(team=self.team, member=self.alice).delete()
self.assertTrue(past.attendances.filter(member=self.alice).exists())
class RecurrenceTestBase(EventsTestBase):
def setUp(self):
super().setUp()
self.anchor = (timezone.now() + timedelta(days=1)).replace(microsecond=0)
def make_series(self, **kwargs):
kwargs.setdefault("club", self.club)
kwargs.setdefault("title", "Weekly Training")
kwargs.setdefault("kind", Event.EventKind.TRAINING)
kwargs.setdefault("rrule", "FREQ=WEEKLY;COUNT=4")
kwargs.setdefault("dtstart", self.anchor)
kwargs.setdefault("duration", timedelta(hours=2))
series = EventSeries.objects.create(**kwargs)
series.teams.set([self.team])
return series
class OccurrenceExpansionTests(RecurrenceTestBase):
def test_str(self):
self.assertEqual(str(self.make_series()), "Weekly Training")
def test_weekly_expansion(self):
series = self.make_series()
dts = occurrence_datetimes(series, self.anchor + timedelta(days=30))
self.assertEqual(dts[0], self.anchor)
self.assertEqual(dts[1], self.anchor + timedelta(weeks=1))
self.assertEqual(len(dts), 4)
def test_until_bounds_expansion(self):
series = self.make_series()
dts = occurrence_datetimes(series, self.anchor + timedelta(days=10))
self.assertEqual(len(dts), 2)
def test_excluded_dates_are_skipped(self):
series = self.make_series()
skipped = self.anchor + timedelta(weeks=1)
series.excluded_dates = [skipped.isoformat()]
series.save()
dts = occurrence_datetimes(series, self.anchor + timedelta(days=30))
self.assertNotIn(skipped, dts)
self.assertEqual(len(dts), 3)
def test_series_until_caps_expansion(self):
series = self.make_series(rrule="FREQ=WEEKLY", until=self.anchor + timedelta(days=10))
dts = occurrence_datetimes(series, self.anchor + timedelta(days=90))
# Only the anchor and the first weekly repeat fall on/before `until`.
self.assertEqual(dts, [self.anchor, self.anchor + timedelta(weeks=1)])
self.assertTrue(all(dt <= series.until for dt in dts))
class GenerateOccurrencesTests(RecurrenceTestBase):
def test_materialises_occurrences_with_template_and_attendance(self):
series = self.make_series()
created = generate_occurrences(series, self.anchor + timedelta(days=30))
self.assertEqual(len(created), 4)
first = series.occurrences.order_by("start").first()
self.assertEqual(first.start, self.anchor)
self.assertEqual(first.end, self.anchor + timedelta(hours=2))
self.assertEqual(first.title, "Weekly Training")
self.assertEqual(first.kind, Event.EventKind.TRAINING)
# Audience copied from the series, so attendance follows the roster.
self.assertEqual(set(first.attendances.values_list("member_id", flat=True)), {self.alice.id, self.bob.id})
series.refresh_from_db()
self.assertIsNotNone(series.generated_until)
def test_generation_is_idempotent(self):
series = self.make_series()
until = self.anchor + timedelta(days=30)
generate_occurrences(series, until)
generate_occurrences(series, until)
self.assertEqual(series.occurrences.count(), 4)
def test_generation_stops_at_series_until(self):
series = self.make_series(rrule="FREQ=WEEKLY", until=self.anchor + timedelta(days=10))
generate_occurrences(series)
self.assertEqual(series.occurrences.count(), 2)
def test_gathering_and_deadline_come_from_offsets(self):
series = self.make_series(gathering_offset=timedelta(minutes=30), deadline_offset=timedelta(days=1))
generate_occurrences(series, self.anchor + timedelta(days=30))
first = series.occurrences.order_by("start").first()
self.assertEqual(first.gathering, self.anchor - timedelta(minutes=30))
self.assertEqual(first.deadline, self.anchor - timedelta(days=1))
def test_without_offsets_gathering_and_deadline_are_blank(self):
series = self.make_series()
generate_occurrences(series, self.anchor + timedelta(days=30))
first = series.occurrences.order_by("start").first()
self.assertIsNone(first.gathering)
self.assertIsNone(first.deadline)
class SingleOccurrenceTests(RecurrenceTestBase):
def test_cancel_deletes_and_prevents_regeneration(self):
series = self.make_series()
until = self.anchor + timedelta(days=30)
generate_occurrences(series, until)
target = series.occurrences.order_by("start")[1]
target_start = target.start
cancel_occurrence(target)
self.assertFalse(series.occurrences.filter(start=target_start).exists())
generate_occurrences(series, until)
self.assertFalse(series.occurrences.filter(start=target_start).exists())
self.assertEqual(series.occurrences.count(), 3)
def test_cancel_soft_marks_cancelled(self):
series = self.make_series()
generate_occurrences(series, self.anchor + timedelta(days=30))
target = series.occurrences.order_by("start").first()
cancel_occurrence(target, hard_delete=False)
target.refresh_from_db()
self.assertTrue(target.cancelled)
self.assertIn(target.start.isoformat(), series.excluded_dates)
def test_detached_occurrence_is_left_untouched_by_propagation(self):
series = self.make_series()
generate_occurrences(series, self.anchor + timedelta(days=30))
detached = series.occurrences.order_by("start").first()
detach_occurrence(detached)
series.title = "Renamed"
series.save()
propagate_series(series)
detached.refresh_from_db()
self.assertEqual(detached.title, "Weekly Training")
other = series.occurrences.exclude(pk=detached.pk).order_by("start").first()
self.assertEqual(other.title, "Renamed")
def test_propagation_updates_audience_and_attendance(self):
series = self.make_series()
generate_occurrences(series, self.anchor + timedelta(days=30))
carol = Member.objects.create(first_name="Carol", last_name="Cedar")
series.invited_members.set([carol])
propagate_series(series)
event = series.occurrences.order_by("start").first()
self.assertIn(carol.id, set(event.attendances.values_list("member_id", flat=True)))
def test_propagation_updates_timing_offsets(self):
series = self.make_series()
generate_occurrences(series, self.anchor + timedelta(days=30))
series.gathering_offset = timedelta(minutes=45)
series.save()
propagate_series(series)
first = series.occurrences.order_by("start").first()
self.assertEqual(first.gathering, self.anchor - timedelta(minutes=45))
class ExtendSeriesCommandTests(RecurrenceTestBase):
def test_command_generates_occurrences(self):
series = self.make_series()
out = StringIO()
call_command("extend_event_series", stdout=out)
self.assertEqual(series.occurrences.count(), 4)
self.assertIn("Done.", out.getvalue())
class EventClubScopeTests(EventsTestBase):
def setUp(self):
super().setUp()
self.other = Club.objects.create(name="Rival FC", slug="rival-fc")
today = timezone.localdate()
self.other_season = Season.objects.create(club=self.other, start_date=today - timedelta(days=30), end_date=today + timedelta(days=300))
self.other_location = Location.objects.create(club=self.other, name="Arena", address="1 St", city="Town", zip_code="1000", country="BE")
self.other_opponent = Opponent.objects.create(club=self.other, name="Rivals")
self.other_team = Team.objects.create(club=self.other, name="First", short_name="1")
def test_event_rejects_cross_club_season(self):
event = Event(club=self.club, title="Match", start=self.future, season=self.other_season)
with self.assertRaises(ValidationError) as ctx:
event.full_clean()
self.assertIn("season", ctx.exception.error_dict)
def test_event_rejects_cross_club_location(self):
event = Event(club=self.club, title="Match", start=self.future, location=self.other_location)
with self.assertRaises(ValidationError) as ctx:
event.full_clean()
self.assertIn("location", ctx.exception.error_dict)
def test_event_accepts_same_club_fields(self):
Event(club=self.club, title="Match", start=self.future, season=self.season).full_clean()
def test_event_rejects_cross_club_team(self):
event = Event.objects.create(club=self.club, title="Match", start=self.future, season=self.season)
with self.assertRaises(ValidationError):
event.teams.add(self.other_team)
def test_event_accepts_same_club_team(self):
event = Event.objects.create(club=self.club, title="Match", start=self.future, season=self.season)
event.teams.add(self.team)
self.assertIn(self.team, event.teams.all())
def test_series_rejects_cross_club_opponent(self):
series = EventSeries(club=self.club, title="Weekly", rrule="FREQ=WEEKLY", dtstart=self.future, opponent=self.other_opponent)
with self.assertRaises(ValidationError) as ctx:
series.full_clean()
self.assertIn("opponent", ctx.exception.error_dict)
def test_series_rejects_cross_club_team(self):
series = EventSeries.objects.create(club=self.club, title="Weekly", rrule="FREQ=WEEKLY", dtstart=self.future)
with self.assertRaises(ValidationError):
series.teams.add(self.other_team)
class TeamAttendanceStatsTests(EventsTestBase):
"""events.services.attendance's team+season-scoped stats -- the queries
behind management.views.TeamDetailView's attendance panel."""
def make_past_training(self, days_ago, **kwargs):
# Past-start events are never auto-synced (see test_past_event_is_not_synced
# above), so attendance rows have to be created by hand here.
kwargs.setdefault("kind", Event.EventKind.TRAINING)
event = self.make_event(start=timezone.now() - timedelta(days=days_ago), **kwargs)
event.teams.add(self.team)
return event
def set_status(self, event, member, status):
attendance, _created = Attendance.objects.update_or_create(event=event, member=member, defaults={"status": status})
return attendance
def test_record_check_in_sets_showed_up(self):
event = self.make_past_training(1)
attendance = self.set_status(event, self.alice, Attendance.AttendanceStatus.PRESENT)
record_check_in(attendance, showed_up=False)
attendance.refresh_from_db()
self.assertFalse(attendance.showed_up)
def test_team_attendance_rate_excludes_excused_and_no_response(self):
event = self.make_past_training(1)
self.set_status(event, self.alice, Attendance.AttendanceStatus.PRESENT)
self.set_status(event, self.bob, Attendance.AttendanceStatus.ABSENT)
self.assertEqual(team_attendance_rate(self.team, self.season), 50)
def test_team_attendance_rate_is_none_with_no_past_events(self):
self.assertIsNone(team_attendance_rate(self.team, self.season))
def test_rankings_exclude_players_below_the_response_minimum(self):
event = self.make_past_training(1)
self.set_status(event, self.alice, Attendance.AttendanceStatus.PRESENT)
self.set_status(event, self.bob, Attendance.AttendanceStatus.ABSENT)
rankings = player_attendance_rankings(self.team, self.season, minimum_responses=2)
self.assertEqual(rankings, [])
def test_rankings_rank_best_first(self):
e1 = self.make_past_training(10)
e2 = self.make_past_training(3)
self.set_status(e1, self.alice, Attendance.AttendanceStatus.PRESENT)
self.set_status(e2, self.alice, Attendance.AttendanceStatus.PRESENT)
self.set_status(e1, self.bob, Attendance.AttendanceStatus.PRESENT)
self.set_status(e2, self.bob, Attendance.AttendanceStatus.ABSENT)
rankings = player_attendance_rankings(self.team, self.season, minimum_responses=2)
self.assertEqual([entry["member"] for entry in rankings], [self.alice, self.bob])
self.assertEqual(rankings[0]["rate"], 100)
self.assertEqual(rankings[1]["rate"], 50)
def test_missed_recent_practices_needs_full_history(self):
self.make_past_training(3) # only one practice logged so far
self.assertFalse(players_who_missed_recent_practices(self.team, self.season, count=2).exists())
def test_missed_recent_practices_flags_absence_on_both(self):
e1 = self.make_past_training(10)
e2 = self.make_past_training(3)
self.set_status(e1, self.alice, Attendance.AttendanceStatus.ABSENT)
self.set_status(e2, self.alice, Attendance.AttendanceStatus.NO_RESPONSE)
self.set_status(e1, self.bob, Attendance.AttendanceStatus.PRESENT)
self.set_status(e2, self.bob, Attendance.AttendanceStatus.ABSENT)
missed = players_who_missed_recent_practices(self.team, self.season, count=2)
self.assertEqual(list(missed), [self.alice])
def test_missed_recent_practices_excludes_an_excused_absence(self):
e1 = self.make_past_training(10)
e2 = self.make_past_training(3)
self.set_status(e1, self.alice, Attendance.AttendanceStatus.EXCUSED)
self.set_status(e2, self.alice, Attendance.AttendanceStatus.ABSENT)
missed = players_who_missed_recent_practices(self.team, self.season, count=2)
self.assertNotIn(self.alice, missed)
def test_no_shows_requires_an_explicit_check_in(self):
event = self.make_past_training(1)
self.set_status(event, self.alice, Attendance.AttendanceStatus.PRESENT)
# Nobody has been checked in at all -- must not read as a no-show.
self.assertEqual(list(team_no_shows(self.team, self.season)), [])
def test_no_shows_flags_a_present_rsvp_checked_in_as_absent(self):
event = self.make_past_training(1)
attendance = self.set_status(event, self.alice, Attendance.AttendanceStatus.PRESENT)
record_check_in(attendance, showed_up=False)
no_shows = team_no_shows(self.team, self.season)
self.assertEqual(len(no_shows), 1)
self.assertEqual(no_shows[0].member, self.alice)
self.assertEqual(no_shows[0].event, event)
def test_a_confirmed_check_in_is_not_a_no_show(self):
event = self.make_past_training(1)
attendance = self.set_status(event, self.alice, Attendance.AttendanceStatus.PRESENT)
record_check_in(attendance, showed_up=True)
self.assertEqual(list(team_no_shows(self.team, self.season)), [])
RBIHF_TEAM_ID = "4460"
RBIHF_TEAM_NAME = "Sportoase Antwerp Phantoms"
def rbihf_sample_html(rows):
"""A trimmed stand-in for an RBIHF team page (https://www.rbihf.be/league/team/<id>)
-- structure confirmed against the real page while designing this feature.
``rows`` is a list of dicts: game_id, date ("YYYY-MM-DD"), hour ("HH:MM"),
venue, home_id, home_name, visit_id, visit_name."""
row_html = "".join(
f'<tr><td class="game-nr"><a href="/game/{r["game_id"]}" title="Game {r["game_id"]}">{r["game_id"]}</a></td>'
f'<td class="date">{r["date"]}</td><td class="hour">{r["hour"]}</td><td>{r["venue"]}</td>'
f'<td><a href="/league/team/{r["home_id"]}" title="{r["home_name"]}">{r["home_name"]}</a></td>'
f'<td><a href="/league/team/{r["visit_id"]}" title="{r["visit_name"]}">{r["visit_name"]}</a></td></tr>'
for r in rows
)
return f"""<html><body>
<div class="block"><div class="block-header"><h2>{RBIHF_TEAM_NAME}</h2></div></div>
<div class="block"><div class="block-header"><h2 id="games-upcoming">Upcoming games</h2></div>
<div class="block-content"><table>
<tr><th class="game-nr">#</th><th class="date">Date</th><th class="hour">Hour</th><th>Location</th><th>Home</th><th>Visit</th></tr>
{row_html}
</table></div></div>
</body></html>"""
class RBIHFParseFixturesTests(TestCase):
"""Pure parsing, no network, no DB -- events.services.rbihf_import.parse_fixtures."""
def test_parses_team_name_and_fixtures(self):
html = rbihf_sample_html(
[
{"game_id": "5002", "date": "2026-09-12", "hour": "12:15", "venue": "Deurne", "home_id": RBIHF_TEAM_ID, "home_name": RBIHF_TEAM_NAME, "visit_id": "4464", "visit_name": "Amsterdam Tigers"},
{"game_id": "5010", "date": "2026-09-20", "hour": "18:30", "venue": "Antwerp Ice Rink", "home_id": "4500", "home_name": "Brussels Bears", "visit_id": RBIHF_TEAM_ID, "visit_name": RBIHF_TEAM_NAME},
]
)
team_name, fixtures = parse_fixtures(html, RBIHF_TEAM_ID)
self.assertEqual(team_name, RBIHF_TEAM_NAME)
self.assertEqual(len(fixtures), 2)
def test_resolves_home_fixture_correctly(self):
html = rbihf_sample_html([{"game_id": "5002", "date": "2026-09-12", "hour": "12:15", "venue": "Deurne", "home_id": RBIHF_TEAM_ID, "home_name": RBIHF_TEAM_NAME, "visit_id": "4464", "visit_name": "Amsterdam Tigers"}])
_team_name, fixtures = parse_fixtures(html, RBIHF_TEAM_ID)
self.assertTrue(fixtures[0].is_home)
self.assertEqual(fixtures[0].opponent_name, "Amsterdam Tigers")
self.assertEqual(fixtures[0].venue_text, "Deurne")
self.assertEqual(fixtures[0].external_game_id, "5002")
def test_resolves_away_fixture_correctly(self):
html = rbihf_sample_html([{"game_id": "5010", "date": "2026-09-20", "hour": "18:30", "venue": "Antwerp Ice Rink", "home_id": "4500", "home_name": "Brussels Bears", "visit_id": RBIHF_TEAM_ID, "visit_name": RBIHF_TEAM_NAME}])
_team_name, fixtures = parse_fixtures(html, RBIHF_TEAM_ID)
self.assertFalse(fixtures[0].is_home)
self.assertEqual(fixtures[0].opponent_name, "Brussels Bears")
def test_a_row_where_neither_side_matches_is_skipped(self):
html = rbihf_sample_html([{"game_id": "9999", "date": "2026-09-20", "hour": "18:30", "venue": "Elsewhere", "home_id": "1", "home_name": "Team One", "visit_id": "2", "visit_name": "Team Two"}])
_team_name, fixtures = parse_fixtures(html, RBIHF_TEAM_ID)
self.assertEqual(fixtures, [])
def test_missing_games_table_raises(self):
with self.assertRaises(RBIHFImportError):
parse_fixtures("<html><body><h2>Some Team</h2></body></html>", RBIHF_TEAM_ID)
class RBIHFExtractTeamIdTests(TestCase):
def test_accepts_the_documented_shape(self):
self.assertEqual(extract_team_id("https://www.rbihf.be/league/team/4460"), "4460")
def test_accepts_without_www(self):
self.assertEqual(extract_team_id("https://rbihf.be/league/team/4460"), "4460")
def test_rejects_a_different_host(self):
with self.assertRaises(RBIHFImportError):
extract_team_id("https://evil.example.com/league/team/4460")
def test_rejects_a_different_path(self):
with self.assertRaises(RBIHFImportError):
extract_team_id("https://www.rbihf.be/leagues")
class RBIHFImportPlanTests(EventsTestBase):
def setUp(self):
super().setUp()
self.home_location = Location.objects.create(club=self.club, name="Home Arena", address="1 St", city="Antwerp", zip_code="1000", country="BE", is_home=True)
self.away_location = Location.objects.create(club=self.club, name="Deurne Ice Hall", address="2 St", city="Deurne", zip_code="2100", country="BE")
def home_fixture_row(self, game_id="5002", date="2026-09-12"):
return {"game_id": game_id, "date": date, "hour": "12:15", "venue": "Deurne", "home_id": RBIHF_TEAM_ID, "home_name": RBIHF_TEAM_NAME, "visit_id": "4464", "visit_name": "Amsterdam Tigers"}
# -- suggested_location --------------------------------------------------
def test_suggested_location_for_a_home_fixture_is_the_clubs_home_location(self):
_team_name, fixtures = parse_fixtures(rbihf_sample_html([self.home_fixture_row()]), RBIHF_TEAM_ID)
self.assertEqual(suggested_location(self.club, fixtures[0]), self.home_location)
def test_suggested_location_for_an_away_fixture_matches_by_city(self):
row = {"game_id": "5010", "date": "2026-09-20", "hour": "18:30", "venue": "Deurne", "home_id": "4500", "home_name": "Brussels Bears", "visit_id": RBIHF_TEAM_ID, "visit_name": RBIHF_TEAM_NAME}
_team_name, fixtures = parse_fixtures(rbihf_sample_html([row]), RBIHF_TEAM_ID)
self.assertEqual(suggested_location(self.club, fixtures[0]), self.away_location)
def test_suggested_location_is_none_when_nothing_matches(self):
row = {"game_id": "5010", "date": "2026-09-20", "hour": "18:30", "venue": "Nowhere Familiar", "home_id": "4500", "home_name": "Brussels Bears", "visit_id": RBIHF_TEAM_ID, "visit_name": RBIHF_TEAM_NAME}
_team_name, fixtures = parse_fixtures(rbihf_sample_html([row]), RBIHF_TEAM_ID)
self.assertIsNone(suggested_location(self.club, fixtures[0]))
# -- suggested_opponent ---------------------------------------------------
def test_suggested_opponent_matches_case_insensitively(self):
opponent = Opponent.objects.create(club=self.club, name="AMSTERDAM TIGERS")
_team_name, fixtures = parse_fixtures(rbihf_sample_html([self.home_fixture_row()]), RBIHF_TEAM_ID)
self.assertEqual(suggested_opponent(self.club, fixtures[0]), opponent)
def test_suggested_opponent_is_none_when_nothing_matches(self):
_team_name, fixtures = parse_fixtures(rbihf_sample_html([self.home_fixture_row()]), RBIHF_TEAM_ID)
self.assertIsNone(suggested_opponent(self.club, fixtures[0]))
# -- build_plan ------------------------------------------------------------
def test_a_new_fixture_is_planned_as_a_create(self):
html = rbihf_sample_html([self.home_fixture_row()])
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
self.assertEqual(len(plan.to_create), 1)
self.assertEqual(plan.to_create[0].fixture.external_game_id, "5002")
self.assertEqual(plan.to_create[0].suggested_location, self.home_location)
def test_an_identical_existing_fixture_is_unchanged(self):
html = rbihf_sample_html([self.home_fixture_row()])
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
apply_plan(plan, {})
plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
self.assertEqual(plan_again.to_create, [])
self.assertEqual(plan_again.to_update, [])
self.assertEqual(plan_again.unchanged_count, 1)
def test_a_changed_fixture_is_planned_as_an_update_with_a_diff(self):
html = rbihf_sample_html([self.home_fixture_row()])
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
apply_plan(plan, {})
changed_row = self.home_fixture_row()
changed_row["hour"] = "20:00" # same game id, different time
changed_html = rbihf_sample_html([changed_row])
plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, changed_html)
self.assertEqual(len(plan_again.to_update), 1)
self.assertIn("start", plan_again.to_update[0].changes)
def test_a_future_fixture_no_longer_listed_is_planned_for_deletion(self):
html = rbihf_sample_html([self.home_fixture_row()])
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
apply_plan(plan, {})
plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, rbihf_sample_html([]))
self.assertEqual(len(plan_again.to_delete), 1)
self.assertEqual(plan_again.to_delete[0].external_game_id, "5002")
def test_a_past_fixture_no_longer_listed_is_not_deleted(self):
# "Upcoming games" naturally stops listing a game once it's happened --
# that's not a signal it was cancelled.
past_row = self.home_fixture_row(date="2020-01-01")
html = rbihf_sample_html([past_row])
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
apply_plan(plan, {})
plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, rbihf_sample_html([]))
self.assertEqual(plan_again.to_delete, [])
def test_a_fixture_for_a_different_team_is_not_touched(self):
other_team = Team.objects.create(club=self.club, name="Second Team", short_name="2nd")
html = rbihf_sample_html([self.home_fixture_row()])
plan = build_plan(self.club, other_team, RBIHF_TEAM_ID, html)
apply_plan(plan, {})
# Re-running for *our* team should see no existing RBIHF events at all
# for it -- the one that exists belongs to other_team.
plan_for_our_team = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
self.assertEqual(len(plan_for_our_team.to_create), 1)
# -- apply_plan --------------------------------------------------------
def test_apply_plan_creates_events_with_the_expected_fields(self):
html = rbihf_sample_html([self.home_fixture_row()])
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
result = apply_plan(plan, {"5002": str(self.home_location.pk)})
self.assertEqual(result, {"created": 1, "updated": 0, "deleted": 0})
event = Event.objects.get(club=self.club, external_game_id="5002")
self.assertEqual(event.kind, Event.EventKind.GAME)
self.assertEqual(event.competition, "RBIHF")
self.assertEqual(event.opponent.name, "Amsterdam Tigers")
self.assertEqual(event.location, self.home_location)
self.assertIn(self.team, event.teams.all())
self.assertTrue(event.is_home_game)
def test_apply_plan_ignores_a_location_id_from_another_club(self):
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
foreign_location = Location.objects.create(club=other_club, name="Not ours", address="x", city="x", zip_code="x", country="BE")
html = rbihf_sample_html([self.home_fixture_row()])
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
apply_plan(plan, {"5002": str(foreign_location.pk)})
event = Event.objects.get(club=self.club, external_game_id="5002")
self.assertIsNone(event.location)
def test_apply_plan_uses_an_explicitly_chosen_opponent_instead_of_the_scraped_name(self):
renamed = Opponent.objects.create(club=self.club, name="Amsterdam Tigers HC")
html = rbihf_sample_html([self.home_fixture_row()])
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
apply_plan(plan, {}, {"5002": str(renamed.pk)})
event = Event.objects.get(club=self.club, external_game_id="5002")
self.assertEqual(event.opponent, renamed)
self.assertEqual(Opponent.objects.filter(club=self.club, name="Amsterdam Tigers").count(), 0)
def test_apply_plan_ignores_an_opponent_id_from_another_club(self):
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
foreign_opponent = Opponent.objects.create(club=other_club, name="Not ours")
html = rbihf_sample_html([self.home_fixture_row()])
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
apply_plan(plan, {}, {"5002": str(foreign_opponent.pk)})
event = Event.objects.get(club=self.club, external_game_id="5002")
# Falls back to find-or-create by the scraped name, not the foreign row.
self.assertEqual(event.opponent.name, "Amsterdam Tigers")
def test_apply_plan_preserves_a_previously_chosen_opponent_on_update(self):
renamed = Opponent.objects.create(club=self.club, name="Amsterdam Tigers HC")
html = rbihf_sample_html([self.home_fixture_row()])
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
apply_plan(plan, {}, {"5002": str(renamed.pk)})
# Re-run with a changed start time (something else triggers the update).
# build_plan should suggest (pre-select) the event's existing opponent
# rather than re-guessing from the scraped name -- a real <select>
# always resubmits whichever option is pre-selected, so that's what's
# passed to apply_plan here too, not a blank dict.
changed_row = self.home_fixture_row()
changed_row["hour"] = "20:00"
plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, rbihf_sample_html([changed_row]))
self.assertEqual(plan_again.to_update[0].suggested_opponent, renamed)
apply_plan(plan_again, {}, {"5002": str(renamed.pk)})
event = Event.objects.get(club=self.club, external_game_id="5002")
self.assertEqual(event.opponent, renamed)
def test_apply_plan_updates_only_start_opponent_and_location(self):
html = rbihf_sample_html([self.home_fixture_row()])
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
apply_plan(plan, {})
event = Event.objects.get(club=self.club, external_game_id="5002")
event.end = self.future
event.gathering = self.future
event.score_for = 3
event.save()
changed_row = self.home_fixture_row()
changed_row["hour"] = "20:00"
plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, rbihf_sample_html([changed_row]))
apply_plan(plan_again, {})
event.refresh_from_db()
self.assertEqual(timezone.localtime(event.start).strftime("%H:%M"), "20:00")
self.assertIsNotNone(event.end)
self.assertIsNotNone(event.gathering)
self.assertEqual(event.score_for, 3)
def test_apply_plan_deletes_only_whats_in_to_delete(self):
html = rbihf_sample_html([self.home_fixture_row()])
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
apply_plan(plan, {})
plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, rbihf_sample_html([]))
result = apply_plan(plan_again, {})
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"))