from datetime import date, datetime, time, timedelta from decimal import Decimal from io import StringIO from types import SimpleNamespace 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 JobToggle, Maintenance from members.models import Group, GroupMembership, Member from notifications.models import Notification from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership from .admin import EventAdminForm from .models import Attendance, Competition, Event, EventReferee, EventSeries, Lineup, LineupSelection, Location, Opponent, RefereeSignup from .services import ( blocked_upcoming_events_for_member, 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.attendance import member_attendance_counts from .services.calendar import add_months, agenda_groups, month_bounds, month_grid, season_grid, week_bounds, week_grid from .services.lineup import cancel_scheduled_publish, notify_dropout, publish_lineup, schedule_lineup_publish, selected_members_by_position, toggle_selection from .services.rbihf_import import RBIHFImportError, apply_plan, build_plan, extract_team_id, parse_fixtures, suggested_location, suggested_opponent from .services.referees import ( RefereeAssignmentError, accept_referee_signup, add_external_referee, assign_referee, conflicting_events, decline_referee_signup, eligible_referees, needs_referee_management, remove_referee, set_referee_fee, sync_referee_invites, ) from .tasks import publish_scheduled_lineups, 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}) # --- blocked_upcoming_events_for_member (the read-side "why can't I sign up" mirror) --- def test_blocked_event_is_reported_with_its_blocking_requirement(self): self.make_membership(self.alice) requirement = 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]) results = blocked_upcoming_events_for_member(self.alice, self.club) self.assertEqual(len(results), 1) blocked_event, requirements = results[0] self.assertEqual(blocked_event, event) self.assertEqual(requirements, [requirement]) def test_an_unblocked_member_reports_nothing(self): # Bob has no ClubMembership row at all in the base fixture -- nothing # for open_requirements_blocking to find, same as blocked_member_ids_ # for_event's own "no club membership" case above. 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(blocked_upcoming_events_for_member(self.bob, self.club), []) def test_a_kind_the_requirement_does_not_block_reports_nothing(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(blocked_upcoming_events_for_member(self.alice, self.club), []) def test_a_resolved_requirement_reports_nothing(self): membership = self.make_membership(self.alice) staff = get_user_model().objects.create_user(email="staff2@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(blocked_upcoming_events_for_member(self.alice, self.club), []) def test_an_explicit_invite_is_not_reported_as_blocked(self): # invited_members bypasses the block entirely -- Alice already has a # normal Attendance row for this event, so there's nothing to explain. 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(blocked_upcoming_events_for_member(self.alice, self.club), []) def test_a_past_blocked_event_is_not_reported(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, start=timezone.now() - timedelta(days=1)) event.teams.set([self.team]) self.assertEqual(blocked_upcoming_events_for_member(self.alice, self.club), []) 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 -- toggle_selection/publish_lineup/ selected_members_by_position, the write+read path behind coach mode's C3 screen (mobile/coach_views.py) and the published member-side view (mobile/views.py's EventDetailView).""" 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) return event, lineup def test_toggle_selection_selects_an_unselected_member(self): _event, lineup = self.make_game_with_lineup() now_selected = toggle_selection(lineup, self.alice) self.assertTrue(now_selected) self.assertTrue(LineupSelection.objects.filter(lineup=lineup, member=self.alice).exists()) def test_toggle_selection_deselects_a_selected_member(self): _event, lineup = self.make_game_with_lineup() LineupSelection.objects.create(lineup=lineup, member=self.alice) now_selected = toggle_selection(lineup, self.alice) self.assertFalse(now_selected) self.assertFalse(LineupSelection.objects.filter(lineup=lineup, member=self.alice).exists()) def test_publish_sets_published_at(self): _event, lineup = self.make_game_with_lineup() publish_lineup(lineup) lineup.refresh_from_db() self.assertIsNotNone(lineup.published_at) def test_publish_clears_a_pending_schedule(self): _event, lineup = self.make_game_with_lineup() lineup.scheduled_publish_at = timezone.now() + timedelta(days=1) lineup.save() publish_lineup(lineup) lineup.refresh_from_db() self.assertIsNone(lineup.scheduled_publish_at) def test_schedule_lineup_publish_sets_the_time(self): _event, lineup = self.make_game_with_lineup() when = timezone.now() + timedelta(days=1) schedule_lineup_publish(lineup, when) lineup.refresh_from_db() self.assertEqual(lineup.scheduled_publish_at, when) self.assertIsNone(lineup.published_at) def test_cancel_scheduled_publish_clears_the_time(self): _event, lineup = self.make_game_with_lineup() lineup.scheduled_publish_at = timezone.now() + timedelta(days=1) lineup.save() cancel_scheduled_publish(lineup) lineup.refresh_from_db() self.assertIsNone(lineup.scheduled_publish_at) def test_publish_selects_picked_members_and_not_selects_the_rest(self): event, lineup = self.make_game_with_lineup() LineupSelection.objects.create(lineup=lineup, member=self.alice) 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 = 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_selected_members_by_position_groups_by_the_teams_roster_position(self): _event, lineup = self.make_game_with_lineup() winger = Position.objects.create(club=self.club, name="Winger", short_name="W", ordering=1) defense = Position.objects.create(club=self.club, name="Defense", short_name="D", ordering=2) TeamMembership.objects.filter(team=self.team, member=self.alice).update(position=winger) TeamMembership.objects.filter(team=self.team, member=self.bob).update(position=defense) LineupSelection.objects.create(lineup=lineup, member=self.alice) LineupSelection.objects.create(lineup=lineup, member=self.bob) categories = selected_members_by_position(lineup) self.assertEqual([c["label"] for c in categories], ["Winger", "Defense"]) self.assertEqual(categories[0]["members"], [self.alice]) self.assertEqual(categories[1]["members"], [self.bob]) def test_selected_members_by_position_falls_back_to_no_position_bucket(self): _event, lineup = self.make_game_with_lineup() guest = Member.objects.create(first_name="Gia", last_name="Guest") LineupSelection.objects.create(lineup=lineup, member=guest) categories = selected_members_by_position(lineup) self.assertEqual(len(categories), 1) self.assertEqual(categories[0]["label"], "No position set") self.assertEqual(categories[0]["members"], [guest]) def test_publish_notifies_only_selected_members(self): event, lineup = self.make_game_with_lineup() LineupSelection.objects.create(lineup=lineup, member=self.alice) 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}) def test_republish_does_not_renotify_a_player_still_selected(self): event, lineup = self.make_game_with_lineup() LineupSelection.objects.create(lineup=lineup, member=self.alice) Attendance.objects.update_or_create(event=event, member=self.alice, defaults={"status": Attendance.AttendanceStatus.PRESENT}) publish_lineup(lineup) Notification.objects.filter(member=self.alice).delete() publish_lineup(lineup) self.assertFalse(Notification.objects.filter(member=self.alice).exists()) def test_republish_notifies_a_newly_added_player(self): event, lineup = self.make_game_with_lineup() Attendance.objects.update_or_create(event=event, member=self.alice, defaults={"status": Attendance.AttendanceStatus.PRESENT}) publish_lineup(lineup) Notification.objects.filter(member=self.alice).delete() LineupSelection.objects.create(lineup=lineup, member=self.alice) publish_lineup(lineup) notification = Notification.objects.get(member=self.alice) self.assertIn("in the line-up", notification.body) def test_republish_notifies_a_dropped_player_with_a_different_message(self): event, lineup = self.make_game_with_lineup() LineupSelection.objects.create(lineup=lineup, member=self.alice) Attendance.objects.update_or_create(event=event, member=self.alice, defaults={"status": Attendance.AttendanceStatus.PRESENT}) publish_lineup(lineup) Notification.objects.filter(member=self.alice).delete() LineupSelection.objects.filter(lineup=lineup, member=self.alice).delete() publish_lineup(lineup) notification = Notification.objects.get(member=self.alice) self.assertIn("not in it this time", notification.body) self.assertEqual(Attendance.objects.get(event=event, member=self.alice).status, Attendance.AttendanceStatus.NOT_SELECTED) def test_notify_dropout_notifies_the_teams_managers(self): event, _lineup = self.make_game_with_lineup() manager = Member.objects.create(first_name="Cara", last_name="Coach") management_position = Position.objects.create(club=self.club, name="Head coach", short_name="HC", staff_position=True, management_position=True) StaffAssignment.objects.create(team=self.team, member=manager, season=self.season, position=management_position) notify_dropout(event, self.alice, "Twisted an ankle") notification = Notification.objects.get(member=manager) self.assertIn(self.alice.get_full_name(), notification.body) self.assertIn("Twisted an ankle", notification.body) def test_notify_dropout_does_not_notify_non_management_staff(self): event, _lineup = self.make_game_with_lineup() physio = Member.objects.create(first_name="Pat", last_name="Physio") physio_position = Position.objects.create(club=self.club, name="Physio", short_name="PH", staff_position=True, management_position=False) StaffAssignment.objects.create(team=self.team, member=physio, season=self.season, position=physio_position) notify_dropout(event, self.alice, "Twisted an ankle") self.assertFalse(Notification.objects.filter(member=physio).exists()) 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()) def test_joining_a_team_notifies_the_member_of_new_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) notification = Notification.objects.get(member=dave) self.assertEqual(notification.title, "New events on your calendar") self.assertIn("1 new event", notification.body) def test_joining_a_team_with_several_upcoming_events_sends_one_summary_notification(self): first = self.make_event(title="First") first.teams.set([self.team]) second = self.make_event(title="Second", start=self.future + timedelta(days=1)) second.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.assertEqual(Notification.objects.filter(member=dave).count(), 1) notification = Notification.objects.get(member=dave) self.assertIn("2 new events", notification.body) def test_joining_a_team_with_no_upcoming_events_does_not_notify(self): 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.assertFalse(Notification.objects.filter(member=dave).exists()) def test_leaving_a_team_does_not_notify(self): event = self.make_event() event.teams.set([self.team]) TeamMembership.objects.get(team=self.team, member=self.alice).delete() self.assertFalse(Notification.objects.filter(member=self.alice).exists()) def test_editing_a_membership_field_does_not_notify(self): event = self.make_event() event.teams.set([self.team]) membership = TeamMembership.objects.get(team=self.team, member=self.alice) membership.jersey_number = 42 membership.save() self.assertFalse(Notification.objects.filter(member=self.alice).exists()) def test_joining_a_group_notifies_the_member_of_new_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) notification = Notification.objects.get(member=carol) self.assertIn("1 new event", notification.body) 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)), []) def test_member_attendance_counts_no_shows_matches_team_no_shows_definition(self): event = self.make_past_training(1) attendance = self.set_status(event, self.alice, Attendance.AttendanceStatus.PRESENT) record_check_in(attendance, showed_up=False) counts = member_attendance_counts(self.alice, self.season) self.assertEqual(counts["no_shows"], 1) # Still counted as present -- present/absent read the RSVP only, not showed_up. self.assertEqual(counts["present"], 1) def test_member_attendance_counts_no_shows_ignores_an_unchecked_present_rsvp(self): event = self.make_past_training(1) self.set_status(event, self.alice, Attendance.AttendanceStatus.PRESENT) counts = member_attendance_counts(self.alice, self.season) self.assertEqual(counts["no_shows"], 0) def test_member_attendance_counts_no_shows_ignores_a_confirmed_check_in(self): event = self.make_past_training(1) attendance = self.set_status(event, self.alice, Attendance.AttendanceStatus.PRESENT) record_check_in(attendance, showed_up=True) counts = member_attendance_counts(self.alice, self.season) self.assertEqual(counts["no_shows"], 0) 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/) -- 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'{r["game_id"]}' f'{r["date"]}{r["hour"]}{r["venue"]}' f'{r["home_name"]}' f'{r["visit_name"]}' for r in rows ) return f"""

{RBIHF_TEAM_NAME}

Upcoming games

{row_html}
#DateHourLocationHomeVisit
""" 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("

Some Team

", 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