import datetime import os import sys from decimal import Decimal from io import BytesIO from unittest import mock import openpyxl from allauth.mfa.models import Authenticator from django.contrib.auth import get_user_model from django.core import mail from django.core.cache import cache from django.core.files.uploadedfile import SimpleUploadedFile from django.template.loader import render_to_string from django.test import TestCase, override_settings from django.urls import NoReverseMatch, reverse from django.utils import timezone from waffle import get_waffle_flag_model from billing.models import Plan, PlanPrice from billing.services.dues import record_payment, subscribe from club.models import Club, ClubMembership, ClubRole, DuesInvoice, FeePayment, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor from club.services.invoicing import DuesInvoicePDFError from club.services.onboarding import mark_complete from events.models import Attendance, Competition, Event, EventReferee, EventSeries, Location, Opponent from events.services.rbihf_import import RBIHFImportError from events.services.recurrence import detach_occurrence, generate_occurrences from management.bulk_import import TEMPLATE_COLUMNS from management.email_previews import EMAIL_PREVIEWS from management.pdf import PDFExportError, _tint_with_white, referee_form_colors, render_pdf from management.pdf_previews import PDF_PREVIEWS from management.recurrence_ui import build_rrule, describe_rrule, parse_rrule from members.models import Family, FamilyMembership, Group, GroupMembership, Member, ParentClaim from members.services.claims import children_awaiting_a_parent from news.models import News, NewsPhoto from shop.models import Order from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership, TeamPhoto from teams.services import eligible_roster_members User = get_user_model() XLSX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" def make_import_workbook(rows): """An in-memory .xlsx upload -- header matching the real template, plus whatever data rows a test wants to exercise.""" workbook = openpyxl.Workbook() sheet = workbook.active sheet.append(TEMPLATE_COLUMNS) for row in rows: sheet.append(row) buffer = BytesIO() workbook.save(buffer) buffer.seek(0) return SimpleUploadedFile("members.xlsx", buffer.read(), content_type=XLSX_CONTENT_TYPE) def enrol_mfa(user): return Authenticator.objects.create(user=user, type=Authenticator.Type.TOTP, data={"secret": "JBSWY3DPEHPK3PXP"}) def make_season(club): # Must genuinely cover *today*: current_season()/teams_staffed_by() key off # Season.covering(club, timezone.localdate()), not just any season row. today = timezone.localdate() return Season.objects.create(club=club, start_date=today - datetime.timedelta(days=30), end_date=today + datetime.timedelta(days=300)) @override_settings( ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "rival-fc.rosterchief.app", "testserver"], ) class ManagementTestBase(TestCase): # setUpTestData, not setUp: the club/season/admin fixture is read-only for # almost every test, so building it once per class (inside the class-wide # transaction Django rolls back) instead of once per test saves the whole # suite a lot of inserts and password hashes. Django hands each test its own # deepcopy of these attributes, so a test that mutates one stays isolated. @classmethod def setUpTestData(cls): cls.club = Club.objects.create(name="Ajax United", slug="ajax-united") cls.season = make_season(cls.club) cls.admin_user = User.objects.create_user(email="admin@example.com", password="pw-secret-123") cls.admin_member = Member.objects.create(user=cls.admin_user, first_name="Ada", last_name="Admin") ClubMembership.objects.create(club=cls.club, member=cls.admin_member, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE) ClubRole.objects.filter(club=cls.club, member=cls.admin_member).update(role=ClubRole.Roles.ADMIN) enrol_mfa(cls.admin_user) def club_get(self, name, *args, params=None): return self.client.get(reverse(f"management:{name}", args=args), data=params, HTTP_HOST="ajax-united.rosterchief.app") def club_post(self, name, data, *args): return self.client.post(reverse(f"management:{name}", args=args), data, HTTP_HOST="ajax-united.rosterchief.app") class AccessTests(ManagementTestBase): def test_anonymous_is_sent_to_login(self): response = self.club_get("home") self.assertEqual(response.status_code, 302) self.assertIn(reverse("account_login"), response.url) def test_a_club_member_with_no_role_or_staff_assignment_gets_403(self): plain_user = User.objects.create_user(email="plain@example.com", password="pw-secret-123") self.client.force_login(plain_user) self.assertEqual(self.club_get("home").status_code, 403) def test_a_plain_active_club_member_gets_403(self): # An active ClubMembership auto-grants a MEMBER ClubRole (club/signals.py) -- # every signed-up player has one. That alone must not be enough to get in, or # players would reach the staff UI they're explicitly excluded from. player_user = User.objects.create_user(email="player@example.com", password="pw-secret-123") player_member = Member.objects.create(user=player_user, first_name="Paul", last_name="Player") ClubMembership.objects.create(club=self.club, member=player_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) self.client.force_login(player_user) self.assertEqual(self.club_get("home").status_code, 403) def test_a_club_admin_can_reach_it(self): self.client.force_login(self.admin_user) self.assertEqual(self.club_get("home").status_code, 200) def test_it_does_not_exist_on_the_base_domain(self): # The management UI manages one club; the mirror image of controlpanel # refusing to exist on a club subdomain. self.client.force_login(self.admin_user) response = self.client.get(reverse("management:home"), HTTP_HOST="rosterchief.app") self.assertEqual(response.status_code, 404) def test_staff_with_only_a_staff_assignment_can_reach_staff_pages(self): # No ClubRole at all -- authority comes purely from a current-season # StaffAssignment, per club.services.access. coach_user = User.objects.create_user(email="coach@example.com", password="pw-secret-123") coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") team = Team.objects.create(club=self.club, name="U12", short_name="U12") position = Position.objects.create(club=self.club, name="Coach", short_name="C", staff_position=True, management_position=True) StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) self.client.force_login(coach_user) self.assertEqual(self.club_get("member_list").status_code, 200) def test_staff_without_admin_role_cannot_reach_admin_only_pages(self): coach_user = User.objects.create_user(email="coach2@example.com", password="pw-secret-123") coach_member = Member.objects.create(user=coach_user, first_name="Cody", last_name="Coach") team = Team.objects.create(club=self.club, name="U13", short_name="U13") position = Position.objects.create(club=self.club, name="Coach2", short_name="C2", staff_position=True, management_position=True) StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) self.client.force_login(coach_user) # position_list itself is open to any staff (see TeamAndPositionAccessTests) # -- creating and editing positions stays admin-only. self.assertEqual(self.club_get("position_create").status_code, 403) self.assertEqual(self.club_post("member_create", {"first_name": "X", "last_name": "Y"}).status_code, 403) class NavLinkTests(ManagementTestBase): """The global navbar's "Management" link, next to Django admin -- see management.context_processors.management_link and templates/_base.html.""" def test_a_club_admin_sees_the_link_on_the_club_subdomain(self): self.client.force_login(self.admin_user) self.assertContains(self.club_get("home"), reverse("management:home")) def test_a_plain_active_club_member_does_not_see_the_link(self): player_user = User.objects.create_user(email="player2@example.com", password="pw-secret-123") player_member = Member.objects.create(user=player_user, first_name="Pia", last_name="Player") ClubMembership.objects.create(club=self.club, member=player_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) self.client.login(email="player2@example.com", password="pw-secret-123") # 403 on /manage/ itself, but the link must not appear on pages this user *can* # reach either -- assert against a page outside the gate: the account view. response = self.client.get(reverse("mfa_index"), HTTP_HOST="ajax-united.rosterchief.app") self.assertNotContains(response, reverse("management:home")) def test_the_link_is_absent_on_the_base_domain_even_for_a_club_admin(self): # has_management_access requires a resolved club; the control panel/base domain # has none, so the link -- which points at a single club's management app -- # correctly never appears there regardless of who's signed in. self.client.force_login(self.admin_user) response = self.client.get(reverse("mfa_index"), HTTP_HOST="rosterchief.app") self.assertNotContains(response, reverse("management:home")) class ActiveNavHighlightTests(ManagementTestBase): """The sidebar/mobile nav highlights whichever section the current page belongs to -- see management.context_processors.active_nav_section.""" def setUp(self): super().setUp() self.client.force_login(self.admin_user) def test_the_members_list_page_highlights_members(self): response = self.club_get("member_list") self.assertContains(response, f'class="nav-item active" href="{reverse("management:member_list")}"') self.assertNotContains(response, f'class="nav-item active" href="{reverse("management:home")}"') def test_a_member_detail_sub_page_still_highlights_members(self): # member_detail has no nav entry of its own -- it belongs to the Members # section, same as member_list, member_update, family_detail, etc. member = Member.objects.create(first_name="Sub", last_name="Page") ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) response = self.club_get("member_detail", member.pk) self.assertContains(response, f'class="nav-item active" href="{reverse("management:member_list")}"') def test_the_dashboard_highlights_dashboard_only(self): response = self.club_get("home") self.assertContains(response, f'class="nav-item active" href="{reverse("management:home")}"') self.assertNotContains(response, f'class="nav-item active" href="{reverse("management:member_list")}"') def test_roles_positions_and_referee_levels_pages_all_highlight_settings(self): # Roles/Positions/Referee levels moved out of Members/Teams and into Settings -- # club-wide setup, not day-to-day people/roster work. for name in ("role_list", "position_list", "referee_level_list"): with self.subTest(name=name): response = self.club_get(name) self.assertContains(response, f'class="nav-item active" href="{reverse("management:club_settings")}"') def test_referee_management_page_highlights_calendar(self): # Referee management is the operational "who's covering this game" page, so it # lives under Calendar with Events/Locations/Opponents, not under Settings. response = self.club_get("referee_management") self.assertContains(response, f'class="nav-item active" href="{reverse("management:event_list")}"') def test_sponsor_list_page_highlights_finance(self): # Sponsors sit under Finance, underneath Dues & billing. response = self.club_get("sponsor_list") self.assertContains(response, f'class="nav-item active" href="{reverse("management:membership_list")}"') class SidebarThemingTests(ManagementTestBase): """The sidebar's own background/foreground follow Club.primary_color (base.html emits --tenant-sidebar-bg/fg; assets/management.css's --color-sidebar-* tokens fall back to the fixed dark-ink look when unset). Both primary_content_color and secondary_content_color are the WCAG-luminance black-or-white choice already computed on Club itself (club.tests.ClubBrandingModelTests covers that math) -- this just checks the value actually reaches the page.""" def setUp(self): self.client.force_login(self.admin_user) def test_a_set_primary_colour_feeds_the_sidebar_background(self): self.club.primary_color = "#0f766e" self.club.save(update_fields=["primary_color"]) response = self.club_get("home") self.assertContains(response, "--tenant-sidebar-bg: #0f766e;") def test_no_primary_colour_means_no_sidebar_override(self): response = self.club_get("home") self.assertNotContains(response, "--tenant-sidebar-bg") def test_a_pale_primary_colour_switches_the_sidebar_text_to_black(self): self.club.primary_color = "#fef08a" self.club.save(update_fields=["primary_color"]) response = self.club_get("home") self.assertContains(response, "--tenant-sidebar-fg: #000000;") def test_a_dark_primary_colour_keeps_the_sidebar_text_white(self): self.club.primary_color = "#1e40af" self.club.save(update_fields=["primary_color"]) response = self.club_get("home") self.assertContains(response, "--tenant-sidebar-fg: #ffffff;") def test_a_set_secondary_colour_also_feeds_the_active_nav_items_text_colour(self): self.club.secondary_color = "#fef08a" self.club.save(update_fields=["secondary_color"]) response = self.club_get("home") self.assertContains(response, "--tenant-club-content: #000000;") def test_no_secondary_colour_means_no_active_nav_text_override(self): response = self.club_get("home") self.assertNotContains(response, "--tenant-club-content") class MemberManagementTests(ManagementTestBase): def setUp(self): super().setUp() self.client.force_login(self.admin_user) def test_member_list_is_scoped_to_the_club(self): other_club = Club.objects.create(name="Rival FC", slug="rival-fc") other_season = make_season(other_club) other_member = Member.objects.create(first_name="Other", last_name="Person") ClubMembership.objects.create(club=other_club, member=other_member, season=other_season, status=ClubMembership.StatusChoices.ACTIVE) response = self.club_get("member_list") self.assertNotContains(response, "Other Person") def test_creating_a_member_also_signs_them_up_for_the_current_season(self): response = self.club_post("member_create", {"first_name": "New", "last_name": "Player", "email": "new@example.com"}) member = Member.objects.get(first_name="New", last_name="Player") self.assertRedirects(response, reverse("management:member_detail", args=[member.pk])) self.assertTrue(ClubMembership.objects.filter(club=self.club, member=member, season=self.season).exists()) def test_updating_a_member(self): member = Member.objects.create(first_name="Old", last_name="Name") ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) # This member has a current-season membership, so its section renders too -- # one combined submit, so its (required) fields must come along. self.club_post( "member_update", {"first_name": "New", "last_name": "Name", "kind": ClubMembership.Kind.MEMBER, "status": ClubMembership.StatusChoices.ACTIVE, "fee_status": ClubMembership.FeeStatus.UNPAID}, member.pk, ) member.refresh_from_db() self.assertEqual(member.first_name, "New") class MemberAttendanceSparklineTests(ManagementTestBase): """The D7-alike attendance card on member_detail.html -- events.services.attendance.member_attendance_sparkline/_counts.""" @classmethod def setUpTestData(cls): super().setUpTestData() 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.player = Member.objects.create(first_name="Peter", last_name="Player") ClubMembership.objects.create(club=cls.club, member=cls.player, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE) TeamMembership.objects.create(team=cls.team, member=cls.player, season=cls.season, position=cls.position) def setUp(self): self.client.force_login(self.admin_user) def make_attendance(self, *, status, start, showed_up=None): event = Event.objects.create(club=self.club, title="Training", kind=Event.EventKind.TRAINING, start=start, season=self.season) # A future event's roster is auto-synced on event.teams.add() (see # events/signals.py), which already creates this row -- a past event's # isn't (history is never rewritten), so get_or_create covers both. event.teams.add(self.team) attendance, created = Attendance.objects.get_or_create(event=event, member=self.player, defaults={"status": status, "showed_up": showed_up}) if not created: attendance.status, attendance.showed_up = status, showed_up attendance.save(update_fields=["status", "showed_up"]) return attendance def test_the_card_is_hidden_for_a_member_not_rostered_this_season(self): unrostered = Member.objects.create(first_name="Not", last_name="Rostered") ClubMembership.objects.create(club=self.club, member=unrostered, season=self.season, status=ClubMembership.StatusChoices.PENDING) response = self.club_get("member_detail", unrostered.pk) self.assertNotContains(response, "attendance-sparkline") def test_the_card_is_hidden_for_a_guardian(self): guardian = Member.objects.create(first_name="Gale", last_name="Guardian") ClubMembership.objects.create(club=self.club, member=guardian, season=self.season, kind=ClubMembership.Kind.GUARDIAN, status=ClubMembership.StatusChoices.ACTIVE) response = self.club_get("member_detail", guardian.pk) self.assertNotContains(response, "attendance-sparkline") def test_a_past_present_event_counts_as_present(self): self.make_attendance(status=Attendance.AttendanceStatus.PRESENT, start=timezone.now() - datetime.timedelta(days=2)) response = self.club_get("member_detail", self.player.pk) self.assertEqual(response.context["attendance_counts"]["present"], 1) self.assertEqual(response.context["attendance_sparkline"][0]["state"], "present") def test_a_past_absent_event_counts_as_absent(self): self.make_attendance(status=Attendance.AttendanceStatus.ABSENT, start=timezone.now() - datetime.timedelta(days=2)) response = self.club_get("member_detail", self.player.pk) self.assertEqual(response.context["attendance_counts"]["absent"], 1) self.assertEqual(response.context["attendance_sparkline"][0]["state"], "absent") def test_a_future_event_is_upcoming_regardless_of_rsvp_status(self): self.make_attendance(status=Attendance.AttendanceStatus.SELECTED, start=timezone.now() + datetime.timedelta(days=2)) response = self.club_get("member_detail", self.player.pk) self.assertEqual(response.context["attendance_sparkline"][0]["state"], "upcoming") # Only past events feed the Present/Absent/No-reply totals. self.assertEqual(response.context["attendance_counts"]["present"], 0) self.assertEqual(response.context["attendance_counts"]["absent"], 0) self.assertEqual(response.context["attendance_counts"]["no_reply"], 0) def test_a_past_unanswered_event_counts_as_no_reply(self): self.make_attendance(status=Attendance.AttendanceStatus.NO_RESPONSE, start=timezone.now() - datetime.timedelta(days=2)) response = self.club_get("member_detail", self.player.pk) self.assertEqual(response.context["attendance_counts"]["no_reply"], 1) def test_the_sparkline_is_capped_at_twelve_bars_oldest_first(self): for day in range(15, 0, -1): self.make_attendance(status=Attendance.AttendanceStatus.PRESENT, start=timezone.now() - datetime.timedelta(days=day)) response = self.club_get("member_detail", self.player.pk) sparkline = response.context["attendance_sparkline"] self.assertEqual(len(sparkline), 12) starts = [bar["event"].start for bar in sparkline] self.assertEqual(starts, sorted(starts)) class TeamManagementTests(ManagementTestBase): def setUp(self): super().setUp() self.client.force_login(self.admin_user) def test_team_list_is_scoped_to_the_club(self): other_club = Club.objects.create(name="Rival FC", slug="rival-fc") Team.objects.create(club=other_club, name="Rival Team", short_name="RT") response = self.club_get("team_list") self.assertNotContains(response, "Rival Team") def test_creating_a_team(self): response = self.club_post("team_create", {"name": "U15", "short_name": "U15", "referee_management": "club"}) team = Team.objects.get(club=self.club, name="U15") self.assertRedirects(response, reverse("management:team_detail", args=[team.pk])) def test_creating_a_federation_managed_team(self): self.club_post("team_create", {"name": "U15", "short_name": "U15", "referee_management": "federation"}) team = Team.objects.get(club=self.club, name="U15") self.assertEqual(team.referee_management, Team.RefereeManagement.FEDERATION) def test_deleting_a_team(self): team = Team.objects.create(club=self.club, name="U16", short_name="U16") response = self.club_post("team_delete", {}, team.pk) self.assertRedirects(response, reverse("management:team_list")) self.assertFalse(Team.objects.filter(pk=team.pk).exists()) def test_deleting_a_team_cascades_its_roster_and_staff(self): team = Team.objects.create(club=self.club, name="U17", short_name="U17") position = Position.objects.create(club=self.club, name="Coach17", short_name="C17", staff_position=True) member = Member.objects.create(first_name="Sam", last_name="Staffer") StaffAssignment.objects.create(team=team, member=member, season=self.season, position=position) self.club_post("team_delete", {}, team.pk) self.assertFalse(StaffAssignment.objects.filter(team=team).exists()) def test_a_non_admin_cannot_delete_a_team(self): team = Team.objects.create(club=self.club, name="U18", short_name="U18") coach_user = User.objects.create_user(email="coach-team-delete@example.com", password="pw-secret-123") coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") position = Position.objects.create(club=self.club, name="CoachTeamDelete", short_name="CTD", staff_position=True, management_position=True) StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) self.client.force_login(coach_user) response = self.club_post("team_delete", {}, team.pk) self.assertEqual(response.status_code, 403) self.assertTrue(Team.objects.filter(pk=team.pk).exists()) class TeamRosterStaffTests(ManagementTestBase): """Roster/staff management folded into the team page -- see management.views.TeamDetailView and the TeamRoster*/TeamStaff* views.""" @classmethod def setUpTestData(cls): super().setUpTestData() cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st") cls.other_team = Team.objects.create(club=cls.club, name="Second Team", short_name="2nd") cls.player_position = Position.objects.create(club=cls.club, name="Forward", short_name="FW", staff_position=False) cls.coach_position = Position.objects.create(club=cls.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) cls.player = Member.objects.create(first_name="Peter", last_name="Player") ClubMembership.objects.create(club=cls.club, member=cls.player, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE) # A coach for each team: "this team's coach may, that team's coach may not" # is the whole point of this class, so both actors are standing fixtures. cls.team_coach = cls.make_team_coach(cls.team, "coach-roster@example.com") cls.other_team_coach = cls.make_team_coach(cls.other_team, "coach-roster-other@example.com") @classmethod def make_team_coach(cls, team, email): coach_user = User.objects.create_user(email=email, password="pw-secret-123") coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") StaffAssignment.objects.create(team=team, member=coach_member, season=cls.season, position=cls.coach_position) return coach_user def test_nav_no_longer_lists_roster_or_staff(self): # Not a bare "Roster"/"Staff" substring check -- "RosterChief" branding is # on every page regardless. The nav's old icons are a safe, specific proxy. self.client.force_login(self.admin_user) response = self.club_get("team_list") self.assertNotContains(response, "clipboard-list") self.assertNotContains(response, "hard-hat") def test_roster_and_staff_urls_no_longer_resolve(self): with self.assertRaises(NoReverseMatch): reverse("management:roster_list") with self.assertRaises(NoReverseMatch): reverse("management:staff_list") def test_season_switcher_defaults_to_the_current_season(self): self.client.force_login(self.admin_user) TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position) response = self.club_get("team_detail", self.team.pk) self.assertContains(response, "Peter Player") def test_season_switcher_honours_the_query_param(self): other_season = Season.objects.create(club=self.club, start_date=datetime.date(2020, 1, 1), end_date=datetime.date(2020, 12, 31)) TeamMembership.objects.create(team=self.team, season=other_season, member=self.player, position=self.player_position) self.client.force_login(self.admin_user) default_response = self.club_get("team_detail", self.team.pk) other_response = self.client.get(f"{reverse('management:team_detail', args=[self.team.pk])}?season={other_season.pk}", HTTP_HOST="ajax-united.rosterchief.app") # Not assertNotContains("Peter Player") on the default response -- he's # still a valid pick in the "Add player" combobox even when he isn't on # *this* season's roster, so his name legitimately appears there too. self.assertContains(default_response, "No one on the roster for this season yet.") self.assertContains(other_response, "Peter Player") def test_a_teams_own_coach_can_add_a_player(self): self.client.force_login(self.team_coach) response = self.club_post("team_roster_add", {"member": str(self.player.pk), "position": str(self.player_position.pk), "jersey_number": "9"}, self.team.pk, self.season.pk) self.assertRedirects(response, f"{reverse('management:team_detail', args=[self.team.pk])}?season={self.season.pk}") membership = TeamMembership.objects.get(team=self.team, season=self.season, member=self.player) self.assertEqual(membership.jersey_number, 9) def test_a_different_teams_coach_cannot_add_a_player(self): self.client.force_login(self.other_team_coach) response = self.club_post("team_roster_add", {"member": str(self.player.pk), "position": str(self.player_position.pk)}, self.team.pk, self.season.pk) self.assertEqual(response.status_code, 403) self.assertFalse(TeamMembership.objects.filter(team=self.team, season=self.season).exists()) def test_a_different_teams_coach_can_still_view_the_team(self): self.client.force_login(self.other_team_coach) response = self.club_get("team_detail", self.team.pk) self.assertEqual(response.status_code, 200) def test_admin_can_add_a_player_to_any_team(self): self.client.force_login(self.admin_user) self.club_post("team_roster_add", {"member": str(self.player.pk), "position": str(self.player_position.pk)}, self.team.pk, self.season.pk) self.assertTrue(TeamMembership.objects.filter(team=self.team, season=self.season, member=self.player).exists()) def test_the_add_player_dropdown_excludes_a_member_with_no_active_membership(self): lapsed_player = Member.objects.create(first_name="Lex", last_name="Lapsed") ClubMembership.objects.create(club=self.club, member=lapsed_player, season=self.season, status=ClubMembership.StatusChoices.LAPSED) self.client.force_login(self.admin_user) response = self.club_get("team_detail", self.team.pk) self.assertNotContains(response, "Lex Lapsed") def test_the_add_player_dropdown_includes_a_member_active_only_next_season(self): next_season = Season.objects.create(club=self.club, start_date=self.season.end_date + datetime.timedelta(days=1), end_date=self.season.end_date + datetime.timedelta(days=300)) upcoming_player = Member.objects.create(first_name="Uma", last_name="Upcoming") ClubMembership.objects.create(club=self.club, member=upcoming_player, season=next_season, status=ClubMembership.StatusChoices.ACTIVE) self.client.force_login(self.admin_user) response = self.club_get("team_detail", self.team.pk) self.assertContains(response, "Uma Upcoming") def test_the_add_player_dropdown_excludes_a_member_active_only_in_a_past_season(self): past_season = Season.objects.create(club=self.club, start_date=datetime.date(2020, 1, 1), end_date=datetime.date(2020, 12, 31)) past_player = Member.objects.create(first_name="Pip", last_name="Past") ClubMembership.objects.create(club=self.club, member=past_player, season=past_season, status=ClubMembership.StatusChoices.ACTIVE) self.client.force_login(self.admin_user) response = self.club_get("team_detail", self.team.pk) self.assertNotContains(response, "Pip Past") def test_adding_the_same_member_twice_fails_with_a_form_error_not_a_500(self): self.client.force_login(self.admin_user) TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position) response = self.club_post("team_roster_add", {"member": str(self.player.pk), "position": str(self.player_position.pk)}, self.team.pk, self.season.pk) self.assertEqual(response.status_code, 302) self.assertEqual(TeamMembership.objects.filter(team=self.team, season=self.season).count(), 1) def test_a_duplicate_jersey_number_fails_with_a_form_error_not_a_500(self): # team/season aren't TeamMembershipForm fields, so Django's own # validate_unique() can't see unique_jersey_number_per_team_per_season -- # this constraint only gets checked because the form does it by hand. other_player = Member.objects.create(first_name="Olly", last_name="Other") ClubMembership.objects.create(club=self.club, member=other_player, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position, jersey_number=7) self.client.force_login(self.admin_user) response = self.club_post("team_roster_add", {"member": str(other_player.pk), "position": str(self.player_position.pk), "jersey_number": "7"}, self.team.pk, self.season.pk) self.assertEqual(response.status_code, 302) self.assertFalse(TeamMembership.objects.filter(team=self.team, season=self.season, member=other_player).exists()) def test_editing_a_roster_entry_to_a_clashing_jersey_number_fails_gracefully(self): other_player = Member.objects.create(first_name="Olly", last_name="Other") ClubMembership.objects.create(club=self.club, member=other_player, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position, jersey_number=7) other_membership = TeamMembership.objects.create(team=self.team, season=self.season, member=other_player, position=self.player_position, jersey_number=8) self.client.force_login(self.admin_user) response = self.club_post( "team_roster_update", {"member": str(other_player.pk), "position": str(self.player_position.pk), "jersey_number": "7"}, self.team.pk, other_membership.pk, ) self.assertEqual(response.status_code, 302) other_membership.refresh_from_db() self.assertEqual(other_membership.jersey_number, 8) def test_editing_a_roster_entry_updates_it(self): membership = TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position, jersey_number=9) self.client.force_login(self.admin_user) self.club_post( "team_roster_update", {"member": str(self.player.pk), "position": str(self.player_position.pk), "jersey_number": "10", "is_captain": "on"}, self.team.pk, membership.pk, ) membership.refresh_from_db() self.assertEqual(membership.jersey_number, 10) self.assertTrue(membership.is_captain) def test_removing_a_roster_entry_deletes_it(self): membership = TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position) self.client.force_login(self.admin_user) self.club_post("team_roster_remove", {}, self.team.pk, membership.pk) self.assertFalse(TeamMembership.objects.filter(pk=membership.pk).exists()) def test_a_teams_own_coach_can_assign_staff(self): physio_position = Position.objects.create(club=self.club, name="Physio", short_name="PH", staff_position=True) physio = Member.objects.create(first_name="Pat", last_name="Physio") ClubMembership.objects.create(club=self.club, member=physio, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) self.client.force_login(self.team_coach) self.club_post("team_staff_add", {"member": str(physio.pk), "position": str(physio_position.pk)}, self.team.pk, self.season.pk) self.assertTrue(StaffAssignment.objects.filter(team=self.team, season=self.season, member=physio).exists()) def test_the_assign_staff_dropdown_excludes_a_member_active_only_in_a_past_season(self): # Same eligibility rule as the "Add player" dropdown -- see # test_the_add_player_dropdown_excludes_a_member_active_only_in_a_past_season. past_season = Season.objects.create(club=self.club, start_date=datetime.date(2020, 1, 1), end_date=datetime.date(2020, 12, 31)) past_member = Member.objects.create(first_name="Sam", last_name="Stale") ClubMembership.objects.create(club=self.club, member=past_member, season=past_season, status=ClubMembership.StatusChoices.ACTIVE) self.client.force_login(self.admin_user) response = self.club_get("team_detail", self.team.pk) self.assertNotContains(response, "Sam Stale") def test_a_different_teams_coach_cannot_assign_staff(self): physio_position = Position.objects.create(club=self.club, name="Physio", short_name="PH", staff_position=True) physio = Member.objects.create(first_name="Pat", last_name="Physio") self.client.force_login(self.other_team_coach) response = self.club_post("team_staff_add", {"member": str(physio.pk), "position": str(physio_position.pk)}, self.team.pk, self.season.pk) self.assertEqual(response.status_code, 403) def test_removing_a_staff_assignment_deletes_it(self): assignment = StaffAssignment.objects.create(team=self.team, season=self.season, member=self.player, position=self.coach_position) self.client.force_login(self.admin_user) self.club_post("team_staff_remove", {}, self.team.pk, assignment.pk) self.assertFalse(StaffAssignment.objects.filter(pk=assignment.pk).exists()) class TeamBulkAddTests(ManagementTestBase): """Adding many people to a team's roster/staff in one submit -- see management.views.TeamBulkAddView. One formset row per assignment; the position picked decides whether the row means a roster entry or a staff one, and one bad row rejects the whole submit rather than half-saving.""" @classmethod def setUpTestData(cls): super().setUpTestData() cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st") cls.other_team = Team.objects.create(club=cls.club, name="Second Team", short_name="2nd") cls.player_position = Position.objects.create(club=cls.club, name="Forward", short_name="FW", staff_position=False) cls.coach_position = Position.objects.create(club=cls.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) cls.player = Member.objects.create(first_name="Peter", last_name="Player") ClubMembership.objects.create(club=cls.club, member=cls.player, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE) cls.other_player = Member.objects.create(first_name="Olly", last_name="Other") ClubMembership.objects.create(club=cls.club, member=cls.other_player, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE) # A third eligible player, so a submit can pair one perfectly valid row with # one bad one -- see test_a_good_row_alongside_a_bad_one_is_not_saved_either. cls.third_player = Member.objects.create(first_name="Tara", last_name="Third") ClubMembership.objects.create(club=cls.club, member=cls.third_player, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE) coach_user = User.objects.create_user(email="coach-bulk@example.com", password="pw-secret-123") coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") StaffAssignment.objects.create(team=cls.other_team, member=coach_member, season=cls.season, position=cls.coach_position) cls.other_team_coach = coach_user def bulk_data(self, *rows): """A formset POST body: one dict per row, plus the management form.""" data = {"form-TOTAL_FORMS": str(len(rows)), "form-INITIAL_FORMS": "0", "form-MIN_NUM_FORMS": "0", "form-MAX_NUM_FORMS": "1000"} for index, row in enumerate(rows): for name, value in row.items(): data[f"form-{index}-{name}"] = value return data def row(self, member, position, jersey_number="", is_captain=False, is_alternate_captain=False): row = {"member": str(member.pk), "position": str(position.pk), "jersey_number": str(jersey_number)} # Unchecked boxes aren't submitted at all, so only add the key when set -- # sending "off" would still read as True to a Django BooleanField. if is_captain: row["is_captain"] = "on" if is_alternate_captain: row["is_alternate_captain"] = "on" return row def bulk_add(self, *rows): return self.club_post("team_bulk_add", self.bulk_data(*rows), self.team.pk, self.season.pk) def test_the_picker_offers_eligible_members_and_marks_who_is_already_on(self): TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position) self.client.force_login(self.admin_user) response = self.club_get("team_bulk_add", self.team.pk, self.season.pk) self.assertContains(response, "Peter Player — on roster") self.assertContains(response, "Olly Other") def test_the_position_picker_marks_staff_positions_for_the_jersey_toggle(self): # PositionSelect stamps data-staff on staff options; bulk-add-rows.js reads # it to grey out the jersey input, which a StaffAssignment has no field for. self.client.force_login(self.admin_user) response = self.club_get("team_bulk_add", self.team.pk, self.season.pk) self.assertContains(response, "data-staff") def test_a_lapsed_member_is_not_offered(self): lapsed = Member.objects.create(first_name="Lex", last_name="Lapsed") ClubMembership.objects.create(club=self.club, member=lapsed, season=self.season, status=ClubMembership.StatusChoices.LAPSED) self.client.force_login(self.admin_user) response = self.club_get("team_bulk_add", self.team.pk, self.season.pk) self.assertNotContains(response, "Lex Lapsed") def test_admin_can_add_two_players_in_one_submit(self): self.client.force_login(self.admin_user) response = self.bulk_add(self.row(self.player, self.player_position, 9), self.row(self.other_player, self.player_position)) self.assertRedirects(response, f"{reverse('management:team_detail', args=[self.team.pk])}?season={self.season.pk}") self.assertEqual(TeamMembership.objects.filter(team=self.team, season=self.season).count(), 2) self.assertEqual(TeamMembership.objects.get(team=self.team, member=self.player).jersey_number, 9) def test_a_staff_position_creates_a_staff_assignment_not_a_roster_entry(self): # The position is what picks the role -- there is no separate control. self.client.force_login(self.admin_user) self.bulk_add(self.row(self.player, self.coach_position)) self.assertTrue(StaffAssignment.objects.filter(team=self.team, season=self.season, member=self.player).exists()) self.assertFalse(TeamMembership.objects.filter(team=self.team, season=self.season, member=self.player).exists()) def test_a_member_can_be_added_as_both_player_and_staff_via_two_rows(self): self.client.force_login(self.admin_user) self.bulk_add(self.row(self.player, self.player_position), self.row(self.player, self.coach_position)) self.assertTrue(TeamMembership.objects.filter(team=self.team, season=self.season, member=self.player).exists()) self.assertTrue(StaffAssignment.objects.filter(team=self.team, season=self.season, member=self.player).exists()) def test_a_jersey_number_on_a_staff_row_is_rejected(self): self.client.force_login(self.admin_user) response = self.bulk_add(self.row(self.player, self.coach_position, 9)) self.assertEqual(response.status_code, 200) self.assertFalse(StaffAssignment.objects.filter(team=self.team, season=self.season).exists()) def test_a_jersey_clashing_with_an_existing_player_rejects_the_submit(self): TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position, jersey_number=7) self.client.force_login(self.admin_user) response = self.bulk_add(self.row(self.other_player, self.player_position, 7)) self.assertEqual(response.status_code, 200) self.assertContains(response, "already taken") self.assertFalse(TeamMembership.objects.filter(team=self.team, season=self.season, member=self.other_player).exists()) # All-or-nothing: nothing was saved, so the roster is untouched apart from # the entry that was already there before the submit. self.assertEqual(TeamMembership.objects.filter(team=self.team, season=self.season).count(), 1) def test_a_good_row_alongside_a_bad_one_is_not_saved_either(self): # The all-or-nothing guarantee proper: the other tests here submit rows that # are either all bad or bad in pairs, so none of them would notice a valid # row slipping through on its own. If it did, the re-rendered form would # re-add it on the next submit. TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position, jersey_number=7) self.client.force_login(self.admin_user) response = self.bulk_add(self.row(self.other_player, self.player_position, 7), self.row(self.third_player, self.player_position, 12)) self.assertEqual(response.status_code, 200) self.assertFalse(TeamMembership.objects.filter(team=self.team, season=self.season, member=self.third_player).exists()) self.assertEqual(TeamMembership.objects.filter(team=self.team, season=self.season).count(), 1) def test_captaincy_is_saved(self): self.client.force_login(self.admin_user) self.bulk_add( self.row(self.player, self.player_position, 9, is_captain=True), self.row(self.other_player, self.player_position, 10, is_alternate_captain=True), ) self.assertTrue(TeamMembership.objects.get(team=self.team, member=self.player).is_captain) self.assertTrue(TeamMembership.objects.get(team=self.team, member=self.other_player).is_alternate_captain) def test_a_row_left_alone_is_neither_captain_nor_alternate(self): self.client.force_login(self.admin_user) self.bulk_add(self.row(self.player, self.player_position)) membership = TeamMembership.objects.get(team=self.team, member=self.player) self.assertFalse(membership.is_captain) self.assertFalse(membership.is_alternate_captain) def test_captain_and_alternate_on_one_row_is_rejected(self): # Contradictory rather than a club policy -- how many captains a team may # have is deliberately left unconstrained, see TeamBulkAddRowForm.clean. self.client.force_login(self.admin_user) response = self.bulk_add(self.row(self.player, self.player_position, is_captain=True, is_alternate_captain=True)) self.assertEqual(response.status_code, 200) self.assertContains(response, "not both") self.assertFalse(TeamMembership.objects.filter(team=self.team, season=self.season).exists()) def test_captaincy_on_a_staff_row_is_rejected(self): # Captaincy lives on TeamMembership; a staff row becomes a StaffAssignment, # which has no such field to put it in. self.client.force_login(self.admin_user) response = self.bulk_add(self.row(self.player, self.coach_position, is_captain=True)) self.assertEqual(response.status_code, 200) self.assertContains(response, "doesn't apply to a staff position") self.assertFalse(StaffAssignment.objects.filter(team=self.team, season=self.season, member=self.player).exists()) def test_two_captains_in_one_submit_are_allowed(self): # Pins the deliberate absence of a rule: neither the model nor the # single-add form limits a team to one captain, so bulk-add doesn't invent # that limit either -- it would be bypassable by adding players one at a time. self.client.force_login(self.admin_user) self.bulk_add( self.row(self.player, self.player_position, 9, is_captain=True), self.row(self.other_player, self.player_position, 10, is_captain=True), ) self.assertEqual(TeamMembership.objects.filter(team=self.team, season=self.season, is_captain=True).count(), 2) def test_two_rows_claiming_the_same_jersey_are_rejected(self): # Neither row clashes with anything already saved -- only with each other, # which no single row can see. See BaseTeamBulkAddFormSet.clean. self.client.force_login(self.admin_user) response = self.bulk_add(self.row(self.player, self.player_position, 7), self.row(self.other_player, self.player_position, 7)) self.assertEqual(response.status_code, 200) self.assertContains(response, "more than one row") self.assertEqual(TeamMembership.objects.filter(team=self.team, season=self.season).count(), 0) def test_the_same_member_twice_in_the_same_role_is_rejected(self): self.client.force_login(self.admin_user) response = self.bulk_add(self.row(self.player, self.player_position), self.row(self.player, self.player_position)) self.assertEqual(response.status_code, 200) self.assertContains(response, "listed twice") self.assertEqual(TeamMembership.objects.filter(team=self.team, season=self.season).count(), 0) def test_an_invalid_submit_re_renders_the_rows_that_were_typed(self): # The whole point of all-or-nothing: nothing the user typed is lost. TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position, jersey_number=7) self.client.force_login(self.admin_user) response = self.bulk_add(self.row(self.other_player, self.player_position, 7)) self.assertContains(response, f'value="{self.other_player.pk}" selected') def test_a_member_already_on_the_roster_is_rejected(self): TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position) self.client.force_login(self.admin_user) response = self.bulk_add(self.row(self.player, self.player_position)) self.assertEqual(response.status_code, 200) self.assertContains(response, "already on this team's roster") self.assertEqual(TeamMembership.objects.filter(team=self.team, season=self.season, member=self.player).count(), 1) def test_a_lapsed_member_cannot_be_added_via_a_crafted_post(self): # Eligibility is recomputed from eligible_roster_members server-side, so a # member id that was never offered fails the field's own queryset lookup. lapsed = Member.objects.create(first_name="Lex", last_name="Lapsed") ClubMembership.objects.create(club=self.club, member=lapsed, season=self.season, status=ClubMembership.StatusChoices.LAPSED) self.client.force_login(self.admin_user) response = self.bulk_add(self.row(lapsed, self.player_position)) self.assertEqual(response.status_code, 200) self.assertFalse(TeamMembership.objects.filter(team=self.team, season=self.season, member=lapsed).exists()) def test_submitting_nothing_says_so_instead_of_adding(self): self.client.force_login(self.admin_user) response = self.club_post("team_bulk_add", self.bulk_data({}, {}), self.team.pk, self.season.pk) self.assertRedirects(response, f"{reverse('management:team_detail', args=[self.team.pk])}?season={self.season.pk}") self.assertEqual(TeamMembership.objects.filter(team=self.team, season=self.season).count(), 0) def test_a_different_teams_coach_cannot_bulk_add(self): self.client.force_login(self.other_team_coach) response = self.bulk_add(self.row(self.player, self.player_position)) self.assertEqual(response.status_code, 403) class GroupManagementTests(ManagementTestBase): """Generic named collections of members -- see management.views.Group* and management.forms.GroupForm. Deliberately has no team/referee knowledge at all; see MemberRefereeEligibilityTests for that (teams.RefereeProfile).""" @classmethod def setUpTestData(cls): super().setUpTestData() # One team only -- groups are deliberately team-agnostic; the team exists # solely so make_non_admin_coach has somewhere to be staffed. cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st") cls.member = Member.objects.create(first_name="Peter", last_name="Player") ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE) def make_non_admin_coach(self, email="coach-groups@example.com"): coach_user = User.objects.create_user(email=email, password="pw-secret-123") coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") 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=coach_member, season=self.season, position=position) return coach_user def test_list_is_admin_only(self): self.client.force_login(self.make_non_admin_coach()) self.assertEqual(self.club_get("group_list").status_code, 403) def test_the_lists_edit_button_goes_to_the_group_page_not_straight_to_the_form(self): # Same as Teams: Edit lands on the overview, which is where the members # live, and offers its own Edit for the rename form. The list pages that # do jump straight to a form (Locations, Opponents, Sponsors, Positions, # Referee levels) have no detail page to land on at all. group = Group.objects.create(club=self.club, name="Referees") self.client.force_login(self.admin_user) response = self.club_get("group_list") self.assertContains(response, reverse("management:group_detail", args=[group.pk])) self.assertNotContains(response, reverse("management:group_update", args=[group.pk])) def test_admin_can_create_a_group(self): self.client.force_login(self.admin_user) response = self.club_post("group_create", {"name": "Referees"}) group = Group.objects.get(club=self.club, name="Referees") self.assertRedirects(response, reverse("management:group_detail", args=[group.pk])) def test_editing_a_group_renames_it(self): self.client.force_login(self.admin_user) group = Group.objects.create(club=self.club, name="Old name") self.club_post("group_update", {"name": "New name"}, group.pk) group.refresh_from_db() self.assertEqual(group.name, "New name") def test_deleting_a_group_removes_it(self): self.client.force_login(self.admin_user) group = Group.objects.create(club=self.club, name="Doomed") self.club_post("group_delete", {}, group.pk) self.assertFalse(Group.objects.filter(pk=group.pk).exists()) def bulk_data(self, *member_pks): """A formset POST body: one row per member, plus the management form.""" data = {"form-TOTAL_FORMS": str(len(member_pks)), "form-INITIAL_FORMS": "0", "form-MIN_NUM_FORMS": "0", "form-MAX_NUM_FORMS": "1000"} for index, member_pk in enumerate(member_pks): data[f"form-{index}-member"] = str(member_pk) return data def test_bulk_add_offers_members_and_marks_the_ones_already_in(self): group = Group.objects.create(club=self.club, name="Referees") GroupMembership.objects.create(group=group, member=self.member) other_member = Member.objects.create(first_name="Olly", last_name="Other") ClubMembership.objects.create(club=self.club, member=other_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) self.client.force_login(self.admin_user) response = self.club_get("group_bulk_add", group.pk) self.assertContains(response, "already in this group") self.assertContains(response, "Olly Other") def test_bulk_add_adds_the_members_in_the_rows(self): group = Group.objects.create(club=self.club, name="Referees") other_member = Member.objects.create(first_name="Olly", last_name="Other") ClubMembership.objects.create(club=self.club, member=other_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) self.client.force_login(self.admin_user) response = self.club_post("group_bulk_add", self.bulk_data(self.member.pk, other_member.pk), group.pk) self.assertRedirects(response, reverse("management:group_detail", args=[group.pk])) self.assertEqual(GroupMembership.objects.filter(group=group).count(), 2) def test_bulk_add_cannot_re_add_an_existing_member_via_a_crafted_post(self): group = Group.objects.create(club=self.club, name="Referees") GroupMembership.objects.create(group=group, member=self.member) self.client.force_login(self.admin_user) response = self.club_post("group_bulk_add", self.bulk_data(self.member.pk), group.pk) self.assertEqual(response.status_code, 200) self.assertContains(response, "already in this group") self.assertEqual(GroupMembership.objects.filter(group=group, member=self.member).count(), 1) def test_bulk_add_rejects_the_same_member_listed_twice(self): group = Group.objects.create(club=self.club, name="Referees") self.client.force_login(self.admin_user) response = self.club_post("group_bulk_add", self.bulk_data(self.member.pk, self.member.pk), group.pk) self.assertEqual(response.status_code, 200) self.assertContains(response, "listed twice") self.assertEqual(GroupMembership.objects.filter(group=group).count(), 0) def test_bulk_add_with_no_rows_filled_in_adds_nothing(self): group = Group.objects.create(club=self.club, name="Referees") self.client.force_login(self.admin_user) response = self.club_post("group_bulk_add", self.bulk_data("", ""), group.pk) self.assertRedirects(response, reverse("management:group_detail", args=[group.pk])) self.assertEqual(GroupMembership.objects.filter(group=group).count(), 0) def test_removing_a_member_deletes_the_membership(self): group = Group.objects.create(club=self.club, name="Referees") membership = GroupMembership.objects.create(group=group, member=self.member) self.client.force_login(self.admin_user) self.club_post("group_member_remove", {}, group.pk, membership.pk) self.assertFalse(GroupMembership.objects.filter(pk=membership.pk).exists()) def test_groups_are_scoped_to_the_club(self): other_club = Club.objects.create(name="Rival FC", slug="rival-fc") other_group = Group.objects.create(club=other_club, name="Rival Referees") self.client.force_login(self.admin_user) response = self.club_get("group_list") self.assertNotContains(response, "Rival Referees") self.assertEqual(self.club_get("group_detail", other_group.pk).status_code, 404) ONE_PIXEL_PNG = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82" def make_image_file(name="photo.png"): return SimpleUploadedFile(name, ONE_PIXEL_PNG, content_type="image/png") class TeamPhotoTests(ManagementTestBase): """One photo per (team, season), uploaded from the team page -- see management.views.TeamPhotoSetView/TeamPhotoDeleteView.""" @classmethod def setUpTestData(cls): super().setUpTestData() cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st") cls.other_team = Team.objects.create(club=cls.club, name="Second Team", short_name="2nd") cls.coach_position = Position.objects.create(club=cls.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) cls.team_coach = cls.make_team_coach(cls.team, "coach-photo@example.com") cls.other_team_coach = cls.make_team_coach(cls.other_team, "coach-photo-other@example.com") # A staff member on this team with a *non*-management position: allowed # into the management app, but never offered the upload controls. staff_user = User.objects.create_user(email="physio-photo@example.com", password="pw-secret-123") staff_member = Member.objects.create(user=staff_user, first_name="Pat", last_name="Physio") position = Position.objects.create(club=cls.club, name="Physio", short_name="PH", staff_position=True, management_position=False) StaffAssignment.objects.create(team=cls.team, member=staff_member, season=cls.season, position=position) cls.plain_staff = staff_user @classmethod def make_team_coach(cls, team, email): coach_user = User.objects.create_user(email=email, password="pw-secret-123") coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") StaffAssignment.objects.create(team=team, member=coach_member, season=cls.season, position=cls.coach_position) return coach_user def test_uploading_creates_a_photo(self): self.client.force_login(self.admin_user) response = self.club_post("team_photo_set", {"image": make_image_file()}, self.team.pk, self.season.pk) self.assertRedirects(response, f"{reverse('management:team_detail', args=[self.team.pk])}?season={self.season.pk}") self.assertEqual(TeamPhoto.objects.filter(team=self.team, season=self.season).count(), 1) def test_uploading_again_replaces_it_in_place(self): self.client.force_login(self.admin_user) self.club_post("team_photo_set", {"image": make_image_file("first.png")}, self.team.pk, self.season.pk) first = TeamPhoto.objects.get(team=self.team, season=self.season) self.club_post("team_photo_set", {"image": make_image_file("second.png")}, self.team.pk, self.season.pk) self.assertEqual(TeamPhoto.objects.filter(team=self.team, season=self.season).count(), 1) second = TeamPhoto.objects.get(team=self.team, season=self.season) self.assertEqual(first.pk, second.pk) self.assertIn("second", second.image.name) def test_a_different_teams_coach_cannot_upload(self): self.client.force_login(self.other_team_coach) response = self.club_post("team_photo_set", {"image": make_image_file()}, self.team.pk, self.season.pk) self.assertEqual(response.status_code, 403) self.assertFalse(TeamPhoto.objects.filter(team=self.team).exists()) def test_this_teams_coach_can_upload(self): self.client.force_login(self.team_coach) response = self.club_post("team_photo_set", {"image": make_image_file()}, self.team.pk, self.season.pk) self.assertRedirects(response, f"{reverse('management:team_detail', args=[self.team.pk])}?season={self.season.pk}") self.assertTrue(TeamPhoto.objects.filter(team=self.team, season=self.season).exists()) def test_deleting_removes_the_photo(self): TeamPhoto.objects.create(team=self.team, season=self.season, image=make_image_file()) self.client.force_login(self.admin_user) response = self.club_post("team_photo_delete", {}, self.team.pk, self.season.pk) self.assertRedirects(response, f"{reverse('management:team_detail', args=[self.team.pk])}?season={self.season.pk}") self.assertFalse(TeamPhoto.objects.filter(team=self.team, season=self.season).exists()) def test_the_team_page_shows_the_photo_when_set(self): TeamPhoto.objects.create(team=self.team, season=self.season, image=make_image_file()) self.client.force_login(self.admin_user) response = self.club_get("team_detail", self.team.pk) self.assertContains(response, "Replace photo") self.assertNotContains(response, "No photo uploaded") def test_the_team_page_shows_a_placeholder_when_not_set(self): self.client.force_login(self.admin_user) response = self.club_get("team_detail", self.team.pk) self.assertContains(response, "No photo uploaded") self.assertContains(response, "Upload photo") def test_upload_ui_is_hidden_from_a_plain_staff_member(self): TeamPhoto.objects.create(team=self.team, season=self.season, image=make_image_file()) self.client.force_login(self.plain_staff) response = self.club_get("team_detail", self.team.pk) self.assertNotContains(response, "Replace photo") self.assertNotContains(response, "Upload photo") class PositionManagementTests(ManagementTestBase): def setUp(self): super().setUp() self.client.force_login(self.admin_user) def test_position_list_is_scoped_to_the_club(self): other_club = Club.objects.create(name="Rival FC", slug="rival-fc") Position.objects.create(club=other_club, name="Rival Coach", short_name="RC", staff_position=True) response = self.club_get("position_list") self.assertNotContains(response, "Rival Coach") def test_creating_a_position(self): response = self.club_post("position_create", {"name": "Physio", "short_name": "PH", "ordering": 0, "staff_position": "on", "management_position": ""}) position = Position.objects.get(club=self.club, name="Physio") self.assertRedirects(response, reverse("management:position_list")) self.assertTrue(position.staff_position) self.assertFalse(position.management_position) def test_updating_a_position(self): position = Position.objects.create(club=self.club, name="Old name", short_name="ON") self.club_post("position_update", {"name": "New name", "short_name": "NN", "ordering": 0}, position.pk) position.refresh_from_db() self.assertEqual(position.name, "New name") def test_a_management_position_must_also_be_a_staff_position(self): response = self.club_post("position_create", {"name": "Bad", "short_name": "B", "ordering": 0, "management_position": "on"}) self.assertEqual(response.status_code, 200) self.assertFalse(Position.objects.filter(club=self.club, name="Bad").exists()) self.assertFormError(response.context["form"], "management_position", "A management position must also be a staff position.") class RefereeLevelManagementTests(ManagementTestBase): """Admin-managed referee qualification tiers -- see management.views.RefereeLevel* and teams.RefereeLevel.""" @classmethod def setUpTestData(cls): super().setUpTestData() cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st") cls.other_team = Team.objects.create(club=cls.club, name="Second Team", short_name="2nd") def make_non_admin_coach(self, email="coach-levels@example.com"): coach_user = User.objects.create_user(email=email, password="pw-secret-123") coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") 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=coach_member, season=self.season, position=position) return coach_user def test_list_is_visible_to_any_staff(self): RefereeLevel.objects.create(club=self.club, name="Regional") self.client.force_login(self.make_non_admin_coach()) response = self.club_get("referee_level_list") self.assertEqual(response.status_code, 200) self.assertContains(response, "Regional") def test_create_is_admin_only(self): self.client.force_login(self.make_non_admin_coach()) response = self.club_post("referee_level_create", {"name": "Regional", "teams": []}) self.assertEqual(response.status_code, 403) def test_admin_can_create_a_level_with_teams(self): self.client.force_login(self.admin_user) response = self.club_post("referee_level_create", {"name": "Regional", "teams": [str(self.team.pk), str(self.other_team.pk)]}) level = RefereeLevel.objects.get(club=self.club, name="Regional") self.assertRedirects(response, reverse("management:referee_level_list")) self.assertEqual(set(level.teams.all()), {self.team, self.other_team}) def test_admin_can_update_a_levels_teams(self): level = RefereeLevel.objects.create(club=self.club, name="Regional") level.teams.add(self.team) self.client.force_login(self.admin_user) self.club_post("referee_level_update", {"name": "Regional", "teams": [str(self.other_team.pk)]}, level.pk) self.assertEqual(set(level.teams.all()), {self.other_team}) def test_list_scoped_to_the_club(self): other_club = Club.objects.create(name="Rival FC", slug="rival-fc") RefereeLevel.objects.create(club=other_club, name="Rival Level") self.client.force_login(self.admin_user) response = self.club_get("referee_level_list") self.assertNotContains(response, "Rival Level") def test_admin_can_link_a_level_to_inherit_from_another(self): regional = RefereeLevel.objects.create(club=self.club, name="Regional") regional.teams.add(self.team) self.client.force_login(self.admin_user) response = self.club_post("referee_level_create", {"name": "National", "teams": [str(self.other_team.pk)], "inherits_from": str(regional.pk)}) national = RefereeLevel.objects.get(club=self.club, name="National") self.assertRedirects(response, reverse("management:referee_level_list")) self.assertEqual(national.inherits_from, regional) self.assertEqual(national.eligible_team_ids(), {self.team.pk, self.other_team.pk}) def test_a_level_cannot_be_set_to_inherit_from_itself(self): level = RefereeLevel.objects.create(club=self.club, name="Regional") self.client.force_login(self.admin_user) self.club_post("referee_level_update", {"name": "Regional", "teams": [], "inherits_from": str(level.pk)}, level.pk) level.refresh_from_db() self.assertIsNone(level.inherits_from) def test_the_inherits_from_dropdown_only_offers_this_clubs_levels(self): other_club = Club.objects.create(name="Rival FC", slug="rival-fc") RefereeLevel.objects.create(club=other_club, name="Rival Level") self.client.force_login(self.admin_user) response = self.club_get("referee_level_create") self.assertNotContains(response, "Rival Level") def test_the_list_shows_what_a_level_inherits(self): regional = RefereeLevel.objects.create(club=self.club, name="Regional") RefereeLevel.objects.create(club=self.club, name="National", inherits_from=regional) self.client.force_login(self.admin_user) response = self.club_get("referee_level_list") self.assertContains(response, "Regional") self.assertContains(response, "everything") class RefereeListViewTests(ManagementTestBase): """The club-wide referee overview -- see management.views.RefereeListView.""" @classmethod def setUpTestData(cls): super().setUpTestData() cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st") cls.level = RefereeLevel.objects.create(club=cls.club, name="Regional") cls.level.teams.add(cls.team) cls.member = Member.objects.create(first_name="Ref", last_name="Eree") ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE) def setUp(self): super().setUp() self.client.force_login(self.admin_user) def test_lists_a_valid_referee_with_level_and_teams(self): RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=timezone.localdate() + datetime.timedelta(days=30)) response = self.club_get("referee_list") self.assertContains(response, "Ref Eree") self.assertContains(response, "Regional") self.assertContains(response, "First Team") self.assertContains(response, "Valid") def test_shows_expired_status(self): RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=timezone.localdate() - datetime.timedelta(days=1)) response = self.club_get("referee_list") self.assertContains(response, "Expired") def test_shows_no_level_status(self): RefereeProfile.objects.create(member=self.member, valid_until=timezone.localdate() + datetime.timedelta(days=30)) response = self.club_get("referee_list") self.assertContains(response, "No level") def test_shows_no_validity_set_status(self): RefereeProfile.objects.create(member=self.member, level=self.level) response = self.club_get("referee_list") self.assertContains(response, "No validity set") def test_a_member_with_no_referee_profile_is_not_listed(self): other_member = Member.objects.create(first_name="Not", last_name="Referee") ClubMembership.objects.create(club=self.club, member=other_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) response = self.club_get("referee_list") self.assertNotContains(response, "Not Referee") def test_visible_to_any_staff(self): coach_user = User.objects.create_user(email="coach-reflist@example.com", password="pw-secret-123") coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") coach_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=coach_member, season=self.season, position=coach_position) RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=timezone.localdate() + datetime.timedelta(days=30)) # Give the coach visibility into self.member too (members_visible_to # scopes a non-admin to teams they're staffed on). player_position = Position.objects.create(club=self.club, name="Forward", short_name="FW") TeamMembership.objects.create(team=self.team, member=self.member, season=self.season, position=player_position) self.client.force_login(coach_user) response = self.club_get("referee_list") self.assertEqual(response.status_code, 200) self.assertContains(response, "Ref Eree") class ClubRoleManagementTests(ManagementTestBase): @classmethod def setUpTestData(cls): super().setUpTestData() cls.member = Member.objects.create(first_name="Future", last_name="Editor") # An active membership already grants an implicit MEMBER role (club/signals.py) -- # granting EDITOR must promote that row, not insert a second one. ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE) def setUp(self): super().setUp() self.client.force_login(self.admin_user) def test_granting_a_role_promotes_the_existing_membership_role(self): self.club_post("role_create", {"member": str(self.member.pk), "role": ClubRole.Roles.EDITOR}) role = ClubRole.objects.get(club=self.club, member=self.member) self.assertEqual(role.role, ClubRole.Roles.EDITOR) self.assertEqual(ClubRole.objects.filter(club=self.club, member=self.member).count(), 1) def test_revoking_a_role(self): role = ClubRole.objects.get(club=self.club, member=self.member) self.club_post("role_revoke", {}, role.pk) self.assertFalse(ClubRole.objects.filter(pk=role.pk).exists()) def test_the_plain_member_role_never_appears_on_the_list(self): # self.admin_member and self.member both hold an implicit MEMBER role -- # noise this page must never show. (self.member's name still legitimately # appears once, in the "Grant role" modal's member picker.) response = self.club_get("role_list") self.assertContains(response, "No one has the Editor role yet.") def test_a_granted_role_appears_under_its_own_section(self): self.club_post("role_create", {"member": str(self.member.pk), "role": ClubRole.Roles.EDITOR}) response = self.club_get("role_list") self.assertContains(response, "Future Editor") def test_grant_role_is_a_modal_on_the_list_page(self): response = self.club_get("role_list") self.assertContains(response, 'id="grant_role_modal"') def test_each_section_explains_what_the_role_grants(self): response = self.club_get("role_list") self.assertContains(response, "Full control over the club") self.assertContains(response, "Can create and edit events") def test_an_invalid_submission_redirects_back_to_the_list_instead_of_a_page(self): response = self.club_post("role_create", {"member": "", "role": ClubRole.Roles.EDITOR}) self.assertRedirects(response, reverse("management:role_list")) class FamilyManagementTests(ManagementTestBase): def setUp(self): super().setUp() self.client.force_login(self.admin_user) def test_registering_a_family_creates_a_login_parent_and_a_login_less_child(self): response = self.club_post( "family_create", { "parent_first_name": "Pat", "parent_last_name": "Parent", "parent_email": "pat.parent@example.com", "child_first_name": "Cody", "child_last_name": "Child", "child_date_of_birth": "2015-04-01", }, ) family = Family.objects.get(memberships__member__first_name="Cody") self.assertRedirects(response, reverse("management:family_detail", args=[family.pk])) parent = Member.objects.get(first_name="Pat", last_name="Parent") child = Member.objects.get(first_name="Cody", last_name="Child") self.assertEqual(parent.user.email, "pat.parent@example.com") self.assertFalse(parent.user.has_usable_password(), "should set a password via the reset link, not be given one") self.assertIsNone(child.user) self.assertEqual(FamilyMembership.objects.get(family=family, member=parent).role, FamilyMembership.FamilyRole.PARENT) self.assertEqual(FamilyMembership.objects.get(family=family, member=child).role, FamilyMembership.FamilyRole.CHILD) # Both get signed up for the current season, same as a plain MemberCreateView. self.assertTrue(ClubMembership.objects.filter(club=self.club, member=parent, season=self.season).exists()) self.assertTrue(ClubMembership.objects.filter(club=self.club, member=child, season=self.season).exists()) def test_reusing_an_existing_login_by_email(self): # A parent who's already a Member elsewhere (an existing login) must be # reused, not duplicated, when registered onto a second family. existing_user = User.objects.create_user(email="existing@example.com", password="pw-secret-123") existing_member = Member.objects.create(user=existing_user, first_name="Existing", last_name="Parent") self.club_post( "family_create", { "parent_first_name": "Ignored", "parent_last_name": "Ignored", "parent_email": "existing@example.com", "child_first_name": "New", "child_last_name": "Kid", }, ) self.assertEqual(Member.objects.filter(user=existing_user).count(), 1) family = Family.objects.get(memberships__member__first_name="New") self.assertIn(existing_member, family.guardians) def make_existing_family(self): # A family only counts as "of this club" once at least one of its members # has actually signed up (families_of_club, management/views.py) -- exactly # what registering the first child through this app already does. family = Family.objects.create() first_kid = Member.objects.create(first_name="First", last_name="Kid") FamilyMembership.objects.create(family=family, member=first_kid, role=FamilyMembership.FamilyRole.CHILD) ClubMembership.objects.create(club=self.club, member=first_kid, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) return family def test_adding_a_child_to_an_existing_family(self): family = self.make_existing_family() response = self.club_post("family_add_child", {"first_name": "Second", "last_name": "Kid", "date_of_birth": "2018-01-01"}, family.pk) self.assertRedirects(response, reverse("management:family_detail", args=[family.pk])) self.assertEqual(family.children.count(), 2) self.assertTrue(ClubMembership.objects.filter(club=self.club, member__first_name="Second", season=self.season).exists()) def test_adding_a_parent_to_an_existing_family_is_idempotent(self): family = self.make_existing_family() self.club_post("family_add_parent", {"email": "new.parent@example.com", "first_name": "New", "last_name": "Parent"}, family.pk) self.club_post("family_add_parent", {"email": "new.parent@example.com", "first_name": "", "last_name": ""}, family.pk) self.assertEqual(family.guardians.count(), 1) class ParentClaimViewTests(ManagementTestBase): """The public claim form and the admin review queue -- see members.services.claims; the service-level guarantees live in members.tests.ParentClaimTests.""" @classmethod def setUpTestData(cls): super().setUpTestData() cls.child = Member.objects.create(first_name="Jamie", last_name="Doe", date_of_birth=datetime.date(2014, 3, 2)) ClubMembership.objects.create(club=cls.club, member=cls.child, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE) family = Family.objects.create() FamilyMembership.objects.create(family=family, member=cls.child, role=FamilyMembership.FamilyRole.CHILD) def claim_payload(self, **overrides): payload = { "parent_first_name": "Taylor", "parent_last_name": "Doe", "parent_email": "taylor.doe@example.com", "child_first_name": "Jamie", "child_last_name": "Doe", "child_date_of_birth": "2014-03-02", } payload.update(overrides) return payload def submit(self, *, follow=False, **overrides): return self.client.post(reverse("members:parent_claim"), self.claim_payload(**overrides), HTTP_HOST="ajax-united.rosterchief.app", follow=follow) def test_the_claim_form_is_reachable_without_signing_in(self): response = self.client.get(reverse("members:parent_claim"), HTTP_HOST="ajax-united.rosterchief.app") self.assertEqual(response.status_code, 200) def test_submitting_records_a_pending_claim_and_redirects_back_with_a_flash(self): response = self.submit() # fetch_redirect_response=False: assertRedirects' own probe GET would # otherwise consume the one-shot flash before the assertion below gets to see it. self.assertRedirects(response, reverse("members:parent_claim"), fetch_redirect_response=False) self.assertEqual(ParentClaim.objects.filter(club=self.club, status=ParentClaim.Status.PENDING).count(), 1) page = self.client.get(reverse("members:parent_claim"), HTTP_HOST="ajax-united.rosterchief.app") self.assertContains(page, "Request received") def test_an_unmatched_claim_flashes_the_same_message_as_a_matched_one(self): # The page must not tell an anonymous submitter which children exist. matched = self.submit(follow=True) unmatched = self.submit(child_first_name="Nobody", child_last_name="Here", parent_email="other@example.com", follow=True) self.assertEqual([str(m) for m in matched.context["messages"]], [str(m) for m in unmatched.context["messages"]]) def test_the_flash_mentions_the_clubs_contact_email_when_set(self): self.club.contact_email = "info@ajax-united.example.com" self.club.save(update_fields=["contact_email"]) response = self.submit(follow=True) self.assertContains(response, "info@ajax-united.example.com") def test_submitting_creates_no_account(self): # A public form that made a User per submission would be a spam magnet; # the account is created on approval, once a human has vouched for it. self.submit() self.assertFalse(User.objects.filter(email="taylor.doe@example.com").exists()) def test_open_signup_is_closed(self): response = self.client.get(reverse("account_signup"), HTTP_HOST="ajax-united.rosterchief.app") self.assertEqual(response.status_code, 403) def test_the_queue_is_admin_only(self): coach_user = User.objects.create_user(email="coach-claims@example.com", password="pw-secret-123") coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") team = Team.objects.create(club=self.club, name="First Team", short_name="1st") position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) self.client.force_login(coach_user) self.assertEqual(self.club_get("parent_claim_list").status_code, 403) def test_the_queue_lists_a_pending_claim_and_the_unclaimed_child(self): self.submit() self.client.force_login(self.admin_user) response = self.club_get("parent_claim_list") self.assertContains(response, "taylor.doe@example.com") self.assertContains(response, "Jamie Doe") def test_the_child_dropdown_matches_the_height_of_the_buttons_beside_it(self): # Regression: ClaimReviewForm.child used to hardcode its own class= # attrs, which rendered a second class="..." on the
"), b"%PDF-1.7") def test_a_missing_pdf_library_says_what_is_missing(self): with mock.patch.dict(sys.modules, {"weasyprint": None}), self.assertRaises(PDFExportError): render_pdf("hi
") class MembershipExportPdfTests(ManagementTestBase): @classmethod def setUpTestData(cls): super().setUpTestData() cls.member = Member.objects.create(first_name="Print", last_name="Me") ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE, fee_status=ClubMembership.FeeStatus.UNPAID) def setUp(self): super().setUp() self.client.force_login(self.admin_user) def test_downloads_as_a_pdf(self): with mock.patch("management.views.membership_list_pdf", return_value=b"%PDF-fake") as renderer: response = self.club_get("membership_export_pdf") self.assertEqual(response["Content-Type"], "application/pdf") self.assertIn(".pdf", response["Content-Disposition"]) self.assertEqual(response.content, b"%PDF-fake") renderer.assert_called_once() def test_export_uses_the_same_filters_as_the_page(self): # Same fixture as the "on-team" filter test for the page itself -- the PDF # must reflect whatever's filtered, not the whole club. other = Member.objects.create(first_name="Other", last_name="Person") ClubMembership.objects.create(club=self.club, member=other, season=self.season, status=ClubMembership.StatusChoices.ACTIVE, fee_status=ClubMembership.FeeStatus.UNPAID) with mock.patch("management.views.membership_list_pdf", return_value=b"%PDF-fake") as renderer: self.client.get(reverse("management:membership_export_pdf") + "?q=Print", HTTP_HOST="ajax-united.rosterchief.app") context = renderer.call_args[0][0] names = {m.member.last_name for m in context["memberships"]} self.assertEqual(names, {"Me"}) def test_a_missing_pdf_library_is_reported_rather_than_a_500(self): # WeasyPrint needs native libs. Without them the button must explain itself. with mock.patch("management.views.membership_list_pdf", side_effect=PDFExportError("PDF rendering needs the native pango/cairo libraries.")): response = self.club_get("membership_export_pdf") response = self.client.get(response.url, HTTP_HOST="ajax-united.rosterchief.app") self.assertContains(response, "pango") def test_non_admin_gets_403(self): coach_user = User.objects.create_user(email="coach-export@example.com", password="pw-secret-123") coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") team = Team.objects.create(club=self.club, name="U21", short_name="U21") position = Position.objects.create(club=self.club, name="Coach10", short_name="C10", staff_position=True) StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) self.client.force_login(coach_user) response = self.club_get("membership_export_pdf") self.assertEqual(response.status_code, 403) class MembershipSendInvoicesTests(ManagementTestBase): def setUp(self): super().setUp() self.client.force_login(self.admin_user) self.member = Member.objects.create(first_name="Jane", last_name="Doe", email="jane@example.com") self.membership = ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE, fee_amount=Decimal("150.00")) def test_sending_creates_and_emails_an_invoice(self): self.club_post("membership_send_invoices", {"membership_ids": [str(self.membership.pk)], "due_in_days": "14"}) invoice = DuesInvoice.objects.get(membership=self.membership) self.assertIsNotNone(invoice.sent_at) self.assertEqual(invoice.sent_to_email, "jane@example.com") self.assertEqual(invoice.amount, Decimal("150.00")) self.assertEqual(invoice.due_date, timezone.now().date() + datetime.timedelta(days=14)) self.assertEqual(len(mail.outbox), 1) self.assertEqual(mail.outbox[0].to, ["jane@example.com"]) def test_the_email_carries_an_html_alternative(self): self.club_post("membership_send_invoices", {"membership_ids": [str(self.membership.pk)], "due_in_days": "14"}) [(html_body, mimetype)] = mail.outbox[0].alternatives self.assertEqual(mimetype, "text/html") self.assertIn(self.club.name, html_body) def test_falls_back_to_a_guardians_email(self): self.member.email = "" self.member.save(update_fields=["email"]) family = Family.objects.create() parent = Member.objects.create(first_name="Pat", last_name="Doe", email="pat@example.com") FamilyMembership.objects.create(family=family, member=self.member, role=FamilyMembership.FamilyRole.CHILD) FamilyMembership.objects.create(family=family, member=parent, role=FamilyMembership.FamilyRole.PARENT) self.club_post("membership_send_invoices", {"membership_ids": [str(self.membership.pk)], "due_in_days": "14"}) invoice = DuesInvoice.objects.get(membership=self.membership) self.assertEqual(invoice.sent_to_email, "pat@example.com") self.assertTrue(invoice.sent_to_guardian) def test_a_member_with_no_reachable_email_is_skipped(self): self.member.email = "" self.member.save(update_fields=["email"]) response = self.club_post("membership_send_invoices", {"membership_ids": [str(self.membership.pk)], "due_in_days": "14"}) self.assertFalse(DuesInvoice.objects.filter(membership=self.membership).exists()) self.assertEqual(len(mail.outbox), 0) response = self.client.get(response.url, HTTP_HOST="ajax-united.rosterchief.app") self.assertContains(response, "no email on file") def test_resending_updates_the_same_invoice(self): self.club_post("membership_send_invoices", {"membership_ids": [str(self.membership.pk)], "due_in_days": "14"}) first_number = DuesInvoice.objects.get(membership=self.membership).number self.club_post("membership_send_invoices", {"membership_ids": [str(self.membership.pk)], "due_in_days": "30"}) self.assertEqual(DuesInvoice.objects.filter(membership=self.membership).count(), 1) invoice = DuesInvoice.objects.get(membership=self.membership) self.assertEqual(invoice.number, first_number) self.assertEqual(invoice.due_date, timezone.now().date() + datetime.timedelta(days=30)) self.assertEqual(len(mail.outbox), 2) def test_no_selection_shows_an_error(self): response = self.club_post("membership_send_invoices", {"due_in_days": "14"}) self.assertFalse(DuesInvoice.objects.exists()) response = self.client.get(response.url, HTTP_HOST="ajax-united.rosterchief.app") self.assertContains(response, "Select at least one member") def test_non_admin_gets_403(self): coach_user = User.objects.create_user(email="coach-invoice@example.com", password="pw-secret-123") coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") team = Team.objects.create(club=self.club, name="U15", short_name="U15") position = Position.objects.create(club=self.club, name="Coach11", short_name="C11", staff_position=True) StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) self.client.force_login(coach_user) response = self.club_post("membership_send_invoices", {"membership_ids": [str(self.membership.pk)], "due_in_days": "14"}) self.assertEqual(response.status_code, 403) class MembershipSendInvoiceRemindersTests(ManagementTestBase): def setUp(self): super().setUp() self.client.force_login(self.admin_user) def make_invoice(self, *, due_date, fee_status=ClubMembership.FeeStatus.UNPAID, email="jane@example.com"): member = Member.objects.create(first_name="Jane", last_name="Doe", email=email) membership = ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE, fee_amount=Decimal("100.00"), fee_status=fee_status) return DuesInvoice.objects.create(club=self.club, membership=membership, number="DUE-2026-00001", amount=Decimal("100.00"), due_date=due_date, sent_at=timezone.now(), sent_to_email=email) def test_reminds_an_overdue_unpaid_invoice(self): invoice = self.make_invoice(due_date=timezone.now().date() - datetime.timedelta(days=1)) self.club_post("membership_send_invoice_reminders", {}) invoice.refresh_from_db() self.assertEqual(invoice.reminder_count, 1) self.assertIsNotNone(invoice.last_reminder_sent_at) self.assertEqual(len(mail.outbox), 1) self.assertEqual(mail.outbox[0].to, ["jane@example.com"]) def test_does_not_remind_one_not_yet_due(self): invoice = self.make_invoice(due_date=timezone.now().date() + datetime.timedelta(days=5)) self.club_post("membership_send_invoice_reminders", {}) invoice.refresh_from_db() self.assertEqual(invoice.reminder_count, 0) self.assertEqual(len(mail.outbox), 0) def test_does_not_remind_a_paid_invoice(self): invoice = self.make_invoice(due_date=timezone.now().date() - datetime.timedelta(days=1), fee_status=ClubMembership.FeeStatus.PAID) self.club_post("membership_send_invoice_reminders", {}) invoice.refresh_from_db() self.assertEqual(invoice.reminder_count, 0) def test_nothing_to_remind_notifies_gracefully(self): response = self.club_post("membership_send_invoice_reminders", {}) response = self.client.get(response.url, HTTP_HOST="ajax-united.rosterchief.app") self.assertContains(response, "Nothing to remind") def test_non_admin_gets_403(self): coach_user = User.objects.create_user(email="coach-reminder@example.com", password="pw-secret-123") coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") team = Team.objects.create(club=self.club, name="U16", short_name="U16") position = Position.objects.create(club=self.club, name="Coach12", short_name="C12", staff_position=True) StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) self.client.force_login(coach_user) response = self.club_post("membership_send_invoice_reminders", {}) self.assertEqual(response.status_code, 403) class DuesInvoiceDetailViewTests(ManagementTestBase): def setUp(self): super().setUp() self.client.force_login(self.admin_user) self.member = Member.objects.create(first_name="Jane", last_name="Doe", email="jane@example.com") self.membership = ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE, fee_amount=Decimal("150.00")) self.invoice = DuesInvoice.objects.create(club=self.club, membership=self.membership, number="DUE-2026-00001", amount=Decimal("150.00"), due_date=timezone.now().date(), sent_at=timezone.now(), sent_to_email="jane@example.com") def test_shows_the_invoice(self): response = self.club_get("membership_invoice_detail", self.membership.pk) self.assertContains(response, "DUE-2026-00001") self.assertContains(response, "jane@example.com") def test_404_when_the_membership_has_no_invoice(self): other_member = Member.objects.create(first_name="No", last_name="Invoice") other_membership = ClubMembership.objects.create(club=self.club, member=other_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) response = self.club_get("membership_invoice_detail", other_membership.pk) self.assertEqual(response.status_code, 404) def test_downloads_as_a_pdf(self): with mock.patch("management.views.invoice_pdf", return_value=b"%PDF-fake") as renderer: response = self.club_get("membership_invoice_pdf", self.membership.pk) self.assertEqual(response["Content-Type"], "application/pdf") self.assertEqual(response.content, b"%PDF-fake") renderer.assert_called_once() def test_a_missing_pdf_library_is_reported_rather_than_a_500(self): with mock.patch("management.views.invoice_pdf", side_effect=DuesInvoicePDFError("PDF rendering needs the native pango/cairo libraries.")): response = self.club_get("membership_invoice_pdf", self.membership.pk) response = self.client.get(response.url, HTTP_HOST="ajax-united.rosterchief.app") self.assertContains(response, "pango") def test_non_admin_gets_403(self): coach_user = User.objects.create_user(email="coach-invoice-detail@example.com", password="pw-secret-123") coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") team = Team.objects.create(club=self.club, name="U17", short_name="U17") position = Position.objects.create(club=self.club, name="Coach13", short_name="C13", staff_position=True) StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) self.client.force_login(coach_user) response = self.club_get("membership_invoice_detail", self.membership.pk) self.assertEqual(response.status_code, 403) class MemberListRowActionsTests(ManagementTestBase): @classmethod def setUpTestData(cls): super().setUpTestData() cls.member = Member.objects.create(first_name="Row", last_name="Actions") ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE) def test_admin_sees_edit_and_delete_buttons(self): self.client.force_login(self.admin_user) response = self.club_get("member_list") self.assertContains(response, reverse("management:member_delete", args=[self.member.pk])) def test_non_admin_does_not_see_edit_and_delete_buttons(self): # Roster self.member on the coach's team so this actually tests the # admin-only button gating, not just "the coach can't see this row at all". coach_user = User.objects.create_user(email="coach5@example.com", password="pw-secret-123") coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") team = Team.objects.create(club=self.club, name="U16", short_name="U16") staff_position = Position.objects.create(club=self.club, name="Coach5", short_name="C5", staff_position=True, management_position=True) player_position = Position.objects.create(club=self.club, name="Player5", short_name="P5", staff_position=False) StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=staff_position) TeamMembership.objects.create(team=team, member=self.member, season=self.season, position=player_position) self.client.force_login(coach_user) response = self.club_get("member_list") self.assertContains(response, "Row") self.assertNotContains(response, reverse("management:member_delete", args=[self.member.pk])) class HomeViewTests(ManagementTestBase): """The dashboard reuses controlpanel.services.statistics' club_attention/ club_charts/club_statistics -- already club-scoped, so directly usable for this club's own staff. Financial pieces (fee chart, Shop stat group) are admin-only, same line the nav already draws around the Shop section.""" def make_coach(self, email): coach_user = User.objects.create_user(email=email, password="pw-secret-123") coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") team = Team.objects.create(club=self.club, name=f"Team-{email}", short_name="T") position = Position.objects.create(club=self.club, name=f"Coach-{email}", short_name="C", staff_position=True, management_position=True) StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) return coach_user def test_admin_sees_the_financial_sections(self): self.client.force_login(self.admin_user) response = self.club_get("home") self.assertEqual(response.status_code, 200) self.assertContains(response, 'id="fees-chart"') self.assertContains(response, 'id="signups-chart"') self.assertContains(response, "Renewal rate") self.assertContains(response, "Open carts") self.assertContains(response, "md:grid-cols-4") def test_non_admin_staff_does_not_see_the_financial_sections(self): self.client.force_login(self.make_coach("coach6@example.com")) response = self.club_get("home") self.assertEqual(response.status_code, 200) self.assertNotContains(response, 'id="fees-chart"') self.assertNotContains(response, 'id="signups-chart"') self.assertNotContains(response, "Renewal rate") self.assertNotContains(response, "Open carts") self.assertContains(response, "md:grid-cols-3") def test_the_season_pill_shows_the_season_years_not_the_short_name(self): self.client.force_login(self.admin_user) response = self.club_get("home") self.assertContains(response, f"Season {self.season.start_date.year}-{self.season.end_date.year}") self.assertNotContains(response, self.season.name) def test_total_members_kpi_counts_current_season_members(self): # self.admin_user already has one ACTIVE ClubMembership for self.season # (ManagementTestBase.setUpTestData) -- a guardian-kind row must not count. member = Member.objects.create(first_name="Extra", last_name="Player") ClubMembership.objects.create(club=self.club, member=member, season=self.season, kind=ClubMembership.Kind.MEMBER, status=ClubMembership.StatusChoices.ACTIVE) guardian = Member.objects.create(first_name="Guardian", last_name="Only") ClubMembership.objects.create(club=self.club, member=guardian, season=self.season, kind=ClubMembership.Kind.GUARDIAN, status=ClubMembership.StatusChoices.ACTIVE) self.client.force_login(self.admin_user) response = self.club_get("home") self.assertEqual(response.context["member_count"], 2) def test_total_members_trend_compares_to_the_previous_season(self): previous_season = Season.objects.create(club=self.club, start_date=self.season.start_date - datetime.timedelta(days=365), end_date=self.season.start_date - datetime.timedelta(days=1)) for i in range(3): member = Member.objects.create(first_name=f"Last{i}", last_name="Season") ClubMembership.objects.create(club=self.club, member=member, season=previous_season, kind=ClubMembership.Kind.MEMBER, status=ClubMembership.StatusChoices.ACTIVE) # self.admin_user is the only current-season member -- 1 now vs 3 last season. self.client.force_login(self.admin_user) response = self.club_get("home") self.assertEqual(response.context["member_count_change"], -2) self.assertContains(response, "-2") def test_no_previous_season_shows_no_comparison_available(self): self.client.force_login(self.admin_user) response = self.club_get("home") self.assertIsNone(response.context["member_count_change"]) self.assertContains(response, "No previous season to compare") def test_missing_documentation_kpi_hidden_without_any_requirements_configured(self): self.client.force_login(self.admin_user) response = self.club_get("home") self.assertFalse(response.context["requirements_configured"]) self.assertNotContains(response, "Missing documentation") def test_missing_documentation_kpi_counts_open_checklist_items_and_appears_in_needs_attention(self): OnboardingRequirement.objects.create(club=self.club, name="Medical certificate") member = Member.objects.create(first_name="Needs", last_name="Docs") ClubMembership.objects.create(club=self.club, member=member, season=self.season, kind=ClubMembership.Kind.MEMBER, status=ClubMembership.StatusChoices.ACTIVE) self.client.force_login(self.admin_user) response = self.club_get("home") # self.admin_user also has an open item for the same requirement -- 2 total. self.assertTrue(response.context["requirements_configured"]) self.assertEqual(response.context["missing_documentation_count"], 2) self.assertContains(response, "Missing documentation") self.assertContains(response, "members have an open checklist item") def test_missing_documentation_does_not_appear_once_everyone_is_complete(self): requirement = OnboardingRequirement.objects.create(club=self.club, name="Medical certificate") membership = ClubMembership.objects.get(club=self.club, member=self.admin_member, season=self.season) MemberRequirementStatus.objects.create(membership=membership, requirement=requirement, is_complete=True) self.client.force_login(self.admin_user) response = self.club_get("home") self.assertEqual(response.context["missing_documentation_count"], 0) self.assertNotContains(response, "have an open checklist item") def test_the_user_menu_links_to_password_mfa_and_logout(self): # base.html's own chrome, exercised through any management page. self.client.force_login(self.admin_user) response = self.club_get("home") self.assertContains(response, f'href="{reverse("account_change_password")}"') self.assertContains(response, f'href="{reverse("mfa_index")}"') self.assertContains(response, f'href="{reverse("account_logout")}"') def test_published_news_is_shown_to_everyone(self): item = News.objects.create(club=self.club, title="Season kickoff", body="Body.") item.publish() self.client.force_login(self.make_coach("coach-news-home@example.com")) response = self.club_get("home") self.assertContains(response, "Season kickoff") def test_a_draft_or_scheduled_news_item_is_not_shown_on_the_home_page(self): draft = News.objects.create(club=self.club, title="Still a draft", body="Body.") scheduled = News.objects.create(club=self.club, title="Scheduled for later", body="Body.") scheduled.publish(at=timezone.now() + datetime.timedelta(days=7)) self.client.force_login(self.admin_user) response = self.club_get("home") self.assertNotContains(response, draft.title) self.assertNotContains(response, scheduled.title) def test_upcoming_events_are_listed_in_order_and_future_only(self): now = timezone.now() past = Event.objects.create(club=self.club, kind=Event.EventKind.TRAINING, title="Past training", start=now - datetime.timedelta(days=1)) soon = Event.objects.create(club=self.club, kind=Event.EventKind.TRAINING, title="Sooner training", start=now + datetime.timedelta(days=1)) later = Event.objects.create(club=self.club, kind=Event.EventKind.GAME, title="Later game", start=now + datetime.timedelta(days=5)) self.client.force_login(self.admin_user) response = self.club_get("home") body = response.content.decode() self.assertNotIn(past.title, body) self.assertLess(body.index(soon.title), body.index(later.title)) def test_loads_fine_with_no_season_and_no_events(self): # A fresh club, never given a season -- ClubMembership.season is PROTECT, so # this is a new club rather than deleting self.season out from under setUp's # own ClubMembership. empty_club = Club.objects.create(name="Empty FC", slug="empty-fc") admin_member = Member.objects.create(first_name="Empty", last_name="Admin") ClubRole.objects.create(club=empty_club, member=admin_member, role=ClubRole.Roles.ADMIN) admin_user = User.objects.create_user(email="emptyadmin@example.com", password="pw-secret-123") admin_member.user = admin_user admin_member.save() enrol_mfa(admin_user) self.client.force_login(admin_user) with override_settings(ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "empty-fc.rosterchief.app", "testserver"]): response = self.client.get(reverse("management:home"), HTTP_HOST="empty-fc.rosterchief.app") self.assertEqual(response.status_code, 200) self.assertContains(response, "cannot take a signup") self.assertContains(response, "Nothing scheduled.") class MemberBulkImportTests(ManagementTestBase): def setUp(self): super().setUp() self.client.force_login(self.admin_user) def make_non_admin_staff(self): coach_user = User.objects.create_user(email="coach-import@example.com", password="pw-secret-123") coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") team = Team.objects.create(club=self.club, name="U14", short_name="U14") position = Position.objects.create(club=self.club, name="CoachImport", short_name="CI", staff_position=True) StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) return coach_user def test_template_download_has_expected_headers_and_dropdowns(self): response = self.club_get("member_import_template") self.assertEqual(response.status_code, 200) self.assertEqual(response["Content-Type"], XLSX_CONTENT_TYPE) workbook = openpyxl.load_workbook(BytesIO(response.content)) sheet = workbook.active header = [cell.value for cell in sheet[1]] self.assertEqual(header, TEMPLATE_COLUMNS) self.assertTrue(sheet.data_validations.dataValidation) def test_non_admin_can_download_template_but_not_upload(self): coach_user = self.make_non_admin_staff() self.client.force_login(coach_user) template_response = self.club_get("member_import_template") upload_response = self.club_post("member_import", {"file": make_import_workbook([["Jamie", "Kid", "", "jamie@example.com", "", "", "", "", ""]])}) self.assertEqual(template_response.status_code, 200) self.assertEqual(upload_response.status_code, 403) def test_anonymous_is_redirected_to_login_for_both(self): self.client.logout() self.assertEqual(self.club_get("member_import_template").status_code, 302) self.assertEqual(self.club_get("member_import").status_code, 302) def test_a_clean_row_previews_as_will_create(self): upload = make_import_workbook([["Jamie", "Kid", "2010-01-01", "jamie.kid@example.com", "+32470111111", "", "", "", ""]]) response = self.club_post("member_import", {"file": upload}) self.assertEqual(response.context["valid_count"], 1) self.assertEqual(response.context["skipped_count"], 0) result = response.context["results"][0] self.assertIsNotNone(result["member"]) self.assertEqual(result["membership_kwargs"]["status"], ClubMembership.StatusChoices.ACTIVE) self.assertEqual(result["membership_kwargs"]["fee_status"], ClubMembership.FeeStatus.UNPAID) def test_a_row_missing_a_required_name_is_skipped(self): upload = make_import_workbook([["", "Noname", "", "", "", "", "", "", ""]]) response = self.club_post("member_import", {"file": upload}) self.assertEqual(response.context["valid_count"], 0) result = response.context["results"][0] self.assertIsNone(result["member"]) self.assertTrue(result["errors"]) def test_an_invalid_email_is_skipped(self): upload = make_import_workbook([["Bad", "Email", "", "not-an-email", "", "", "", "", ""]]) response = self.club_post("member_import", {"file": upload}) result = response.context["results"][0] self.assertIsNone(result["member"]) def test_an_invalid_status_value_is_skipped(self): upload = make_import_workbook([["Bad", "Status", "", "bad.status@example.com", "", "", "", "not-a-status", ""]]) response = self.club_post("member_import", {"file": upload}) result = response.context["results"][0] self.assertIsNone(result["member"]) self.assertTrue(any("status" in error.lower() for error in result["errors"])) def test_a_duplicate_email_within_the_file_is_flagged_on_the_second_row(self): upload = make_import_workbook( [ ["First", "Dup", "", "dup@example.com", "", "", "", "", ""], ["Second", "Dup", "", "dup@example.com", "", "", "", "", ""], ] ) response = self.club_post("member_import", {"file": upload}) results = response.context["results"] self.assertIsNotNone(results[0]["member"]) self.assertIsNone(results[1]["member"]) self.assertIn("Duplicate email in this file.", results[1]["errors"]) def test_an_email_already_in_the_club_is_skipped(self): upload = make_import_workbook([["Ada", "Admin", "", self.admin_user.email, "", "", "", "", ""]]) response = self.club_post("member_import", {"file": upload}) result = response.context["results"][0] self.assertIsNone(result["member"]) self.assertIn("Already a member of this club.", result["errors"]) def test_confirm_creates_only_the_valid_rows_with_defaults(self): upload = make_import_workbook( [ ["Jamie", "Kid", "2010-01-01", "jamie.kid@example.com", "", "", "LIC-1", "", ""], ["", "Noname", "", "", "", "", "", "", ""], ] ) self.club_post("member_import", {"file": upload}) response = self.club_post("member_import_confirm", {}) self.assertRedirects(response, reverse("management:member_list")) member = Member.objects.get(email="jamie.kid@example.com") membership = ClubMembership.objects.get(club=self.club, member=member) self.assertEqual(membership.status, ClubMembership.StatusChoices.ACTIVE) self.assertEqual(membership.fee_status, ClubMembership.FeeStatus.UNPAID) self.assertEqual(membership.license, "LIC-1") self.assertFalse(Member.objects.filter(last_name="Noname").exists()) def test_confirm_without_a_prior_upload_creates_nothing(self): response = self.club_post("member_import_confirm", {}) self.assertRedirects(response, reverse("management:member_import")) self.assertEqual(Member.objects.filter(last_name="Kid").count(), 0) def test_confirm_is_admin_only(self): coach_user = self.make_non_admin_staff() upload = make_import_workbook([["Jamie", "Kid", "", "jamie.kid2@example.com", "", "", "", "", ""]]) self.club_post("member_import", {"file": upload}) self.client.force_login(coach_user) response = self.club_post("member_import_confirm", {}) self.assertEqual(response.status_code, 403) def test_a_family_group_links_a_parent_and_child_and_grants_the_parent_a_login(self): upload = make_import_workbook( [ ["Taylor", "Doe", "", "taylor.doe@example.com", "", "", "", "", "", "Doe family", "parent"], ["Jamie", "Doe", "2014-03-02", "", "", "", "", "", "", "Doe family", "child"], ] ) self.club_post("member_import", {"file": upload}) self.club_post("member_import_confirm", {}) parent = Member.objects.get(email="taylor.doe@example.com") child = Member.objects.get(first_name="Jamie", last_name="Doe") self.assertIsNotNone(parent.user_id) self.assertTrue(User.objects.filter(email="taylor.doe@example.com").exists()) self.assertIsNone(child.user_id) family = Family.objects.get(memberships__member=parent) self.assertEqual(family, Family.objects.get(memberships__member=child)) self.assertEqual(FamilyMembership.objects.get(family=family, member=parent).role, FamilyMembership.FamilyRole.PARENT) self.assertEqual(FamilyMembership.objects.get(family=family, member=child).role, FamilyMembership.FamilyRole.CHILD) def test_membership_kind_guardian_creates_a_guardian_not_a_member(self): # Columns are positional; membership_kind is the last one. upload = make_import_workbook( [ ["Taylor", "Doe", "", "taylor.guardian@example.com", "", "", "", "", "", "Doe family", "parent", "guardian"], ["Jamie", "Doe", "2014-03-02", "", "", "", "", "", "", "Doe family", "child", "member"], ] ) self.club_post("member_import", {"file": upload}) self.club_post("member_import_confirm", {}) parent = Member.objects.get(email="taylor.guardian@example.com") child = Member.objects.get(first_name="Jamie", last_name="Doe") self.assertEqual(ClubMembership.objects.get(club=self.club, member=parent).kind, ClubMembership.Kind.GUARDIAN) self.assertEqual(ClubMembership.objects.get(club=self.club, member=child).kind, ClubMembership.Kind.MEMBER) def test_a_blank_membership_kind_still_means_member(self): # Every file written before the column existed carried this implicitly. upload = make_import_workbook([["Solo", "Blankkind", "", "solo.blank@example.com", "", "", "", "", "", "", "", ""]]) self.club_post("member_import", {"file": upload}) self.club_post("member_import_confirm", {}) member = Member.objects.get(email="solo.blank@example.com") self.assertEqual(ClubMembership.objects.get(club=self.club, member=member).kind, ClubMembership.Kind.MEMBER) def test_a_lone_child_row_gets_a_family_of_its_own(self): # The migration case: children arrive with no parents on file. A family of # one is what makes "nobody responsible for this child" visible -- see # members.services.claims.families_awaiting_a_parent. upload = make_import_workbook([["Jamie", "Lonechild", "2014-03-02", "", "", "", "", "", "", "", "child", ""]]) self.club_post("member_import", {"file": upload}) self.club_post("member_import_confirm", {}) child = Member.objects.get(first_name="Jamie", last_name="Lonechild") self.assertIn(child, children_awaiting_a_parent(self.club)) def test_a_lone_parent_row_is_still_an_error(self): # Only `child` is meaningful without a family_group; a parent with nobody # to be a parent *of* is a mistake in the file. upload = make_import_workbook([["Odd", "Loneparent", "", "odd.lone@example.com", "", "", "", "", "", "", "parent", ""]]) response = self.club_post("member_import", {"file": upload}) result = response.context["results"][0] self.assertIsNone(result["member"]) self.assertTrue(any("family_group" in error for error in result["errors"])) def test_a_child_marked_as_a_guardian_is_an_error(self): # A child is the member the guardian is attached *to*. upload = make_import_workbook([["Jamie", "Doe", "2014-03-02", "", "", "", "", "", "", "Doe family", "child", "guardian"]]) response = self.club_post("member_import", {"file": upload}) result = response.context["results"][0] self.assertIsNone(result["member"]) self.assertTrue(any("child is always a member" in error.lower() for error in result["errors"])) def test_an_invalid_membership_kind_is_reported(self): upload = make_import_workbook([["Odd", "Kind", "", "odd.kind@example.com", "", "", "", "", "", "", "", "sponsor"]]) response = self.club_post("member_import", {"file": upload}) result = response.context["results"][0] self.assertIsNone(result["member"]) self.assertTrue(any("membership_kind" in error for error in result["errors"])) def test_family_role_without_a_group_is_an_error(self): upload = make_import_workbook([["Odd", "Row", "", "odd.row@example.com", "", "", "", "", "", "", "parent"]]) response = self.club_post("member_import", {"file": upload}) result = response.context["results"][0] self.assertIsNone(result["member"]) self.assertTrue(any("family_group" in error.lower() for error in result["errors"])) def test_family_group_without_a_role_is_an_error(self): upload = make_import_workbook([["Odd", "Row", "", "odd.row2@example.com", "", "", "", "", "", "Odd family", ""]]) response = self.club_post("member_import", {"file": upload}) result = response.context["results"][0] self.assertIsNone(result["member"]) self.assertTrue(any("family_role" in error.lower() for error in result["errors"])) def test_a_standalone_row_is_not_linked_to_any_family(self): upload = make_import_workbook([["Solo", "Standalone", "", "solo@example.com", "", "", "", "", "", "", ""]]) self.club_post("member_import", {"file": upload}) self.club_post("member_import_confirm", {}) member = Member.objects.get(email="solo@example.com") self.assertFalse(FamilyMembership.objects.filter(member=member).exists()) class NewsManagementTests(ManagementTestBase): @classmethod def setUpTestData(cls): super().setUpTestData() cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st") # The three actors this class draws its lines between: a coach with a # management position (may draft, may not publish), a plain staff member # (may not touch news at all), and an EDITOR (may publish and may keep # editing afterwards). cls.coach_manager = User.objects.create_user(email="coach-news@example.com", password="pw-secret-123") coach_member = Member.objects.create(user=cls.coach_manager, first_name="Cara", last_name="Coach") coach_position = Position.objects.create(club=cls.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) StaffAssignment.objects.create(team=cls.team, member=coach_member, season=cls.season, position=coach_position) cls.plain_staff = User.objects.create_user(email="physio-news@example.com", password="pw-secret-123") staff_member = Member.objects.create(user=cls.plain_staff, first_name="Pat", last_name="Physio") staff_position = Position.objects.create(club=cls.club, name="Physio", short_name="PH", staff_position=True, management_position=False) StaffAssignment.objects.create(team=cls.team, member=staff_member, season=cls.season, position=staff_position) cls.editor = User.objects.create_user(email="editor-news@example.com", password="pw-secret-123") editor_member = Member.objects.create(user=cls.editor, first_name="Eve", last_name="Editor") ClubMembership.objects.create(club=cls.club, member=editor_member, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE) ClubRole.objects.filter(club=cls.club, member=editor_member).update(role=ClubRole.Roles.EDITOR) enrol_mfa(cls.editor) # ClubRole ADMIN/EDITOR requires a second factor; StaffAssignment-only doesn't. def test_list_is_scoped_to_the_club(self): other_club = Club.objects.create(name="Rival FC", slug="rival-fc") News.objects.create(club=other_club, title="Rival news", body="Body.") self.client.force_login(self.admin_user) response = self.club_get("news_list") self.assertNotContains(response, "Rival news") def test_the_status_filter_chips_count_each_bucket(self): News.objects.create(club=self.club, title="Draft one", body="Body.") News.objects.create(club=self.club, title="Draft two", body="Body.") News.objects.create(club=self.club, title="Live", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now()) News.objects.create(club=self.club, title="Upcoming", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now() + datetime.timedelta(days=3)) self.client.force_login(self.admin_user) response = self.club_get("news_list") self.assertEqual(response.context["counts"], {"all": 4, "draft": 2, "scheduled": 1, "published": 1}) def test_the_draft_filter_chip_narrows_the_list_to_drafts(self): News.objects.create(club=self.club, title="A draft", body="Body.") News.objects.create(club=self.club, title="Already live", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now()) self.client.force_login(self.admin_user) response = self.club_get("news_list", params={"status": "draft"}) self.assertContains(response, "A draft") self.assertNotContains(response, "Already live") def test_selecting_an_item_previews_it_on_the_right(self): News.objects.create(club=self.club, title="First post", body="First body.") second = News.objects.create(club=self.club, title="Second post", body="Second body.") self.client.force_login(self.admin_user) response = self.club_get("news_list", params={"selected": str(second.pk)}) self.assertEqual(response.context["news_item"], second) self.assertContains(response, "Second body.") self.assertNotContains(response, "First body.") def test_the_first_item_previews_by_default_when_none_is_selected(self): # News.Meta.ordering is "-created", so the most recently created row # (second) sorts first and is what should preview with no ?selected=. News.objects.create(club=self.club, title="First post", body="First body.") second = News.objects.create(club=self.club, title="Second post", body="Second body.") self.client.force_login(self.admin_user) response = self.club_get("news_list") self.assertEqual(response.context["news_item"], second) def test_a_coach_manager_can_create_a_draft(self): self.client.force_login(self.coach_manager) response = self.club_post("news_create", {"title": "Season Kickoff", "body": "Big news.", "visibility": News.Visibility.INTERNAL, "teams": [str(self.team.pk)]}) item = News.objects.get(club=self.club, title="Season Kickoff") self.assertRedirects(response, reverse("management:news_detail", args=[item.pk])) self.assertEqual(item.status, News.Status.DRAFT) def test_an_english_translation_can_be_added_alongside_the_original(self): self.client.force_login(self.coach_manager) self.club_post( "news_create", {"title": "Seizoensstart", "title_en": "Season kickoff", "body": "We beginnen het seizoen.", "body_en": "We're starting the season.", "visibility": News.Visibility.INTERNAL, "teams": [str(self.team.pk)]}, ) item = News.objects.get(club=self.club, title="Seizoensstart") self.assertEqual(item.title_en, "Season kickoff") self.assertEqual(item.body_en, "We're starting the season.") def test_the_english_translation_is_optional(self): self.client.force_login(self.coach_manager) response = self.club_post("news_create", {"title": "Seizoensstart", "body": "We beginnen het seizoen.", "visibility": News.Visibility.INTERNAL, "teams": [str(self.team.pk)]}) item = News.objects.get(club=self.club, title="Seizoensstart") self.assertRedirects(response, reverse("management:news_detail", args=[item.pk])) self.assertEqual(item.title_en, "") def test_detail_page_shows_the_english_translation_when_set(self): item = News.objects.create(club=self.club, title="Seizoensstart", body="We beginnen het seizoen.", title_en="Season kickoff", body_en="We're starting the season.") self.client.force_login(self.admin_user) response = self.club_get("news_detail", item.pk) self.assertContains(response, "Season kickoff") self.assertContains(response, "We're starting the season.") def test_detail_page_notes_a_missing_english_translation(self): # The NL/EN toggle is always present now (not conditional on a # translation existing) -- untranslated items instead get a fallback # note, and the EN pane falls back to showing the Dutch content # (News.effective_title_en/effective_body_en) rather than sitting empty. item = News.objects.create(club=self.club, title="Seizoensstart", body="We beginnen het seizoen.") self.client.force_login(self.admin_user) response = self.club_get("news_detail", item.pk) self.assertContains(response, "No English translation yet") self.assertContains(response, 'data-lang-content="en"') def test_plain_staff_cannot_create_news(self): self.client.force_login(self.plain_staff) response = self.club_post("news_create", {"title": "Not allowed", "body": "Body.", "visibility": News.Visibility.INTERNAL}) self.assertEqual(response.status_code, 403) self.assertFalse(News.objects.filter(club=self.club, title="Not allowed").exists()) def test_coach_manager_cannot_publish(self): item = News.objects.create(club=self.club, title="Draft item", body="Body.") self.client.force_login(self.coach_manager) response = self.club_post("news_publish", {"published_at": "2026-08-10T10:00"}, item.pk) self.assertEqual(response.status_code, 403) item.refresh_from_db() self.assertEqual(item.status, News.Status.DRAFT) def test_editor_can_publish(self): item = News.objects.create(club=self.club, title="Draft item", body="Body.") self.client.force_login(self.editor) self.club_post("news_publish", {"published_at": "2026-08-10T10:00"}, item.pk) item.refresh_from_db() self.assertEqual(item.status, News.Status.PUBLISHED) def test_publishing_with_a_future_date_leaves_it_scheduled(self): item = News.objects.create(club=self.club, title="Draft item", body="Body.") self.client.force_login(self.editor) future = timezone.now() + datetime.timedelta(days=7) self.club_post("news_publish", {"published_at": future.strftime("%Y-%m-%dT%H:%M")}, item.pk) item.refresh_from_db() self.assertTrue(item.is_scheduled) def test_publishing_with_now_makes_it_live_immediately(self): item = News.objects.create(club=self.club, title="Draft item", body="Body.") self.client.force_login(self.editor) self.club_post("news_publish", {"published_at": timezone.now().strftime("%Y-%m-%dT%H:%M")}, item.pk) item.refresh_from_db() self.assertFalse(item.is_scheduled) def test_notify_members_checkbox_emails_the_audience(self): member = Member.objects.create(first_name="Jamie", last_name="Doe", email="jamie@example.com") ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) User.objects.create_user(email="jamie@example.com", password="pw-secret-123") member.user = User.objects.get(email="jamie@example.com") member.save(update_fields=["user"]) item = News.objects.create(club=self.club, title="Draft item", body="Body.") self.client.force_login(self.editor) self.club_post("news_publish", {"published_at": timezone.now().strftime("%Y-%m-%dT%H:%M"), "notify_members": "on"}, item.pk) # Not asserting an exact outbox size: the base fixture's own staff # members (self.editor etc.) may also be active MEMBER-kind club # members in this same season, so they legitimately get one too. sent_to = [address for message in mail.outbox for address in message.to] self.assertIn("jamie@example.com", sent_to) def test_leaving_notify_members_unchecked_sends_nothing(self): member = Member.objects.create(first_name="Jamie", last_name="Doe", email="jamie@example.com") ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) item = News.objects.create(club=self.club, title="Draft item", body="Body.") self.client.force_login(self.editor) self.club_post("news_publish", {"published_at": timezone.now().strftime("%Y-%m-%dT%H:%M")}, item.pk) self.assertEqual(len(mail.outbox), 0) def test_unpublishing_reverts_to_draft(self): item = News.objects.create(club=self.club, title="Live item", body="Body.") item.publish() self.client.force_login(self.editor) self.club_post("news_unpublish", {}, item.pk) item.refresh_from_db() self.assertEqual(item.status, News.Status.DRAFT) self.assertIsNone(item.published_at) def test_a_coach_manager_can_edit_someone_elses_draft(self): item = News.objects.create(club=self.club, title="Old title", body="Body.") self.client.force_login(self.coach_manager) self.club_post("news_update", {"title": "New title", "body": "Body.", "visibility": News.Visibility.INTERNAL}, item.pk) item.refresh_from_db() self.assertEqual(item.title, "New title") def test_a_coach_manager_cannot_edit_once_published(self): item = News.objects.create(club=self.club, title="Old title", body="Body.") item.publish() self.client.force_login(self.coach_manager) response = self.club_post("news_update", {"title": "New title", "body": "Body.", "visibility": News.Visibility.INTERNAL}, item.pk) self.assertEqual(response.status_code, 403) def test_an_editor_can_still_edit_once_published(self): item = News.objects.create(club=self.club, title="Old title", body="Body.") item.publish() self.client.force_login(self.editor) self.club_post("news_update", {"title": "New title", "body": "Body.", "visibility": News.Visibility.INTERNAL}, item.pk) item.refresh_from_db() self.assertEqual(item.title, "New title") def test_uploading_multiple_photos_creates_one_per_file_and_marks_the_first_main(self): item = News.objects.create(club=self.club, title="Match report", body="Body.") self.client.force_login(self.coach_manager) images = [ SimpleUploadedFile("one.jpg", b"fake-bytes-one", content_type="image/jpeg"), SimpleUploadedFile("two.jpg", b"fake-bytes-two", content_type="image/jpeg"), ] self.club_post("news_photo_upload", {"images": images}, item.pk) self.assertEqual(item.photos.count(), 2) self.assertEqual(item.photos.filter(is_main=True).count(), 1) def test_set_main_moves_the_main_flag(self): item = News.objects.create(club=self.club, title="Match report", body="Body.") first = NewsPhoto.objects.create(news_item=item, image=SimpleUploadedFile("one.jpg", b"one", content_type="image/jpeg"), is_main=True) second = NewsPhoto.objects.create(news_item=item, image=SimpleUploadedFile("two.jpg", b"two", content_type="image/jpeg"), is_main=False) self.client.force_login(self.coach_manager) self.club_post("news_photo_set_main", {}, item.pk, second.pk) first.refresh_from_db() second.refresh_from_db() self.assertFalse(first.is_main) self.assertTrue(second.is_main) def test_deleting_a_photo_removes_it(self): item = News.objects.create(club=self.club, title="Match report", body="Body.") photo = NewsPhoto.objects.create(news_item=item, image=SimpleUploadedFile("one.jpg", b"one", content_type="image/jpeg")) photo_path = photo.image.path self.client.force_login(self.coach_manager) self.club_post("news_photo_delete", {}, item.pk, photo.pk) self.assertFalse(NewsPhoto.objects.filter(pk=photo.pk).exists()) self.assertFalse(os.path.exists(photo_path)) def test_deleting_the_main_photo_promotes_another_one(self): item = News.objects.create(club=self.club, title="Match report", body="Body.") main = NewsPhoto.objects.create(news_item=item, image=SimpleUploadedFile("one.jpg", b"one", content_type="image/jpeg"), is_main=True) other = NewsPhoto.objects.create(news_item=item, image=SimpleUploadedFile("two.jpg", b"two", content_type="image/jpeg"), is_main=False) self.client.force_login(self.coach_manager) self.club_post("news_photo_delete", {}, item.pk, main.pk) other.refresh_from_db() self.assertTrue(other.is_main) def test_deleting_the_only_photo_leaves_nothing_to_promote(self): item = News.objects.create(club=self.club, title="Match report", body="Body.") photo = NewsPhoto.objects.create(news_item=item, image=SimpleUploadedFile("one.jpg", b"one", content_type="image/jpeg"), is_main=True) self.client.force_login(self.coach_manager) response = self.club_post("news_photo_delete", {}, item.pk, photo.pk) self.assertEqual(response.status_code, 302) self.assertEqual(item.photos.count(), 0) def test_a_coach_manager_can_delete_a_draft(self): item = News.objects.create(club=self.club, title="Draft item", body="Body.") self.client.force_login(self.coach_manager) response = self.club_post("news_delete", {}, item.pk) self.assertRedirects(response, reverse("management:news_list")) self.assertFalse(News.objects.filter(pk=item.pk).exists()) def test_a_coach_manager_cannot_delete_once_published(self): item = News.objects.create(club=self.club, title="Live item", body="Body.") item.publish() self.client.force_login(self.coach_manager) response = self.club_post("news_delete", {}, item.pk) self.assertEqual(response.status_code, 403) self.assertTrue(News.objects.filter(pk=item.pk).exists()) def test_an_editor_can_delete_once_published(self): item = News.objects.create(club=self.club, title="Live item", body="Body.") item.publish() self.client.force_login(self.editor) self.club_post("news_delete", {}, item.pk) self.assertFalse(News.objects.filter(pk=item.pk).exists()) def test_deleting_a_news_item_removes_its_photos(self): item = News.objects.create(club=self.club, title="Match report", body="Body.") photo = NewsPhoto.objects.create(news_item=item, image=SimpleUploadedFile("one.jpg", b"one", content_type="image/jpeg")) photo_path = photo.image.path self.client.force_login(self.coach_manager) self.club_post("news_delete", {}, item.pk) self.assertFalse(NewsPhoto.objects.filter(pk=photo.pk).exists()) self.assertFalse(os.path.exists(photo_path)) def test_the_edit_and_delete_buttons_are_hidden_once_published_for_a_coach_manager(self): item = News.objects.create(club=self.club, title="Live item", body="Body.") item.publish() self.client.force_login(self.coach_manager) response = self.club_get("news_list") self.assertNotContains(response, reverse("management:news_update", args=[item.pk])) class TeamAttendancePanelTests(ManagementTestBase): """The attendance KPI panel on the team page -- see management.views.TeamDetailView and events.services.attendance.""" @classmethod def setUpTestData(cls): super().setUpTestData() 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", staff_position=False) cls.player = Member.objects.create(first_name="Peter", last_name="Player") ClubMembership.objects.create(club=cls.club, member=cls.player, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE) TeamMembership.objects.create(team=cls.team, season=cls.season, member=cls.player, position=cls.position) def make_past_training(self, days_ago=1): event = Event.objects.create(club=self.club, title="Practice", kind=Event.EventKind.TRAINING, season=self.season, start=timezone.now() - datetime.timedelta(days=days_ago)) event.teams.add(self.team) return event def test_attendance_panel_shows_the_rate_and_rankings(self): event = self.make_past_training() Attendance.objects.create(event=event, member=self.player, status=Attendance.AttendanceStatus.PRESENT) self.client.force_login(self.admin_user) response = self.club_get("team_detail", self.team.pk) self.assertContains(response, "Attendance rate") self.assertContains(response, "Peter Player") def test_a_present_rsvp_without_a_check_in_is_never_a_no_show(self): event = self.make_past_training() Attendance.objects.create(event=event, member=self.player, status=Attendance.AttendanceStatus.PRESENT) self.client.force_login(self.admin_user) response = self.club_get("team_detail", self.team.pk) self.assertContains(response, "None recorded.") def test_a_checked_in_no_show_appears_in_the_panel(self): event = self.make_past_training() attendance = Attendance.objects.create(event=event, member=self.player, status=Attendance.AttendanceStatus.PRESENT, showed_up=False) self.client.force_login(self.admin_user) response = self.club_get("team_detail", self.team.pk) self.assertContains(response, "Peter Player") self.assertContains(response, attendance.event.title) self.assertNotContains(response, "None recorded.") class TeamAndPositionAccessTests(ManagementTestBase): """Non-admin coaches/managers: scoped to their own teams, read-only on positions -- see club.mixins.TeamManagerRequiredMixin and management.views.TeamListView/PositionListView.""" @classmethod def setUpTestData(cls): super().setUpTestData() cls.own_team = Team.objects.create(club=cls.club, name="First Team", short_name="1st") cls.other_team = Team.objects.create(club=cls.club, name="Second Team", short_name="2nd") cls.manager_position = Position.objects.create(club=cls.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) cls.coach_user = User.objects.create_user(email="coach3@example.com", password="pw-secret-123") coach_member = Member.objects.create(user=cls.coach_user, first_name="Cara", last_name="Coach") StaffAssignment.objects.create(team=cls.own_team, member=coach_member, season=cls.season, position=cls.manager_position) def test_a_coach_only_sees_their_own_team_in_the_list(self): self.client.force_login(self.coach_user) response = self.club_get("team_list") self.assertContains(response, "First Team") self.assertNotContains(response, "Second Team") def test_an_admin_sees_every_team_in_the_list(self): self.client.force_login(self.admin_user) response = self.club_get("team_list") self.assertContains(response, "First Team") self.assertContains(response, "Second Team") def test_a_coach_does_not_see_the_new_team_button(self): self.client.force_login(self.coach_user) response = self.club_get("team_list") self.assertNotContains(response, reverse("management:team_create")) def test_a_coach_does_not_see_the_edit_button_on_their_team_page(self): self.client.force_login(self.coach_user) response = self.club_get("team_detail", self.own_team.pk) self.assertNotContains(response, reverse("management:team_update", args=[self.own_team.pk])) def test_a_coach_can_view_positions_but_not_edit_them(self): self.client.force_login(self.coach_user) response = self.club_get("position_list") self.assertEqual(response.status_code, 200) self.assertContains(response, "Head Coach") self.assertNotContains(response, reverse("management:position_create")) self.assertNotContains(response, reverse("management:position_update", args=[self.manager_position.pk])) def test_a_coach_cannot_create_or_edit_a_position(self): self.client.force_login(self.coach_user) self.assertEqual(self.club_get("position_create").status_code, 403) self.assertEqual(self.club_get("position_update", self.manager_position.pk).status_code, 403) class TeamListCountsTests(ManagementTestBase): """Player/staff counts on the team list -- see TeamListView.get_queryset.""" @classmethod def setUpTestData(cls): super().setUpTestData() cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st") cls.player_position = Position.objects.create(club=cls.club, name="Forward", short_name="FW", staff_position=False) cls.coach_position = Position.objects.create(club=cls.club, name="Coach", short_name="C", staff_position=True, management_position=True) def setUp(self): super().setUp() self.client.force_login(self.admin_user) def test_counts_reflect_the_current_seasons_roster_and_staff(self): peter = Member.objects.create(first_name="Peter", last_name="Player") paula = Member.objects.create(first_name="Paula", last_name="Player") cara = Member.objects.create(first_name="Cara", last_name="Coach") TeamMembership.objects.create(team=self.team, season=self.season, member=peter, position=self.player_position) TeamMembership.objects.create(team=self.team, season=self.season, member=paula, position=self.player_position) StaffAssignment.objects.create(team=self.team, season=self.season, member=cara, position=self.coach_position) response = self.club_get("team_list") # Not .get(pk=...): the team list is now paginated, and a Page's # object_list is a sliced queryset -- Django refuses to .filter()/.get() # a queryset once it's been sliced. team = next(t for t in response.context["teams"] if t.pk == self.team.pk) self.assertEqual(team.player_count, 2) self.assertEqual(team.staff_count, 1) def test_counts_exclude_a_different_season(self): other_season = Season.objects.create(club=self.club, start_date=datetime.date(2020, 1, 1), end_date=datetime.date(2020, 12, 31)) peter = Member.objects.create(first_name="Peter", last_name="Player") TeamMembership.objects.create(team=self.team, season=other_season, member=peter, position=self.player_position) response = self.club_get("team_list") team = next(t for t in response.context["teams"] if t.pk == self.team.pk) self.assertEqual(team.player_count, 0) class LocationOpponentManagementTests(ManagementTestBase): """Full CRUD for Location/Opponent -- restricted to ADMIN and anyone with a current-season management position, see club.mixins.ManagementPositionRequiredMixin and management.views.LocationListView/OpponentListView (and their Create/Update/Delete siblings).""" @classmethod def setUpTestData(cls): super().setUpTestData() cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st") # The two actors either side of the line this class draws: a management # position (allowed) and a plain staff position (refused). cls.coach_manager = User.objects.create_user(email="coach-loc@example.com", password="pw-secret-123") coach_member = Member.objects.create(user=cls.coach_manager, first_name="Cara", last_name="Coach") coach_position = Position.objects.create(club=cls.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) StaffAssignment.objects.create(team=cls.team, member=coach_member, season=cls.season, position=coach_position) cls.plain_staff = User.objects.create_user(email="physio-loc@example.com", password="pw-secret-123") staff_member = Member.objects.create(user=cls.plain_staff, first_name="Pat", last_name="Physio") staff_position = Position.objects.create(club=cls.club, name="Physio", short_name="PH", staff_position=True, management_position=False) StaffAssignment.objects.create(team=cls.team, member=staff_member, season=cls.season, position=staff_position) # --- Locations --------------------------------------------------------- def test_a_management_position_can_view_the_location_list(self): Location.objects.create(club=self.club, name="Main Field", address="1 St", city="Town", zip_code="1000", country="BE") self.client.force_login(self.coach_manager) response = self.club_get("location_list") self.assertEqual(response.status_code, 200) self.assertContains(response, "Main Field") def test_the_location_form_renders_country_as_a_dropdown(self): # Regression: CountryField's widget reports widget_type "lazyselect", which # the form_field templatetag didn't recognise -- it fell through to the # "input" case and rendered a plain (i.e. a # broken text box), not a| # | Date | Hour | Location | Home | Visit |
|---|---|---|---|---|---|
| 5002 | 2026-09-12 | 12:15 | Deurne | Sportoase Antwerp Phantoms | Amsterdam Tigers |