New models (events/models.py): Lineup (one per event), LineupUnit (Line 1, Defence pair 1, ... -- coach-entered labels, no fixed sport structure since neither Club nor Team carries one), LineupSlot (one position, optionally filled by a member). events/services/lineup.py's publish_lineup is the reason Attendance.AttendanceStatus.SELECTED/NOT_SELECTED existed at all -- publishing flips every slotted member to SELECTED and every other available (non-out, non-silent) roster member to NOT_SELECTED, then notifies only the selected players. Placement is a native <select> per slot, batch-saved with one "Save line-up" submit, not the design mock's drag-and-drop -- this codebase has no established htmx interaction pattern yet (htmx.js is loaded but nothing uses it) to build a live per-tap version on, and a reliable plain form beats a first, unproven real-time interaction for an already-large screen. place_member handles "swap" semantics for it: placing a member vacates any other slot of theirs in the same lineup, bumping whoever was already in the target slot back to the available pool. Wires the missing pieces from C1's own docstring: the "needs you" line-up- not-published row and the tonight card's "Line-up" button, both deferred in that stage specifically because this model didn't exist yet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
1677 lines
76 KiB
Python
1677 lines
76 KiB
Python
from datetime import date, datetime, time, timedelta
|
|
from decimal import Decimal
|
|
from io import StringIO
|
|
|
|
from django.contrib.auth import get_user_model
|
|
from django.core.cache import cache
|
|
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, ClubMembership, OnboardingRequirement, Season
|
|
from club.services.onboarding import mark_bypassed, mark_complete
|
|
from features.models import Maintenance
|
|
from members.models import Group, GroupMembership, Member
|
|
from notifications.models import Notification
|
|
from teams.models import Position, RefereeLevel, RefereeProfile, Team, TeamMembership
|
|
|
|
from .admin import EventAdminForm
|
|
from .models import Attendance, Competition, Event, EventReferee, EventSeries, Lineup, LineupSlot, LineupUnit, 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.calendar import add_months, month_bounds, month_grid, season_grid, week_bounds, week_grid
|
|
from .services.lineup import clear_slot, place_member, publish_lineup
|
|
from .services.rbihf_import import RBIHFImportError, apply_plan, build_plan, extract_team_id, parse_fixtures, suggested_location, suggested_opponent
|
|
from .services.referees import RefereeAssignmentError, add_external_referee, assign_referee, conflicting_events, eligible_referees, needs_referee_management, remove_referee, set_referee_fee
|
|
from .tasks import send_deadline_reminders
|
|
|
|
|
|
class EventsTestBase(TestCase):
|
|
# One club, one current season, one two-player roster: shared by every events test
|
|
# and never edited in place. Tests that do change these rows (deleting Alice, flipping
|
|
# the team to federation-managed) are safe -- setUpTestData hands each test its own
|
|
# copy of the objects, and the surrounding transaction rolls the rows back.
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
today = timezone.localdate()
|
|
cls.season = Season.objects.create(
|
|
club=cls.club,
|
|
start_date=today - timedelta(days=30),
|
|
end_date=today + timedelta(days=300),
|
|
)
|
|
cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st")
|
|
cls.position = Position.objects.create(club=cls.club, name="Forward", short_name="FW")
|
|
cls.alice = Member.objects.create(first_name="Alice", last_name="Ash")
|
|
cls.bob = Member.objects.create(first_name="Bob", last_name="Birch")
|
|
TeamMembership.objects.create(team=cls.team, member=cls.alice, season=cls.season, position=cls.position)
|
|
TeamMembership.objects.create(team=cls.team, member=cls.bob, season=cls.season, position=cls.position)
|
|
cls.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."""
|
|
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
super().setUpTestData()
|
|
Flag = get_waffle_flag_model()
|
|
cls.active_flag = Flag.objects.create(name="active-competition")
|
|
cls.active_flag.clubs.add(cls.club)
|
|
cls.inactive_flag = Flag.objects.create(name="inactive-competition")
|
|
cls.active_competition = Competition.objects.create(name="Active Cup", module="events.competition.active", flag=cls.active_flag)
|
|
cls.inactive_competition = Competition.objects.create(name="Inactive Cup", module="events.competition.inactive", flag=cls.inactive_flag)
|
|
cls.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)
|
|
|
|
def test_group_members_are_included(self):
|
|
carol = Member.objects.create(first_name="Carol", last_name="Cedar")
|
|
group = Group.objects.create(club=self.club, name="Committee")
|
|
GroupMembership.objects.create(group=group, member=carol)
|
|
event = self.make_event()
|
|
event.groups.set([group])
|
|
|
|
self.assertEqual(self.attendee_ids(event), {carol.id})
|
|
|
|
def test_teams_and_groups_combine(self):
|
|
carol = Member.objects.create(first_name="Carol", last_name="Cedar")
|
|
group = Group.objects.create(club=self.club, name="Committee")
|
|
GroupMembership.objects.create(group=group, member=carol)
|
|
event = self.make_event()
|
|
event.teams.set([self.team])
|
|
event.groups.set([group])
|
|
|
|
self.assertEqual(self.attendee_ids(event), {self.alice.id, self.bob.id, carol.id})
|
|
|
|
def test_club_wide_includes_every_active_member_this_season(self):
|
|
carol = Member.objects.create(first_name="Carol", last_name="Cedar")
|
|
ClubMembership.objects.create(club=self.club, member=carol, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
|
|
lapsed = Member.objects.create(first_name="Len", last_name="Lapsed")
|
|
ClubMembership.objects.create(club=self.club, member=lapsed, season=self.season, status=ClubMembership.StatusChoices.LAPSED)
|
|
event = self.make_event(club_wide=True)
|
|
|
|
self.assertEqual(self.attendee_ids(event), {carol.id})
|
|
|
|
def test_club_wide_ignores_teams_and_groups(self):
|
|
# Teams/groups can be left populated on the row without affecting the
|
|
# audience -- validation (EventForm) is what keeps editors from doing
|
|
# this in the UI, but effective_members itself just ignores them.
|
|
carol = Member.objects.create(first_name="Carol", last_name="Cedar")
|
|
ClubMembership.objects.create(club=self.club, member=carol, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
|
|
event = self.make_event(club_wide=True)
|
|
event.teams.set([self.team])
|
|
|
|
self.assertEqual(self.attendee_ids(event), {carol.id})
|
|
|
|
def test_club_wide_still_honours_invited_and_excluded(self):
|
|
carol = Member.objects.create(first_name="Carol", last_name="Cedar")
|
|
ClubMembership.objects.create(club=self.club, member=carol, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
|
|
dave = Member.objects.create(first_name="Dave", last_name="Dogwood")
|
|
event = self.make_event(club_wide=True)
|
|
event.invited_members.set([dave])
|
|
event.excluded_members.set([carol])
|
|
|
|
self.assertEqual(self.attendee_ids(event), {dave.id})
|
|
|
|
# --- onboarding-requirement gating (club.services.onboarding.blocked_member_ids_for_event) ---
|
|
def make_membership(self, member, **kwargs):
|
|
kwargs.setdefault("status", ClubMembership.StatusChoices.ACTIVE)
|
|
return ClubMembership.objects.create(club=self.club, member=member, season=self.season, **kwargs)
|
|
|
|
def test_an_open_blocking_requirement_excludes_the_member_for_that_kind(self):
|
|
self.make_membership(self.alice)
|
|
OnboardingRequirement.objects.create(club=self.club, name="Medical certificate", blocked_event_kinds=["game"])
|
|
event = self.make_event(kind=Event.EventKind.GAME)
|
|
event.teams.set([self.team])
|
|
|
|
self.assertEqual(self.attendee_ids(event), {self.bob.id})
|
|
|
|
def test_the_same_open_requirement_does_not_block_an_unlisted_kind(self):
|
|
self.make_membership(self.alice)
|
|
OnboardingRequirement.objects.create(club=self.club, name="Medical certificate", blocked_event_kinds=["game"])
|
|
event = self.make_event(kind=Event.EventKind.TRAINING)
|
|
event.teams.set([self.team])
|
|
|
|
self.assertEqual(self.attendee_ids(event), {self.alice.id, self.bob.id})
|
|
|
|
def test_a_completed_blocking_requirement_stops_excluding_the_member(self):
|
|
membership = self.make_membership(self.alice)
|
|
staff = get_user_model().objects.create_user(email="staff@example.com", password="pw-secret-123")
|
|
requirement = OnboardingRequirement.objects.create(club=self.club, name="Medical certificate", blocked_event_kinds=["game"])
|
|
mark_complete(membership, requirement, user=staff)
|
|
event = self.make_event(kind=Event.EventKind.GAME)
|
|
event.teams.set([self.team])
|
|
|
|
self.assertEqual(self.attendee_ids(event), {self.alice.id, self.bob.id})
|
|
|
|
def test_a_bypassed_blocking_requirement_also_stops_excluding_the_member(self):
|
|
membership = self.make_membership(self.alice)
|
|
staff = get_user_model().objects.create_user(email="staff@example.com", password="pw-secret-123")
|
|
requirement = OnboardingRequirement.objects.create(club=self.club, name="Medical certificate", blocked_event_kinds=["game"])
|
|
mark_bypassed(membership, requirement, user=staff, note="waived")
|
|
event = self.make_event(kind=Event.EventKind.GAME)
|
|
event.teams.set([self.team])
|
|
|
|
self.assertEqual(self.attendee_ids(event), {self.alice.id, self.bob.id})
|
|
|
|
def test_an_explicit_invite_overrides_the_block(self):
|
|
self.make_membership(self.alice)
|
|
OnboardingRequirement.objects.create(club=self.club, name="Medical certificate", blocked_event_kinds=["game"])
|
|
event = self.make_event(kind=Event.EventKind.GAME)
|
|
event.teams.set([self.team])
|
|
event.invited_members.set([self.alice])
|
|
|
|
self.assertEqual(self.attendee_ids(event), {self.alice.id, self.bob.id})
|
|
|
|
def test_a_member_with_no_club_membership_at_all_is_unaffected_either_way(self):
|
|
# Alice/Bob have a TeamMembership but no ClubMembership in the base fixture --
|
|
# blocked_member_ids_for_event has nothing to look up for them, and they were
|
|
# never excluded by it to begin with (this is really a "doesn't crash" check).
|
|
OnboardingRequirement.objects.create(club=self.club, name="Medical certificate", blocked_event_kinds=["game"])
|
|
event = self.make_event(kind=Event.EventKind.GAME)
|
|
event.teams.set([self.team])
|
|
|
|
self.assertEqual(self.attendee_ids(event), {self.alice.id, self.bob.id})
|
|
|
|
|
|
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 LineupServiceTests(EventsTestBase):
|
|
"""events.services.lineup -- place_member/clear_slot/publish_lineup, the
|
|
write path behind coach mode's C3 screen (mobile/coach_views.py)."""
|
|
|
|
def make_game_with_lineup(self, **event_kwargs):
|
|
event_kwargs.setdefault("kind", Event.EventKind.GAME)
|
|
event = self.make_event(**event_kwargs)
|
|
event.teams.add(self.team)
|
|
lineup = Lineup.objects.create(event=event, team=self.team)
|
|
unit = LineupUnit.objects.create(lineup=lineup, label="Line 1")
|
|
slot = LineupSlot.objects.create(unit=unit)
|
|
return event, lineup, unit, slot
|
|
|
|
def test_place_member_fills_the_slot(self):
|
|
_event, lineup, _unit, slot = self.make_game_with_lineup()
|
|
|
|
place_member(lineup, slot, self.alice)
|
|
|
|
slot.refresh_from_db()
|
|
self.assertEqual(slot.member, self.alice)
|
|
|
|
def test_place_member_vacates_the_members_other_slot_in_the_same_lineup(self):
|
|
_event, lineup, unit, slot = self.make_game_with_lineup()
|
|
other_slot = LineupSlot.objects.create(unit=unit, ordering=1, member=self.alice)
|
|
|
|
place_member(lineup, slot, self.alice)
|
|
|
|
other_slot.refresh_from_db()
|
|
self.assertIsNone(other_slot.member)
|
|
slot.refresh_from_db()
|
|
self.assertEqual(slot.member, self.alice)
|
|
|
|
def test_place_member_bumps_whoever_was_already_in_the_slot(self):
|
|
_event, lineup, _unit, slot = self.make_game_with_lineup()
|
|
slot.member = self.bob
|
|
slot.save()
|
|
|
|
place_member(lineup, slot, self.alice)
|
|
|
|
slot.refresh_from_db()
|
|
self.assertEqual(slot.member, self.alice)
|
|
# Bumped, not swapped -- Bob doesn't land in any other slot.
|
|
self.assertFalse(LineupSlot.objects.filter(unit__lineup=lineup, member=self.bob).exists())
|
|
|
|
def test_clear_slot_empties_it(self):
|
|
_event, _lineup, _unit, slot = self.make_game_with_lineup()
|
|
slot.member = self.alice
|
|
slot.save()
|
|
|
|
clear_slot(slot)
|
|
|
|
slot.refresh_from_db()
|
|
self.assertIsNone(slot.member)
|
|
|
|
def test_publish_sets_published_at(self):
|
|
_event, lineup, _unit, _slot = self.make_game_with_lineup()
|
|
|
|
publish_lineup(lineup)
|
|
|
|
lineup.refresh_from_db()
|
|
self.assertIsNotNone(lineup.published_at)
|
|
|
|
def test_publish_selects_slotted_members_and_not_selects_the_rest(self):
|
|
event, lineup, _unit, slot = self.make_game_with_lineup()
|
|
slot.member = self.alice
|
|
slot.save()
|
|
Attendance.objects.update_or_create(event=event, member=self.alice, defaults={"status": Attendance.AttendanceStatus.PRESENT})
|
|
Attendance.objects.update_or_create(event=event, member=self.bob, defaults={"status": Attendance.AttendanceStatus.PRESENT})
|
|
|
|
publish_lineup(lineup)
|
|
|
|
self.assertEqual(Attendance.objects.get(event=event, member=self.alice).status, Attendance.AttendanceStatus.SELECTED)
|
|
self.assertEqual(Attendance.objects.get(event=event, member=self.bob).status, Attendance.AttendanceStatus.NOT_SELECTED)
|
|
|
|
def test_publish_leaves_unavailable_members_untouched(self):
|
|
event, lineup, _unit, _slot = self.make_game_with_lineup()
|
|
Attendance.objects.update_or_create(event=event, member=self.bob, defaults={"status": Attendance.AttendanceStatus.ABSENT})
|
|
|
|
publish_lineup(lineup)
|
|
|
|
self.assertEqual(Attendance.objects.get(event=event, member=self.bob).status, Attendance.AttendanceStatus.ABSENT)
|
|
|
|
def test_publish_notifies_only_selected_members(self):
|
|
event, lineup, _unit, slot = self.make_game_with_lineup()
|
|
slot.member = self.alice
|
|
slot.save()
|
|
Attendance.objects.update_or_create(event=event, member=self.alice, defaults={"status": Attendance.AttendanceStatus.PRESENT})
|
|
Attendance.objects.update_or_create(event=event, member=self.bob, defaults={"status": Attendance.AttendanceStatus.PRESENT})
|
|
|
|
publish_lineup(lineup)
|
|
|
|
notified_member_ids = set(Notification.objects.filter(member__in=[self.alice, self.bob]).values_list("member_id", flat=True))
|
|
self.assertEqual(notified_member_ids, {self.alice.pk})
|
|
|
|
|
|
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_adding_a_group_member_syncs_future_events(self):
|
|
group = Group.objects.create(club=self.club, name="Committee")
|
|
event = self.make_event()
|
|
event.groups.set([group])
|
|
carol = Member.objects.create(first_name="Carol", last_name="Cedar")
|
|
|
|
GroupMembership.objects.create(group=group, member=carol)
|
|
|
|
self.assertIn(carol.id, self.attendee_ids(event))
|
|
|
|
def test_removing_a_group_member_syncs_future_events(self):
|
|
group = Group.objects.create(club=self.club, name="Committee")
|
|
carol = Member.objects.create(first_name="Carol", last_name="Cedar")
|
|
membership = GroupMembership.objects.create(group=group, member=carol)
|
|
event = self.make_event()
|
|
event.groups.set([group])
|
|
|
|
membership.delete()
|
|
|
|
self.assertNotIn(carol.id, self.attendee_ids(event))
|
|
|
|
def test_a_new_active_club_membership_syncs_future_club_wide_events(self):
|
|
event = self.make_event(club_wide=True)
|
|
carol = Member.objects.create(first_name="Carol", last_name="Cedar")
|
|
|
|
ClubMembership.objects.create(club=self.club, member=carol, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
|
|
|
|
self.assertIn(carol.id, self.attendee_ids(event))
|
|
|
|
def test_a_club_membership_lapsing_syncs_future_club_wide_events(self):
|
|
carol = Member.objects.create(first_name="Carol", last_name="Cedar")
|
|
membership = ClubMembership.objects.create(club=self.club, member=carol, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
|
|
event = self.make_event(club_wide=True)
|
|
|
|
membership.status = ClubMembership.StatusChoices.LAPSED
|
|
membership.save()
|
|
|
|
self.assertNotIn(carol.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):
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
super().setUpTestData()
|
|
cls.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):
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
super().setUpTestData()
|
|
cls.other = Club.objects.create(name="Rival FC", slug="rival-fc")
|
|
today = timezone.localdate()
|
|
cls.other_season = Season.objects.create(club=cls.other, start_date=today - timedelta(days=30), end_date=today + timedelta(days=300))
|
|
cls.other_location = Location.objects.create(club=cls.other, name="Arena", address="1 St", city="Town", zip_code="1000", country="BE")
|
|
cls.other_opponent = Opponent.objects.create(club=cls.other, name="Rivals")
|
|
cls.other_team = Team.objects.create(club=cls.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):
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
super().setUpTestData()
|
|
cls.home_location = Location.objects.create(club=cls.club, name="Home Arena", address="1 St", city="Antwerp", zip_code="1000", country="BE", is_home=True)
|
|
cls.away_location = Location.objects.create(club=cls.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."""
|
|
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
super().setUpTestData()
|
|
cls.home_ground = Location.objects.create(club=cls.club, name="Home Ground", address="1 St", city="Town", zip_code="1000", country="BE", is_home=True)
|
|
cls.away_ground = Location.objects.create(club=cls.club, name="Away Ground", address="2 St", city="Town", zip_code="1000", country="BE")
|
|
|
|
cls.level = RefereeLevel.objects.create(club=cls.club, name="Regional")
|
|
cls.level.teams.add(cls.team)
|
|
|
|
cls.referee = Member.objects.create(first_name="Ref", last_name="Eree")
|
|
cls.referee_profile = RefereeProfile.objects.create(member=cls.referee, level=cls.level, valid_until=timezone.localdate() + timedelta(days=30))
|
|
|
|
def make_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_includes_a_higher_level_via_inheritance(self):
|
|
# self.level ("Regional") qualifies for self.team; a referee holding a
|
|
# higher level that inherits from Regional (without self.team added
|
|
# directly) must still show up -- see RefereeLevel.eligible_team_ids.
|
|
national = RefereeLevel.objects.create(club=self.club, name="National", inherits_from=self.level)
|
|
national_referee = Member.objects.create(first_name="Nat", last_name="Ional")
|
|
self.make_eligible_profile(national_referee, level=national)
|
|
|
|
game = self.make_home_game()
|
|
|
|
self.assertIn(national_referee, set(eligible_referees(game)))
|
|
|
|
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"))
|
|
|
|
|
|
class CalendarGridTests(EventsTestBase):
|
|
"""events.services.calendar -- date-range math and grid layout behind the
|
|
Events page's Week/Month/Season views."""
|
|
|
|
def at(self, day, hour, minute=0):
|
|
return timezone.make_aware(datetime.combine(day, time(hour, minute)))
|
|
|
|
def test_week_bounds_returns_monday_to_sunday(self):
|
|
start, end = week_bounds(date(2026, 8, 19)) # a Wednesday
|
|
|
|
self.assertEqual(start, date(2026, 8, 17))
|
|
self.assertEqual(end, date(2026, 8, 23))
|
|
|
|
def test_month_bounds_returns_first_and_last_day(self):
|
|
start, end = month_bounds(date(2026, 2, 10))
|
|
|
|
self.assertEqual(start, date(2026, 2, 1))
|
|
self.assertEqual(end, date(2026, 2, 28))
|
|
|
|
def test_add_months_rolls_over_the_year(self):
|
|
self.assertEqual(add_months(date(2026, 11, 15), 2), date(2027, 1, 1))
|
|
|
|
def test_week_grid_always_covers_the_full_day(self):
|
|
monday = date(2026, 8, 17)
|
|
event = self.make_event(start=self.at(monday, 10), end=self.at(monday, 11, 30))
|
|
|
|
grid = week_grid([event], monday)
|
|
|
|
self.assertEqual(grid["day_start_hour"], 0)
|
|
self.assertEqual(grid["day_end_hour"], 24)
|
|
self.assertEqual(grid["hours"], list(range(0, 25)))
|
|
span = 24
|
|
block = grid["days"][0]["blocks"][0]
|
|
self.assertAlmostEqual(block["top_pct"], 100 * 10 / span, places=2)
|
|
self.assertAlmostEqual(block["height_pct"], 100 * 1.5 / span, places=2)
|
|
|
|
def test_week_grid_still_covers_an_event_starting_at_midnight_or_ending_near_it(self):
|
|
monday = date(2026, 8, 17)
|
|
event = self.make_event(start=self.at(monday, 0), end=self.at(monday, 23, 45))
|
|
|
|
grid = week_grid([event], monday)
|
|
|
|
self.assertEqual(grid["day_start_hour"], 0)
|
|
self.assertEqual(grid["day_end_hour"], 24)
|
|
|
|
def test_week_grid_excludes_events_outside_the_week(self):
|
|
monday = date(2026, 8, 17)
|
|
event = self.make_event(start=self.at(monday + timedelta(days=7), 10))
|
|
|
|
grid = week_grid([event], monday)
|
|
|
|
self.assertFalse(any(day["blocks"] for day in grid["days"]))
|
|
|
|
def test_week_grid_splits_overlapping_events_into_columns(self):
|
|
monday = date(2026, 8, 17)
|
|
first = self.make_event(title="Training A", start=self.at(monday, 10), end=self.at(monday, 11))
|
|
second = self.make_event(title="Training B", start=self.at(monday, 10, 30), end=self.at(monday, 11, 30))
|
|
|
|
grid = week_grid([first, second], monday)
|
|
|
|
blocks = grid["days"][0]["blocks"]
|
|
self.assertEqual(len(blocks), 2)
|
|
self.assertEqual({block["width_pct"] for block in blocks}, {50.0})
|
|
self.assertEqual({block["left_pct"] for block in blocks}, {0.0, 50.0})
|
|
|
|
def test_week_grid_gives_non_overlapping_events_full_width(self):
|
|
monday = date(2026, 8, 17)
|
|
first = self.make_event(title="Morning", start=self.at(monday, 9), end=self.at(monday, 10))
|
|
second = self.make_event(title="Evening", start=self.at(monday, 18), end=self.at(monday, 19))
|
|
|
|
grid = week_grid([first, second], monday)
|
|
|
|
blocks = grid["days"][0]["blocks"]
|
|
self.assertTrue(all(block["width_pct"] == 100.0 for block in blocks))
|
|
|
|
def test_month_grid_always_spans_full_weeks_of_seven_days(self):
|
|
grid = month_grid([], date(2026, 2, 1)) # Feb 2026 starts on a Sunday
|
|
|
|
self.assertTrue(all(len(week) == 7 for week in grid["weeks"]))
|
|
self.assertEqual(grid["weeks"][0][0]["date"].weekday(), 0) # Monday
|
|
|
|
def test_month_grid_flags_days_outside_the_month(self):
|
|
grid = month_grid([], date(2026, 2, 1))
|
|
|
|
self.assertFalse(grid["weeks"][0][0]["in_month"]) # January spillover
|
|
in_month_cell = next(cell for week in grid["weeks"] for cell in week if cell["date"] == date(2026, 2, 1))
|
|
self.assertTrue(in_month_cell["in_month"])
|
|
|
|
def test_month_grid_buckets_events_under_their_start_day(self):
|
|
event = self.make_event(start=self.at(date(2026, 2, 12), 14))
|
|
|
|
grid = month_grid([event], date(2026, 2, 1))
|
|
|
|
cell = next(cell for week in grid["weeks"] for cell in week if cell["date"] == date(2026, 2, 12))
|
|
self.assertEqual(cell["events"], [event])
|
|
|
|
def test_season_grid_spans_every_month_of_the_season(self):
|
|
season = Season.objects.create(club=self.club, start_date=date(2026, 8, 1), end_date=date(2027, 7, 31))
|
|
|
|
months = season_grid([], season)
|
|
|
|
self.assertEqual(len(months), 12)
|
|
self.assertEqual(months[0]["label"], date(2026, 8, 1))
|
|
self.assertEqual(months[-1]["label"], date(2027, 7, 1))
|
|
|
|
def test_season_grid_cells_carry_a_count_not_the_event_list(self):
|
|
season = Season.objects.create(club=self.club, start_date=date(2026, 8, 1), end_date=date(2027, 7, 31))
|
|
event = self.make_event(start=self.at(date(2026, 9, 5), 10))
|
|
|
|
months = season_grid([event], season)
|
|
|
|
september = next(month for month in months if month["label"] == date(2026, 9, 1))
|
|
cell = next(cell for week in september["weeks"] for cell in week if cell["date"] == date(2026, 9, 5))
|
|
self.assertEqual(cell["count"], 1)
|
|
self.assertNotIn("events", cell)
|
|
|
|
|
|
class SendDeadlineRemindersTests(EventsTestBase):
|
|
"""events.tasks.send_deadline_reminders -- one reminder push per event,
|
|
a week before whichever cutoff matters (its own deadline, or its start
|
|
when no deadline is set), to whoever's still NO_RESPONSE. This is the
|
|
periodic sweep that also catches a recurring series' occurrences, which
|
|
never go through notify_new_event's on-creation path at all."""
|
|
|
|
def setUp(self):
|
|
# Maintenance.save() writes through to the cache (see that model's own
|
|
# docstring) -- a DB rollback between tests doesn't clear it, so the
|
|
# maintenance-mode test below would otherwise leak into every test that
|
|
# happens to run after it.
|
|
cache.clear()
|
|
self.addCleanup(cache.clear)
|
|
|
|
def make_event_with_roster(self, **kwargs):
|
|
event = self.make_event(**kwargs)
|
|
event.teams.add(self.team)
|
|
return event
|
|
|
|
def test_reminds_within_the_window_before_the_deadline(self):
|
|
event = self.make_event_with_roster(start=timezone.now() + timedelta(days=10), deadline=timezone.now() + timedelta(days=6))
|
|
|
|
result = send_deadline_reminders()
|
|
|
|
self.assertTrue(Notification.objects.filter(member=self.alice, title=event.title).exists())
|
|
self.assertTrue(Notification.objects.filter(member=self.bob, title=event.title).exists())
|
|
self.assertIn("Reminded 2 member(s) across 1 event(s)", result)
|
|
|
|
def test_falls_back_to_the_event_start_when_no_deadline_is_set(self):
|
|
event = self.make_event_with_roster(start=timezone.now() + timedelta(days=6), deadline=None)
|
|
|
|
send_deadline_reminders()
|
|
|
|
self.assertTrue(Notification.objects.filter(member=self.alice, title=event.title).exists())
|
|
|
|
def test_does_not_remind_before_the_window_opens(self):
|
|
self.make_event_with_roster(start=timezone.now() + timedelta(days=30), deadline=timezone.now() + timedelta(days=20))
|
|
|
|
send_deadline_reminders()
|
|
|
|
self.assertFalse(Notification.objects.exists())
|
|
|
|
def test_does_not_remind_after_the_deadline_has_passed(self):
|
|
# Deadline already gone, but the event itself hasn't started yet -- the
|
|
# window is closed, not "still open and overdue".
|
|
self.make_event_with_roster(start=timezone.now() + timedelta(days=2), deadline=timezone.now() - timedelta(hours=1))
|
|
|
|
send_deadline_reminders()
|
|
|
|
self.assertFalse(Notification.objects.exists())
|
|
|
|
def test_only_notifies_members_who_have_not_answered(self):
|
|
event = self.make_event_with_roster(start=timezone.now() + timedelta(days=10), deadline=timezone.now() + timedelta(days=6))
|
|
Attendance.objects.filter(event=event, member=self.alice).update(status=Attendance.AttendanceStatus.PRESENT)
|
|
|
|
send_deadline_reminders()
|
|
|
|
self.assertFalse(Notification.objects.filter(member=self.alice).exists())
|
|
self.assertTrue(Notification.objects.filter(member=self.bob).exists())
|
|
|
|
def test_does_not_notify_the_same_event_twice(self):
|
|
self.make_event_with_roster(start=timezone.now() + timedelta(days=10), deadline=timezone.now() + timedelta(days=6))
|
|
|
|
send_deadline_reminders()
|
|
Notification.objects.all().delete()
|
|
send_deadline_reminders()
|
|
|
|
self.assertFalse(Notification.objects.exists())
|
|
|
|
def test_marks_the_event_processed_even_when_nobody_needed_notifying(self):
|
|
event = self.make_event_with_roster(start=timezone.now() + timedelta(days=10), deadline=timezone.now() + timedelta(days=6))
|
|
Attendance.objects.filter(event=event).update(status=Attendance.AttendanceStatus.PRESENT)
|
|
|
|
send_deadline_reminders()
|
|
|
|
event.refresh_from_db()
|
|
self.assertIsNotNone(event.deadline_reminder_sent_at)
|
|
|
|
def test_skips_a_cancelled_event(self):
|
|
self.make_event_with_roster(start=timezone.now() + timedelta(days=10), deadline=timezone.now() + timedelta(days=6), cancelled=True)
|
|
|
|
send_deadline_reminders()
|
|
|
|
self.assertFalse(Notification.objects.exists())
|
|
|
|
def test_raises_during_maintenance_instead_of_silently_skipping(self):
|
|
Maintenance.start(user=None)
|
|
|
|
with self.assertRaises(RuntimeError):
|
|
send_deadline_reminders()
|