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, FeePayment, Season, Sponsor 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.pdf import PDFExportError, _tint_with_white, referee_form_colors, render_pdf 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 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): return self.client.get(reverse(f"management:{name}", args=args), 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="menu-active" href="{reverse("management:member_list")}"') self.assertNotContains(response, f'class="menu-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="menu-active" href="{reverse("management:member_list")}"') def test_the_dashboard_highlights_dashboard_only(self): response = self.club_get("home") self.assertContains(response, f'class="menu-active" href="{reverse("management:home")}"') self.assertNotContains(response, f'class="menu-active" href="{reverse("management:member_list")}"') 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 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", "ordering": 0, "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", "ordering": 0, "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", "ordering": 0, "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") 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 (i.e. a # broken text box), not a