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, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
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.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
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_setup_pages_all_highlight_settings(self):
# Roles/Positions/Referee levels/Referee management 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", "referee_management"):
with self.subTest(name=name):
response = self.club_get(name)
self.assertContains(response, f'class="nav-item active" href="{reverse("management:club_settings")}"')
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 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
"), 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 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_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_hides_the_english_section_when_not_translated(self):
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.assertNotContains(response, ">English<")
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_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 .
self.client.force_login(self.coach_manager)
response = self.club_get("location_create")
self.assertNotContains(response, 'type="lazyselect"')
self.assertContains(response, "Belgium")
self.assertContains(response, " raw RRULE round-trip."""
def test_weekly_round_trips(self):
rrule = build_rrule("weekly", 2, ["WE", "MO"])
self.assertEqual(rrule, "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE")
self.assertEqual(parse_rrule(rrule), {"frequency": "weekly", "interval": 2, "weekdays": ["MO", "WE"]})
self.assertEqual(str(describe_rrule(rrule)), "Every 2 weeks on Mon, Wed")
def test_monthly_round_trips(self):
rrule = build_rrule("monthly", 1)
self.assertEqual(rrule, "FREQ=MONTHLY;INTERVAL=1")
self.assertEqual(parse_rrule(rrule), {"frequency": "monthly", "interval": 1, "weekdays": []})
self.assertEqual(str(describe_rrule(rrule)), "Every month")
def test_an_unrecognised_rrule_falls_back_to_the_raw_string(self):
self.assertIsNone(parse_rrule("FREQ=DAILY;COUNT=5"))
self.assertEqual(describe_rrule("FREQ=DAILY;COUNT=5"), "FREQ=DAILY;COUNT=5")
class EventManagementTests(ManagementTestBase):
"""Event CRUD -- permissions are scoped per-team (like roster/staff), not
club-wide, since an event's teams field is M2M: a manager of at least one
of an event's current teams can edit it, see club.mixins.EventManagerRequiredMixin."""
@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.coach_position = Position.objects.create(club=cls.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True)
# Manager of own_team only -- "may for my team, may not for theirs" is the
# line nearly every test here checks, so it's a standing fixture.
cls.own_team_coach = User.objects.create_user(email="coach-events@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=cls.own_team_coach, first_name="Cara", last_name="Coach")
StaffAssignment.objects.create(team=cls.own_team, member=coach_member, season=cls.season, position=cls.coach_position)
# Staff, but with no management position anywhere: reaches the app, manages nothing.
cls.plain_staff = User.objects.create_user(email="physio-events@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.own_team, member=staff_member, season=cls.season, position=staff_position)
def make_group_member(self, group, email="committee-events@example.com"):
# Staff (so they can reach the management app at all -- ClubStaffRequiredMixin
# excludes a plain MEMBER-only club member entirely), but NOT a team manager of
# anything: a non-management StaffAssignment on an unrelated team, so
# teams_managed_by(this user) is empty and the group claim is what's really
# being tested, isolated from any team-manager claim.
member_user = User.objects.create_user(email=email, password="pw-secret-123")
member = Member.objects.create(user=member_user, first_name="Gale", last_name="Group")
physio_position = Position.objects.create(club=self.club, name="Committee Physio", short_name="CP", staff_position=True, management_position=False)
StaffAssignment.objects.create(team=self.other_team, member=member, season=self.season, position=physio_position)
GroupMembership.objects.create(group=group, member=member)
return member_user
def event_data(self, **overrides):
data = {
"title": "Training",
"kind": "training",
"teams": [str(self.own_team.pk)],
"invited_members": [],
"excluded_members": [],
"location": "",
"opponent": "",
"start": "2026-09-01T18:00",
"end": "",
"gathering": "",
"deadline": "",
"max_referees": "2",
}
data.update(overrides)
return data
def test_a_team_manager_can_create_an_event_for_their_own_team(self):
self.client.force_login(self.own_team_coach)
response = self.club_post("event_create", self.event_data())
event = Event.objects.get(title="Training")
self.assertRedirects(response, reverse("management:event_detail", args=[event.pk]))
self.assertIn(self.own_team, event.teams.all())
def test_creating_an_event_with_a_same_club_location_does_not_raise_a_cross_club_error(self):
# Regression: Event.clean() rejects a location from another club by
# comparing against self.club_id, which was still None on a brand-new
# instance at validation time (club is only auto-assigned in save(),
# which runs after full_clean()) -- so a same-club location falsely
# failed as "must belong to the same club". See EventCreateView.get_form_kwargs.
location = Location.objects.create(club=self.club, name="Home Ground", address="1 St", city="Town", zip_code="1000", country="BE")
self.client.force_login(self.own_team_coach)
response = self.club_post("event_create", self.event_data(location=str(location.pk)))
event = Event.objects.get(title="Training")
self.assertRedirects(response, reverse("management:event_detail", args=[event.pk]))
self.assertEqual(event.location, location)
def test_the_location_dropdown_shows_the_city(self):
Location.objects.create(club=self.club, name="Sportcentrum", address="Straat 1", city="Mechelen", zip_code="2800", country="BE")
self.client.force_login(self.admin_user)
response = self.club_get("event_create")
self.assertContains(response, "Sportcentrum — Mechelen")
def test_the_location_dropdown_adds_the_country_when_not_belgium(self):
Location.objects.create(club=self.club, name="Rival Hall", address="Rue 2", city="Lille", zip_code="59000", country="FR")
self.client.force_login(self.admin_user)
response = self.club_get("event_create")
self.assertContains(response, "Rival Hall — Lille, France")
def test_the_location_field_is_searchable(self):
self.client.force_login(self.admin_user)
response = self.club_get("event_create")
self.assertContains(response, 'name="location"')
self.assertContains(response, "data-searchable")
def test_a_group_member_can_create_an_event_for_their_own_group(self):
group = Group.objects.create(club=self.club, name="Committee")
self.client.force_login(self.make_group_member(group))
response = self.club_post("event_create", self.event_data(teams=[], groups=[str(group.pk)]))
event = Event.objects.get(title="Training")
self.assertRedirects(response, reverse("management:event_detail", args=[event.pk]))
self.assertIn(group, event.groups.all())
def test_a_group_member_cannot_pick_a_group_they_do_not_belong_to(self):
own_group = Group.objects.create(club=self.club, name="Committee")
other_group = Group.objects.create(club=self.club, name="Other Committee")
self.client.force_login(self.make_group_member(own_group))
response = self.club_post("event_create", self.event_data(teams=[], groups=[str(other_group.pk)]))
self.assertEqual(response.status_code, 200)
self.assertFalse(Event.objects.filter(title="Training").exists())
def test_a_non_admin_with_neither_team_nor_group_is_rejected(self):
self.client.force_login(self.make_group_member(Group.objects.create(club=self.club, name="Committee")))
response = self.club_post("event_create", self.event_data(teams=[], groups=[]))
self.assertEqual(response.status_code, 200)
self.assertFalse(Event.objects.filter(title="Training").exists())
def test_club_wide_is_not_offered_to_a_non_admin(self):
self.client.force_login(self.own_team_coach)
response = self.club_get("event_create")
self.assertNotContains(response, 'name="club_wide"')
def test_a_non_admin_cannot_force_a_club_wide_event(self):
# club_wide isn't in the form for a non-admin, so even a forged POST
# value must not slip through as a create-time claim.
self.client.force_login(self.own_team_coach)
response = self.club_post("event_create", self.event_data(teams=[], club_wide="on"))
self.assertEqual(response.status_code, 200)
event = Event.objects.filter(title="Training").first()
self.assertIsNone(event)
def test_an_admin_can_schedule_a_club_wide_event(self):
self.client.force_login(self.admin_user)
response = self.club_post("event_create", self.event_data(teams=[], club_wide="on"))
event = Event.objects.get(title="Training")
self.assertRedirects(response, reverse("management:event_detail", args=[event.pk]))
self.assertTrue(event.club_wide)
def test_club_wide_cannot_be_combined_with_teams(self):
self.client.force_login(self.admin_user)
response = self.club_post("event_create", self.event_data(club_wide="on"))
self.assertEqual(response.status_code, 200)
self.assertFalse(Event.objects.filter(title="Training").exists())
def test_a_group_member_who_is_not_a_team_manager_can_edit_their_group_event(self):
group = Group.objects.create(club=self.club, name="Committee")
member_user = self.make_group_member(group)
self.client.force_login(member_user)
self.club_post("event_create", self.event_data(teams=[], groups=[str(group.pk)]))
event = Event.objects.get(title="Training")
response = self.club_get("event_update", event.pk)
self.assertEqual(response.status_code, 200)
def test_the_new_event_forms_competition_dropdown_shows_every_competition_regardless_of_flag(self):
# Unlike the Django-admin form, this dropdown isn't filtered by whether the
# competition's flag is active for the club -- see management.forms.EventForm
# and events.services.competitions.fetch_game_info (which is where that
# per-club gate actually lives).
Competition.objects.create(name="Active Cup", module="events.competition.active")
Competition.objects.create(name="Inactive Cup", module="events.competition.inactive")
self.client.force_login(self.own_team_coach)
response = self.club_get("event_create")
self.assertContains(response, "Active Cup")
self.assertContains(response, "Inactive Cup")
def test_a_team_manager_cannot_create_an_event_for_a_team_they_dont_manage(self):
self.client.force_login(self.own_team_coach)
self.club_post("event_create", self.event_data(teams=[str(self.other_team.pk)]))
self.assertFalse(Event.objects.filter(title="Training").exists())
def test_a_plain_staff_member_gets_403_creating_an_event(self):
self.client.force_login(self.plain_staff)
response = self.club_post("event_create", self.event_data())
self.assertEqual(response.status_code, 403)
def test_a_plain_staff_member_can_still_view_the_event_list(self):
self.client.force_login(self.plain_staff)
self.assertEqual(self.club_get("event_list", params={"view": "list"}).status_code, 200)
def test_the_list_shows_edit_and_delete_only_for_events_the_manager_manages(self):
own_event = Event.objects.create(club=self.club, title="My event", start=timezone.now() + datetime.timedelta(days=1))
own_event.teams.add(self.own_team)
other_event = Event.objects.create(club=self.club, title="Other event", start=timezone.now() + datetime.timedelta(days=2))
other_event.teams.add(self.other_team)
self.client.force_login(self.own_team_coach)
response = self.club_get("event_list", params={"view": "list"})
# The Edit link goes to the detail page, not straight to the edit form (same
# convention as Teams/News), so what actually distinguishes a manageable row
# is the delete action being present.
self.assertContains(response, reverse("management:event_delete", args=[own_event.pk]))
self.assertNotContains(response, reverse("management:event_delete", args=[other_event.pk]))
def test_the_week_calendar_marks_a_series_occurrence_with_the_repeat_icon(self):
series = EventSeries.objects.create(club=self.club, title="Weekly Training", kind=Event.EventKind.TRAINING, dtstart=timezone.now(), rrule="FREQ=WEEKLY;COUNT=1")
occurrence = Event.objects.create(club=self.club, title="Weekly Training", start=timezone.now(), series=series)
occurrence.teams.add(self.own_team)
standalone = Event.objects.create(club=self.club, title="One-off Training", start=timezone.now() + datetime.timedelta(hours=1))
standalone.teams.add(self.own_team)
self.client.force_login(self.own_team_coach)
response = self.club_get("event_list") # default view=calendar, range=week
self.assertContains(response, 'aria-label="Part of a series"', count=1)
def test_the_month_calendar_marks_a_series_occurrence_with_the_repeat_icon(self):
series = EventSeries.objects.create(club=self.club, title="Weekly Training", kind=Event.EventKind.TRAINING, dtstart=timezone.now(), rrule="FREQ=WEEKLY;COUNT=1")
occurrence = Event.objects.create(club=self.club, title="Weekly Training", start=timezone.now(), series=series)
occurrence.teams.add(self.own_team)
self.client.force_login(self.own_team_coach)
response = self.club_get("event_list", params={"range": "month"})
self.assertContains(response, 'aria-label="Part of a series"', count=1)
def test_a_manager_only_sees_events_for_teams_they_manage(self):
own_event = Event.objects.create(club=self.club, title="My event", start=timezone.now() + datetime.timedelta(days=1))
own_event.teams.add(self.own_team)
other_event = Event.objects.create(club=self.club, title="Other event", start=timezone.now() + datetime.timedelta(days=2))
other_event.teams.add(self.other_team)
Event.objects.create(club=self.club, title="AGM", start=timezone.now() + datetime.timedelta(days=3))
self.client.force_login(self.own_team_coach)
response = self.club_get("event_list", params={"view": "list"})
self.assertContains(response, "My event")
self.assertNotContains(response, "Other event")
self.assertContains(response, "AGM") # team-less events stay visible to everyone
def test_a_group_member_sees_their_groups_event_without_managing_any_team(self):
group = Group.objects.create(club=self.club, name="Committee")
group_event = Event.objects.create(club=self.club, title="Committee meeting", start=timezone.now() + datetime.timedelta(days=1))
group_event.groups.add(group)
self.client.force_login(self.make_group_member(group))
response = self.club_get("event_list", params={"view": "list"})
self.assertContains(response, "Committee meeting")
def test_a_non_member_does_not_see_another_groups_event(self):
own_group = Group.objects.create(club=self.club, name="Committee")
other_group = Group.objects.create(club=self.club, name="Other Committee")
other_event = Event.objects.create(club=self.club, title="Other group event", start=timezone.now() + datetime.timedelta(days=1))
other_event.groups.add(other_group)
self.client.force_login(self.make_group_member(own_group))
response = self.club_get("event_list", params={"view": "list"})
self.assertNotContains(response, "Other group event")
def test_an_admin_sees_every_event_regardless_of_team(self):
own_event = Event.objects.create(club=self.club, title="My event", start=timezone.now() + datetime.timedelta(days=1))
own_event.teams.add(self.own_team)
other_event = Event.objects.create(club=self.club, title="Other event", start=timezone.now() + datetime.timedelta(days=2))
other_event.teams.add(self.other_team)
self.client.force_login(self.admin_user)
response = self.club_get("event_list", params={"view": "list"})
self.assertContains(response, "My event")
self.assertContains(response, "Other event")
def test_a_manager_cannot_open_another_teams_event_by_url(self):
other_event = Event.objects.create(club=self.club, title="Other event", start=timezone.now() + datetime.timedelta(days=2))
other_event.teams.add(self.other_team)
self.client.force_login(self.own_team_coach)
response = self.club_get("event_detail", other_event.pk)
self.assertEqual(response.status_code, 404)
def test_the_dashboard_only_shows_upcoming_events_for_managed_teams(self):
own_event = Event.objects.create(club=self.club, title="My event", start=timezone.now() + datetime.timedelta(days=1))
own_event.teams.add(self.own_team)
other_event = Event.objects.create(club=self.club, title="Other event", start=timezone.now() + datetime.timedelta(days=2))
other_event.teams.add(self.other_team)
self.client.force_login(self.own_team_coach)
response = self.club_get("home")
self.assertContains(response, "My event")
self.assertNotContains(response, "Other event")
def test_a_team_less_event_is_refused_for_a_non_admin(self):
self.client.force_login(self.own_team_coach)
self.club_post("event_create", self.event_data(teams=[]))
self.assertFalse(Event.objects.filter(title="Training").exists())
def test_an_admin_can_create_a_team_less_event(self):
self.client.force_login(self.admin_user)
self.club_post("event_create", self.event_data(teams=[]))
self.assertTrue(Event.objects.filter(title="Training").exists())
def test_a_manager_of_one_team_cannot_edit_an_event_for_another_team(self):
event = Event.objects.create(club=self.club, title="Other's event", start=timezone.now() + datetime.timedelta(days=1))
event.teams.add(self.other_team)
self.client.force_login(self.own_team_coach)
response = self.club_get("event_update", event.pk)
self.assertEqual(response.status_code, 403)
def test_a_manager_can_edit_their_own_teams_event(self):
event = Event.objects.create(club=self.club, title="My event", start=timezone.now() + datetime.timedelta(days=1))
event.teams.add(self.own_team)
self.client.force_login(self.own_team_coach)
self.club_post("event_update", self.event_data(title="Renamed"), event.pk)
event.refresh_from_db()
self.assertEqual(event.title, "Renamed")
def test_deleting_a_one_off_event_deletes_it(self):
event = Event.objects.create(club=self.club, title="Gone", start=timezone.now() + datetime.timedelta(days=1))
event.teams.add(self.own_team)
self.client.force_login(self.own_team_coach)
self.club_post("event_delete", {}, event.pk)
self.assertFalse(Event.objects.filter(pk=event.pk).exists())
def test_creating_a_game_records_competition_and_external_id_but_not_score(self):
# Score/live status don't exist yet for a game that's only just being
# scheduled -- the add form doesn't even offer those fields (see
# test_the_add_form_has_no_score_or_live_fields below), so posting them
# here has no effect.
Competition.objects.create(name="Regional Cup", module="events.competition.regional")
self.client.force_login(self.own_team_coach)
self.club_post("event_create", self.event_data(kind="game", competition="Regional Cup", external_game_id="ext-42", score_for="3", score_against="1", is_live="on"))
game = Event.objects.get(title="Training")
self.assertEqual(game.kind, Event.EventKind.GAME)
self.assertEqual(game.competition, "Regional Cup")
self.assertEqual(game.external_game_id, "ext-42")
self.assertIsNone(game.score_for)
self.assertFalse(game.is_live)
def test_the_add_form_has_no_score_or_live_fields(self):
self.client.force_login(self.own_team_coach)
response = self.club_get("event_create")
self.assertNotContains(response, 'name="score_for"')
self.assertNotContains(response, 'name="is_live"')
self.assertContains(response, 'name="competition"')
def test_editing_a_game_can_record_its_score_and_live_status(self):
Competition.objects.create(name="Regional Cup", module="events.competition.regional")
game = Event.objects.create(club=self.club, title="Cup game", kind=Event.EventKind.GAME, start=timezone.now() + datetime.timedelta(days=1))
game.teams.add(self.own_team)
self.client.force_login(self.own_team_coach)
response = self.club_get("event_update", game.pk)
self.assertContains(response, 'name="score_for"')
self.assertContains(response, 'name="is_live"')
self.club_post("event_update", self.event_data(kind="game", competition="Regional Cup", external_game_id="ext-42", score_for="3", score_against="1", is_live="on"), game.pk)
game.refresh_from_db()
self.assertEqual(game.score_for, 3)
self.assertEqual(game.score_against, 1)
self.assertTrue(game.is_live)
def test_the_game_kind_choice_is_no_longer_called_match(self):
self.assertNotIn("match", dict(Event.EventKind.choices))
self.assertEqual(dict(Event.EventKind.choices)["game"], "Game")
class EventSeriesManagementTests(ManagementTestBase):
"""EventSeries CRUD + occurrence lifecycle actions (cancel/detach/stop)."""
@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)
# A manager of each team: the series is created by the first, and the
# second is the "different team's manager" the access checks refuse.
cls.team_coach = cls.make_coach(cls.team, "coach-series@example.com")
cls.other_team_coach = cls.make_coach(cls.other_team, "other-coach@example.com")
@classmethod
def make_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 series_data(self, **overrides):
data = {
"title": "Weekly training",
"kind": "training",
"dtstart": "2026-09-01T18:00",
"until": "",
"teams": [str(self.team.pk)],
"invited_members": [],
"excluded_members": [],
"location": "",
"opponent": "",
"frequency": "weekly",
"interval": "1",
"weekdays": ["MO", "WE"],
"duration_hours": "1",
"duration_minutes": "0",
"gathering_minutes_before": "",
"deadline_minutes_before": "",
"advanced_rrule": "",
}
data.update(overrides)
return data
def create_series(self):
self.club_post("event_series_create", self.series_data())
return EventSeries.objects.get(title="Weekly training")
def test_creating_a_series_with_a_same_club_location_does_not_raise_a_cross_club_error(self):
# Same regression as EventManagementTests' equivalent -- EventSeries.clean()
# has the same self.club_id-is-still-None-at-validation-time problem.
location = Location.objects.create(club=self.club, name="Home Ground", address="1 St", city="Town", zip_code="1000", country="BE")
self.client.force_login(self.team_coach)
response = self.club_post("event_series_create", self.series_data(location=str(location.pk)))
series = EventSeries.objects.get(title="Weekly training")
self.assertRedirects(response, reverse("management:event_series_detail", args=[series.pk]))
self.assertEqual(series.location, location)
def test_creating_a_series_generates_occurrences_immediately(self):
self.client.force_login(self.team_coach)
series = self.create_series()
self.assertTrue(series.occurrences.exists())
def test_a_group_member_can_create_a_series_for_their_own_group(self):
group = Group.objects.create(club=self.club, name="Committee")
member_user = User.objects.create_user(email="committee-series@example.com", password="pw-secret-123")
member = Member.objects.create(user=member_user, first_name="Gale", last_name="Group")
physio_position = Position.objects.create(club=self.club, name="Series Physio", short_name="SP", staff_position=True, management_position=False)
StaffAssignment.objects.create(team=self.other_team, member=member, season=self.season, position=physio_position)
GroupMembership.objects.create(group=group, member=member)
self.client.force_login(member_user)
response = self.club_post("event_series_create", self.series_data(teams=[], groups=[str(group.pk)]))
series = EventSeries.objects.get(title="Weekly training")
self.assertRedirects(response, reverse("management:event_series_detail", args=[series.pk]))
self.assertIn(group, series.groups.all())
def test_generated_occurrences_copy_groups_and_club_wide(self):
self.client.force_login(self.admin_user)
series = EventSeries.objects.create(club=self.club, title="AGM", kind=Event.EventKind.MEETING, dtstart=timezone.now() + datetime.timedelta(days=1), rrule="FREQ=WEEKLY;COUNT=1", club_wide=True)
generate_occurrences(series)
occurrence = series.occurrences.get()
self.assertTrue(occurrence.club_wide)
def test_editing_a_series_propagates_to_future_occurrences_but_not_a_detached_one(self):
self.client.force_login(self.team_coach)
series = self.create_series()
detached = series.occurrences.filter(start__gte=timezone.now()).order_by("start").first()
other = series.occurrences.exclude(pk=detached.pk).filter(start__gte=timezone.now()).first()
detach_occurrence(detached)
self.club_post("event_series_update", self.series_data(title="Renamed training"), series.pk)
detached.refresh_from_db()
other.refresh_from_db()
self.assertEqual(detached.title, "Weekly training")
self.assertEqual(other.title, "Renamed training")
def test_cancelling_an_occurrence_records_an_excluded_date_not_a_raw_delete(self):
self.client.force_login(self.team_coach)
series = self.create_series()
occurrence = series.occurrences.order_by("start").first()
start_iso = occurrence.start.isoformat()
self.club_post("event_delete", {}, occurrence.pk)
self.assertFalse(Event.objects.filter(pk=occurrence.pk).exists())
series.refresh_from_db()
self.assertIn(start_iso, series.excluded_dates)
def test_cancelling_with_keep_record_marks_it_cancelled_instead_of_deleting(self):
self.client.force_login(self.team_coach)
series = self.create_series()
occurrence = series.occurrences.order_by("start").first()
self.club_post("event_delete", {"keep_record": "on"}, occurrence.pk)
occurrence.refresh_from_db()
self.assertTrue(occurrence.cancelled)
def test_stop_repeating_prevents_new_occurrences_without_touching_existing_ones(self):
self.client.force_login(self.team_coach)
series = self.create_series()
count_before = series.occurrences.count()
self.club_post("event_series_stop", {}, series.pk)
series.refresh_from_db()
generate_occurrences(series)
self.assertEqual(series.occurrences.count(), count_before)
def test_a_manager_of_a_different_team_cannot_edit_the_series(self):
self.client.force_login(self.team_coach)
series = self.create_series()
self.client.force_login(self.other_team_coach)
response = self.club_get("event_series_update", series.pk)
self.assertEqual(response.status_code, 403)
def test_a_manager_of_a_different_team_cannot_view_the_series_either(self):
self.client.force_login(self.team_coach)
series = self.create_series()
self.client.force_login(self.other_team_coach)
response = self.club_get("event_series_detail", series.pk)
self.assertEqual(response.status_code, 404)
def test_deleting_a_series_deletes_its_occurrences(self):
self.client.force_login(self.team_coach)
series = self.create_series()
occurrence_ids = list(series.occurrences.values_list("pk", flat=True))
self.club_post("event_series_delete", {}, series.pk)
self.assertFalse(EventSeries.objects.filter(pk=series.pk).exists())
self.assertFalse(Event.objects.filter(pk__in=occurrence_ids).exists())
class EventDetailDisplayTests(ManagementTestBase):
"""The event detail page's RSVP breakdown/modal and the game "fetch info"
stub -- see management.views.EventDetailView/EventFetchGameInfoView and
events.services.competitions.fetch_game_info."""
@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, season=cls.season, member=cls.player, position=cls.position)
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def make_event(self, **kwargs):
kwargs.setdefault("title", "Training")
kwargs.setdefault("kind", Event.EventKind.TRAINING)
kwargs.setdefault("start", timezone.now() + datetime.timedelta(days=1))
event = Event.objects.create(club=self.club, **kwargs)
event.teams.add(self.team)
return event
def test_the_rsvp_breakdown_shows_every_status_even_at_zero(self):
event = self.make_event()
response = self.club_get("event_detail", event.pk)
for label in ["Present", "Absent", "Excused", "Selected", "Not selected", "Maybe", "No response"]:
self.assertContains(response, label)
def test_the_rsvp_modal_shows_who_responded_and_their_note(self):
event = self.make_event()
Attendance.objects.filter(event=event, member=self.player).update(status=Attendance.AttendanceStatus.PRESENT, note="Bringing the kit bag")
response = self.club_get("event_detail", event.pk)
self.assertContains(response, "Peter Player")
self.assertContains(response, "Bringing the kit bag")
def test_the_rsvp_modal_offers_a_filter_pill_per_status_with_responses(self):
event = self.make_event()
Attendance.objects.filter(event=event, member=self.player).update(status=Attendance.AttendanceStatus.PRESENT)
response = self.club_get("event_detail", event.pk)
# One pill per status that actually has a response ("Present"), none for
# the others (Absent, Excused, ... all sit at 0 for this single-player event).
self.assertContains(response, 'data-status="present"')
self.assertNotContains(response, 'data-status="absent"')
self.assertContains(response, 'data-status=""') # the "All" pill
def test_the_rsvp_modal_tags_each_row_with_its_status_for_client_side_filtering(self):
event = self.make_event()
Attendance.objects.filter(event=event, member=self.player).update(status=Attendance.AttendanceStatus.PRESENT)
response = self.club_get("event_detail", event.pk)
self.assertContains(response, '')
def test_the_details_card_shows_gathering_and_deadline_even_when_unset(self):
event = self.make_event()
response = self.club_get("event_detail", event.pk)
self.assertContains(response, "Gathering")
self.assertContains(response, "Registration deadline")
def test_the_fetch_button_only_shows_for_a_game_with_a_competition_set(self):
game_with_competition = self.make_event(title="Cup game", kind=Event.EventKind.GAME, competition="Regional Cup")
game_without_competition = self.make_event(title="Friendly game", kind=Event.EventKind.GAME)
training = self.make_event(title="Training session")
self.assertContains(self.club_get("event_detail", game_with_competition.pk), "Fetch new game info")
self.assertNotContains(self.club_get("event_detail", game_without_competition.pk), "Fetch new game info")
self.assertNotContains(self.club_get("event_detail", training.pk), "Fetch new game info")
def test_fetching_game_info_reports_that_nothing_is_configured_yet(self):
# The competition's flag must be active for this club, or fetch_game_info
# gates before ever getting as far as "no data source configured" -- see
# test_fetching_game_info_is_a_silent_no_op_when_the_flag_is_not_active.
Flag = get_waffle_flag_model()
flag = Flag.objects.create(name="regional-cup")
flag.clubs.add(self.club)
Competition.objects.create(name="Regional Cup", module="events.competition.regional", flag=flag)
game = self.make_event(title="Cup game", kind=Event.EventKind.GAME, competition="Regional Cup")
redirect = self.club_post("event_fetch_game_info", {}, game.pk)
response = self.club_get("event_detail", game.pk)
self.assertRedirects(redirect, reverse("management:event_detail", args=[game.pk]))
self.assertContains(response, "No competition data source is configured yet")
def test_fetching_game_info_is_a_silent_no_op_when_the_flag_is_not_active(self):
# No matching Competition row at all -- same "nothing to gate on" outcome
# as one that exists but whose flag isn't active for this club.
game = self.make_event(title="Cup game", kind=Event.EventKind.GAME, competition="Regional Cup")
redirect = self.club_post("event_fetch_game_info", {}, game.pk)
response = self.club_get("event_detail", game.pk)
self.assertRedirects(redirect, reverse("management:event_detail", args=[game.pk]))
self.assertNotContains(response, "No competition data source is configured yet")
self.assertContains(response, "is not enabled for this club")
class EventRefereeManagementTests(ManagementTestBase):
"""Assigning/removing referees from the event detail page's Referees
panel -- home games only, see management.views.EventRefereeAssignView/
EventRefereeRemoveView and events.services.referees."""
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st")
cls.home_ground = Location.objects.create(club=cls.club, name="Home Ground", address="1 St", city="Town", zip_code="1000", country="BE", is_home=True)
cls.away_ground = Location.objects.create(club=cls.club, name="Away Ground", address="2 St", city="Town", zip_code="1000", country="BE")
cls.level = RefereeLevel.objects.create(club=cls.club, name="Regional")
cls.level.teams.add(cls.team)
cls.referee = Member.objects.create(first_name="Ref", last_name="Eree")
ClubMembership.objects.create(club=cls.club, member=cls.referee, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE)
RefereeProfile.objects.create(member=cls.referee, level=cls.level, valid_until=timezone.localdate() + datetime.timedelta(days=30))
cls.coach_position = Position.objects.create(club=cls.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True)
# Manages this team, which still isn't enough to touch referees -- that's
# admin-only, so this actor recurs throughout the 403 checks below.
cls.team_coach = cls.make_coach(cls.team, "coach-referees@example.com")
@classmethod
def make_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 make_game(self, **kwargs):
kwargs.setdefault("title", "Cup game")
kwargs.setdefault("kind", Event.EventKind.GAME)
kwargs.setdefault("location", self.home_ground)
kwargs.setdefault("start", timezone.now() + datetime.timedelta(days=1))
event = Event.objects.create(club=self.club, **kwargs)
event.teams.add(self.team)
return event
def test_the_referees_panel_only_shows_for_a_home_game(self):
# "Referees" alone also matches the nav link on every page -- assert on
# text unique to the panel itself.
home_game = self.make_game()
away_game = self.make_game(title="Away game", location=self.away_ground)
self.client.force_login(self.admin_user)
self.assertContains(self.club_get("event_detail", home_game.pk), "No referees assigned yet.")
self.assertNotContains(self.club_get("event_detail", away_game.pk), "No referees assigned yet.")
def test_the_referees_panel_is_replaced_by_a_note_for_a_federation_managed_team(self):
self.team.referee_management = Team.RefereeManagement.FEDERATION
self.team.save(update_fields=["referee_management"])
home_game = self.make_game()
self.client.force_login(self.admin_user)
response = self.club_get("event_detail", home_game.pk)
self.assertNotContains(response, "No referees assigned yet.")
self.assertContains(response, "managed by the federation")
def test_cannot_assign_a_referee_to_a_federation_managed_teams_game(self):
self.team.referee_management = Team.RefereeManagement.FEDERATION
self.team.save(update_fields=["referee_management"])
game = self.make_game()
self.client.force_login(self.admin_user)
response = self.club_post("event_referee_assign", {"member": str(self.referee.pk)}, game.pk)
self.assertEqual(response.status_code, 404)
self.assertFalse(EventReferee.objects.filter(event=game).exists())
def test_a_teams_own_coach_gets_403_assigning_a_referee(self):
# Admin-only for now, even for the coach who manages this team --
# see management.views.EventRefereeAssignView.
game = self.make_game()
self.client.force_login(self.team_coach)
response = self.club_post("event_referee_assign", {"member": str(self.referee.pk)}, game.pk)
self.assertEqual(response.status_code, 403)
self.assertFalse(EventReferee.objects.filter(event=game, member=self.referee).exists())
def test_a_teams_own_coach_gets_403_removing_a_referee(self):
game = self.make_game()
assignment = EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member)
self.client.force_login(self.team_coach)
response = self.club_post("event_referee_remove", {}, game.pk, assignment.pk)
self.assertEqual(response.status_code, 403)
self.assertTrue(EventReferee.objects.filter(event=game, member=self.referee).exists())
def test_a_teams_coach_sees_the_referees_panel_but_not_the_assign_or_remove_controls(self):
game = self.make_game()
assignment = EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member)
self.client.force_login(self.team_coach)
response = self.club_get("event_detail", game.pk)
# The panel itself, and who's assigned, are still visible...
self.assertContains(response, "Ref Eree")
self.assertContains(response, "1 / 2")
# ...but not the controls to change it.
self.assertNotContains(response, reverse("management:event_referee_assign", args=[game.pk]))
self.assertNotContains(response, reverse("management:event_referee_remove", args=[game.pk, assignment.pk]))
def test_admin_can_assign_an_eligible_referee(self):
game = self.make_game()
self.client.force_login(self.admin_user)
response = self.club_post("event_referee_assign", {"member": str(self.referee.pk)}, game.pk)
self.assertRedirects(response, reverse("management:event_detail", args=[game.pk]))
self.assertTrue(EventReferee.objects.filter(event=game, member=self.referee).exists())
def test_assigning_records_which_admin_assigned_them(self):
game = self.make_game()
self.client.force_login(self.admin_user)
self.club_post("event_referee_assign", {"member": str(self.referee.pk)}, game.pk)
assignment = EventReferee.objects.get(event=game, member=self.referee)
self.assertEqual(assignment.assigned_by, self.admin_member)
def test_cannot_assign_beyond_max_referees(self):
game = self.make_game(max_referees=1)
second_referee = Member.objects.create(first_name="Second", last_name="Ref")
RefereeProfile.objects.create(member=second_referee, level=self.level, valid_until=timezone.localdate() + datetime.timedelta(days=30))
self.client.force_login(self.admin_user)
self.club_post("event_referee_assign", {"member": str(self.referee.pk)}, game.pk)
response = self.club_post("event_referee_assign", {"member": str(second_referee.pk)}, game.pk)
self.assertRedirects(response, reverse("management:event_detail", args=[game.pk]))
self.assertEqual(EventReferee.objects.filter(event=game).count(), 1)
def test_cannot_assign_a_referee_to_an_away_game(self):
# eligible_referees() is already empty for a non-home game, so the
# attempted member isn't found at all -- same 404 as any other
# crafted POST naming someone who isn't a legitimate candidate.
game = self.make_game(location=self.away_ground)
self.client.force_login(self.admin_user)
response = self.club_post("event_referee_assign", {"member": str(self.referee.pk)}, game.pk)
self.assertEqual(response.status_code, 404)
self.assertFalse(EventReferee.objects.filter(event=game).exists())
def test_cannot_assign_someone_not_eligible_via_a_crafted_post(self):
game = self.make_game()
ineligible = Member.objects.create(first_name="Not", last_name="Eligible")
self.client.force_login(self.admin_user)
response = self.club_post("event_referee_assign", {"member": str(ineligible.pk)}, game.pk)
self.assertEqual(response.status_code, 404)
self.assertFalse(EventReferee.objects.filter(event=game).exists())
def test_a_different_teams_coach_cannot_assign_a_referee(self):
other_team = Team.objects.create(club=self.club, name="Second Team", short_name="2nd")
game = self.make_game()
self.client.force_login(self.make_coach(other_team, "coach-referees-other@example.com"))
response = self.club_post("event_referee_assign", {"member": str(self.referee.pk)}, game.pk)
self.assertEqual(response.status_code, 403)
def test_can_remove_an_assigned_referee(self):
game = self.make_game()
assignment = EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member)
self.client.force_login(self.admin_user)
response = self.club_post("event_referee_remove", {}, game.pk, assignment.pk)
self.assertRedirects(response, reverse("management:event_detail", args=[game.pk]))
self.assertFalse(EventReferee.objects.filter(event=game, member=self.referee).exists())
def test_conflict_warning_shown_but_does_not_block_the_assign_control(self):
# The referee is also on this team's roster and expected at an
# overlapping training -- shown as a warning, still selectable.
position = Position.objects.create(club=self.club, name="Forward", short_name="FW")
TeamMembership.objects.create(team=self.team, member=self.referee, season=self.season, position=position)
game = self.make_game(start=timezone.now() + datetime.timedelta(days=1))
clashing_training = Event.objects.create(club=self.club, title="Clashing training", kind=Event.EventKind.TRAINING, start=game.start, end=game.start + datetime.timedelta(hours=1))
clashing_training.teams.add(self.team)
self.client.force_login(self.admin_user)
response = self.club_get("event_detail", game.pk)
self.assertContains(response, "⚠")
self.assertContains(response, f'value="{self.referee.pk}"')
def test_admin_can_add_an_external_referee(self):
game = self.make_game()
self.client.force_login(self.admin_user)
response = self.club_post("event_referee_add_external", {"name": "Guest Referee"}, game.pk)
self.assertRedirects(response, reverse("management:event_detail", args=[game.pk]))
assignment = EventReferee.objects.get(event=game, external_name="Guest Referee")
self.assertIsNone(assignment.member)
def test_adding_an_external_referee_with_a_blank_name_is_rejected(self):
game = self.make_game()
self.client.force_login(self.admin_user)
self.club_post("event_referee_add_external", {"name": " "}, game.pk)
self.assertFalse(EventReferee.objects.filter(event=game).exists())
def test_a_coach_gets_403_adding_an_external_referee(self):
game = self.make_game()
self.client.force_login(self.team_coach)
response = self.club_post("event_referee_add_external", {"name": "Guest Referee"}, game.pk)
self.assertEqual(response.status_code, 403)
def test_event_detail_page_shows_an_external_referee(self):
game = self.make_game()
EventReferee.objects.create(event=game, external_name="Guest Referee", assigned_by=self.admin_member)
self.client.force_login(self.admin_user)
response = self.club_get("event_detail", game.pk)
self.assertContains(response, "Guest Referee")
self.assertContains(response, "External")
def test_admin_can_set_a_referees_fee(self):
game = self.make_game()
assignment = EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member)
self.client.force_login(self.admin_user)
response = self.club_post("event_referee_fee_update", {"fee": "25.00", "km": "40", "km_rate": "0.35"}, game.pk, assignment.pk)
self.assertRedirects(response, reverse("management:event_detail", args=[game.pk]))
assignment.refresh_from_db()
self.assertEqual(assignment.fee, Decimal("25.00"))
self.assertEqual(assignment.total_payable, Decimal("39.00"))
def test_a_km_rate_with_more_than_two_decimals_is_accepted(self):
# e.g. a per-km rate of €0.083 -- the km_rate input must not be pinned
# to money-style step="0.01" the way the fee field is.
game = self.make_game()
assignment = EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member)
self.client.force_login(self.admin_user)
response = self.club_post("event_referee_fee_update", {"fee": "0", "km": "40", "km_rate": "0.083"}, game.pk, assignment.pk)
self.assertRedirects(response, reverse("management:event_detail", args=[game.pk]))
assignment.refresh_from_db()
self.assertEqual(assignment.km_rate, Decimal("0.083"))
def test_event_detail_page_shows_the_total_due_once_a_fee_is_set(self):
game = self.make_game()
EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member, fee=Decimal("25.00"))
self.client.force_login(self.admin_user)
response = self.club_get("event_detail", game.pk)
self.assertContains(response, "25.00")
def test_the_total_due_is_shown_with_at_most_two_decimals(self):
# A per-km rate like 0.083 pushes the raw total to 3+ decimals -- the
# "due" summary must still round to money-style 2.
game = self.make_game()
EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member, fee=Decimal("25.00"), km=Decimal("40"), km_rate=Decimal("0.083"))
self.client.force_login(self.admin_user)
response = self.club_get("event_detail", game.pk)
self.assertContains(response, "28.32 due")
self.assertNotContains(response, "28.320")
def test_the_fee_is_entered_inline_pre_filled_with_the_current_value(self):
# Editing a fee is a single input right on the referee's row -- no
# separate modal to click through, so it's less likely to be skipped.
game = self.make_game()
EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member, fee=Decimal("25.00"))
self.client.force_login(self.admin_user)
response = self.club_get("event_detail", game.pk)
self.assertContains(response, 'name="fee" value="25.00"')
def test_an_unset_fee_is_flagged_on_the_referees_panel(self):
game = self.make_game()
EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member)
self.client.force_login(self.admin_user)
response = self.club_get("event_detail", game.pk)
self.assertContains(response, "Fee not set")
def test_a_fee_already_set_is_not_flagged(self):
game = self.make_game()
EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member, fee=Decimal("25.00"))
self.client.force_login(self.admin_user)
response = self.club_get("event_detail", game.pk)
self.assertNotContains(response, "Fee not set")
def test_a_coach_gets_403_setting_a_fee(self):
game = self.make_game()
assignment = EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member)
self.client.force_login(self.team_coach)
response = self.club_post("event_referee_fee_update", {"fee": "25.00"}, game.pk, assignment.pk)
self.assertEqual(response.status_code, 403)
class EventRefereeFormPdfTests(ManagementTestBase):
"""Downloadable referee payment form -- see management.views.EventRefereeFormPdfView,
modeled on the club's existing paper form."""
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st")
cls.home_ground = Location.objects.create(club=cls.club, name="Home Ground", address="1 St", city="Town", zip_code="1000", country="BE", is_home=True)
cls.referee = Member.objects.create(first_name="Ref", last_name="Eree")
ClubMembership.objects.create(club=cls.club, member=cls.referee, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE)
def make_coach(self, team, email="coach-refpdf@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=team, member=coach_member, season=self.season, position=position)
return coach_user
def make_game(self, **kwargs):
kwargs.setdefault("title", "Cup game")
kwargs.setdefault("kind", Event.EventKind.GAME)
kwargs.setdefault("location", self.home_ground)
kwargs.setdefault("start", timezone.now() + datetime.timedelta(days=1))
event = Event.objects.create(club=self.club, **kwargs)
event.teams.add(self.team)
return event
def test_downloads_as_a_pdf(self):
game = self.make_game()
self.client.force_login(self.admin_user)
with mock.patch("management.views.event_referee_form_pdf", return_value=b"%PDF-fake") as renderer:
response = self.club_get("event_referee_form_pdf", game.pk)
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_uses_the_clubs_legal_name_and_home_location_when_set(self):
self.club.legal_name = "Ajax United VZW"
self.club.save(update_fields=["legal_name"])
game = self.make_game()
EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member, fee=Decimal("25.00"), km=Decimal("40"), km_rate=Decimal("0.35"))
self.client.force_login(self.admin_user)
with mock.patch("management.views.event_referee_form_pdf", return_value=b"%PDF-fake") as renderer:
self.club_get("event_referee_form_pdf", game.pk)
context = renderer.call_args[0][0]
self.assertEqual(context["club"].official_name, "Ajax United VZW")
self.assertEqual(context["home_location"], self.home_ground)
self.assertEqual(list(context["referees"]), [EventReferee.objects.get(event=game)])
def test_the_grand_total_sums_every_referees_total_payable(self):
game = self.make_game()
other_referee = Member.objects.create(first_name="Other", last_name="Ref")
ClubMembership.objects.create(club=self.club, member=other_referee, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member, fee=Decimal("25.00"), km=Decimal("40"), km_rate=Decimal("0.083"))
EventReferee.objects.create(event=game, member=other_referee, assigned_by=self.admin_member, fee=Decimal("20.00"))
self.client.force_login(self.admin_user)
with mock.patch("management.views.event_referee_form_pdf", return_value=b"%PDF-fake") as renderer:
self.club_get("event_referee_form_pdf", game.pk)
self.assertEqual(renderer.call_args[0][0]["grand_total"], Decimal("48.320"))
def test_the_external_referee_pill_is_not_rendered(self):
game = self.make_game()
EventReferee.objects.create(event=game, external_name="Guest Referee", assigned_by=self.admin_member)
html = render_to_string("management/event_referee_form_pdf.html", {"club": self.club, "event": game, "referees": list(game.referees.all()), "home_location": self.home_ground, "grand_total": Decimal("0")})
self.assertIn("Guest Referee", html)
self.assertNotIn("External", html)
def test_amounts_render_with_at_most_two_decimals(self):
game = self.make_game()
EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member, fee=Decimal("25.00"), km=Decimal("40"), km_rate=Decimal("0.083"))
html = render_to_string("management/event_referee_form_pdf.html", {"club": self.club, "event": game, "referees": list(game.referees.all()), "home_location": self.home_ground, "grand_total": Decimal("28.320")})
self.assertIn("€28.32<", html)
self.assertNotIn("28.320", html)
def test_the_pdf_uses_the_clubs_colours_when_set(self):
self.club.primary_color = "#0f766e"
self.club.secondary_color = "#f59e0b"
self.club.save(update_fields=["primary_color", "secondary_color"])
game = self.make_game()
html = render_to_string("management/event_referee_form_pdf.html", {"club": self.club, "event": game, "referees": [], "home_location": self.home_ground, "grand_total": Decimal("0"), **referee_form_colors(self.club)})
self.assertIn("--accent: #0f766e", html)
def test_the_pdf_falls_back_to_default_colours_when_unset(self):
game = self.make_game()
html = render_to_string("management/event_referee_form_pdf.html", {"club": self.club, "event": game, "referees": [], "home_location": self.home_ground, "grand_total": Decimal("0"), **referee_form_colors(self.club)})
self.assertIn("--accent: #3730a3", html)
def test_the_info_card_background_is_not_left_to_unsupported_css(self):
# WeasyPrint doesn't support color-mix() -- the background must be a
# plain computed hex value baked into the template, or the card
# silently renders with no background at all.
game = self.make_game()
html = render_to_string("management/event_referee_form_pdf.html", {"club": self.club, "event": game, "referees": [], "home_location": self.home_ground, "grand_total": Decimal("0"), **referee_form_colors(self.club)})
self.assertNotIn("color-mix(", html)
self.assertIn("--info-card-bg: #", html)
def test_the_info_card_falls_back_to_secondary_when_primary_is_near_white(self):
self.club.primary_color = "#ffffff"
self.club.secondary_color = "#f59e0b"
self.club.save(update_fields=["primary_color", "secondary_color"])
colors = referee_form_colors(self.club)
self.assertEqual(colors["accent_color"], "#ffffff")
self.assertNotEqual(colors["info_card_color"], "#ffffff")
def test_the_info_card_uses_primary_when_it_is_not_near_black_or_white(self):
self.club.primary_color = "#0f766e"
self.club.save(update_fields=["primary_color"])
colors = referee_form_colors(self.club)
self.assertEqual(colors["info_card_color"], _tint_with_white("#0f766e"))
def test_a_missing_pdf_library_is_reported_rather_than_a_500(self):
game = self.make_game()
self.client.force_login(self.admin_user)
with mock.patch("management.views.event_referee_form_pdf", side_effect=PDFExportError("PDF rendering needs the native pango/cairo libraries.")):
response = self.club_get("event_referee_form_pdf", game.pk)
response = self.client.get(response.url, HTTP_HOST="ajax-united.rosterchief.app")
self.assertContains(response, "pango")
def test_a_coach_gets_403(self):
game = self.make_game()
self.client.force_login(self.make_coach(self.team))
response = self.club_get("event_referee_form_pdf", game.pk)
self.assertEqual(response.status_code, 403)
class RefereeManagementDashboardTests(ManagementTestBase):
"""The admin-only one-stop view of upcoming home games needing a
club-arranged referee -- see management.views.RefereeManagementDashboardView."""
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st")
cls.federation_team = Team.objects.create(club=cls.club, name="Federation Team", short_name="Fed", referee_management=Team.RefereeManagement.FEDERATION)
cls.home_ground = Location.objects.create(club=cls.club, name="Home Ground", address="1 St", city="Town", zip_code="1000", country="BE", is_home=True)
cls.away_ground = Location.objects.create(club=cls.club, name="Away Ground", address="2 St", city="Town", zip_code="1000", country="BE")
cls.level = RefereeLevel.objects.create(club=cls.club, name="Regional")
cls.level.teams.add(cls.team)
cls.referee = Member.objects.create(first_name="Ref", last_name="Eree")
ClubMembership.objects.create(club=cls.club, member=cls.referee, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE)
RefereeProfile.objects.create(member=cls.referee, level=cls.level, valid_until=timezone.localdate() + datetime.timedelta(days=30))
def make_coach(self, team, email="coach-refdash@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=team, member=coach_member, season=self.season, position=position)
return coach_user
def make_game(self, team=None, **kwargs):
kwargs.setdefault("title", "Cup game")
kwargs.setdefault("kind", Event.EventKind.GAME)
kwargs.setdefault("location", self.home_ground)
kwargs.setdefault("start", timezone.now() + datetime.timedelta(days=1))
event = Event.objects.create(club=self.club, **kwargs)
event.teams.add(team or self.team)
return event
def test_is_admin_only(self):
self.client.force_login(self.make_coach(self.team))
self.assertEqual(self.club_get("referee_management").status_code, 403)
def test_lists_an_upcoming_club_managed_home_game(self):
game = self.make_game()
self.client.force_login(self.admin_user)
response = self.club_get("referee_management")
self.assertContains(response, reverse("management:event_detail", args=[game.pk]))
def test_each_game_tile_links_straight_to_the_referee_form_pdf(self):
game = self.make_game()
self.client.force_login(self.admin_user)
response = self.club_get("referee_management")
self.assertContains(response, reverse("management:event_referee_form_pdf", args=[game.pk]))
def test_the_game_tile_shows_assigned_referee_names(self):
game = self.make_game()
EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member)
self.client.force_login(self.admin_user)
response = self.club_get("referee_management")
self.assertContains(response, str(self.referee))
def test_a_game_with_an_unpaid_referee_is_flagged_as_fees_pending(self):
game = self.make_game()
EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member)
self.client.force_login(self.admin_user)
response = self.club_get("referee_management")
self.assertEqual(response.context["kpi_fees_pending"], 1)
self.assertContains(response, "Fee not set")
def test_a_game_with_a_fee_already_set_is_not_flagged(self):
game = self.make_game()
EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member, fee=Decimal("25.00"))
self.client.force_login(self.admin_user)
response = self.club_get("referee_management")
self.assertEqual(response.context["kpi_fees_pending"], 0)
self.assertNotContains(response, "Fee not set")
def test_a_game_with_no_referee_at_all_is_not_counted_as_fees_pending(self):
# Missing a referee entirely is a different, already-visible problem
# (kpi_no_referee) -- it shouldn't also inflate the fees-pending count.
self.make_game()
self.client.force_login(self.admin_user)
response = self.club_get("referee_management")
self.assertEqual(response.context["kpi_fees_pending"], 0)
def test_excludes_a_federation_managed_teams_game(self):
game = self.make_game(team=self.federation_team)
self.client.force_login(self.admin_user)
response = self.club_get("referee_management")
self.assertNotContains(response, reverse("management:event_detail", args=[game.pk]))
def test_excludes_an_away_game(self):
game = self.make_game(location=self.away_ground)
self.client.force_login(self.admin_user)
response = self.club_get("referee_management")
self.assertNotContains(response, reverse("management:event_detail", args=[game.pk]))
def test_excludes_a_past_game(self):
game = self.make_game(start=timezone.now() - datetime.timedelta(days=1))
self.client.force_login(self.admin_user)
response = self.club_get("referee_management")
self.assertNotContains(response, reverse("management:event_detail", args=[game.pk]))
def test_excludes_a_cancelled_game(self):
game = self.make_game(cancelled=True)
self.client.force_login(self.admin_user)
response = self.club_get("referee_management")
self.assertNotContains(response, reverse("management:event_detail", args=[game.pk]))
def test_an_out_of_range_value_falls_back_to_the_default(self):
self.client.force_login(self.admin_user)
response = self.club_get("referee_management")
response_with_bad_range = self.client.get(f"{reverse('management:referee_management')}?range=bogus", HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.context["range_choice"], "10")
self.assertEqual(response_with_bad_range.context["range_choice"], "10")
def test_a_valid_range_is_honoured(self):
self.client.force_login(self.admin_user)
response = self.client.get(f"{reverse('management:referee_management')}?range=25", HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.context["range_choice"], "25")
def test_the_week_range_excludes_a_game_beyond_this_week(self):
today = timezone.localdate()
end_of_this_week = today + datetime.timedelta(days=6 - today.weekday())
game_this_week = self.make_game(start=timezone.now() + datetime.timedelta(minutes=5))
game_next_week = self.make_game(start=timezone.make_aware(datetime.datetime.combine(end_of_this_week + datetime.timedelta(days=1), datetime.time(10, 0))))
self.client.force_login(self.admin_user)
response = self.client.get(f"{reverse('management:referee_management')}?range=week", HTTP_HOST="ajax-united.rosterchief.app")
self.assertContains(response, reverse("management:event_detail", args=[game_this_week.pk]))
self.assertNotContains(response, reverse("management:event_detail", args=[game_next_week.pk]))
def test_kpis_count_games_by_referee_staffing(self):
self.make_game()
partially_staffed = self.make_game()
EventReferee.objects.create(event=partially_staffed, member=self.referee, assigned_by=self.admin_member)
self.client.force_login(self.admin_user)
response = self.club_get("referee_management")
self.assertEqual(response.context["kpi_total"], 2)
self.assertEqual(response.context["kpi_no_referee"], 1)
self.assertEqual(response.context["kpi_understaffed"], 1)
self.assertEqual(response.context["kpi_fully_staffed"], 0)
def test_an_assigned_referee_gets_a_fee_form_for_the_dashboard_modal(self):
game = self.make_game()
EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member)
self.client.force_login(self.admin_user)
response = self.club_get("referee_management")
self.assertContains(response, 'name="fee"')
def test_assigning_from_the_dashboard_redirects_back_to_the_dashboard(self):
game = self.make_game()
self.client.force_login(self.admin_user)
response = self.client.post(
reverse("management:event_referee_assign", args=[game.pk]),
{"member": str(self.referee.pk), "next": reverse("management:referee_management")},
HTTP_HOST="ajax-united.rosterchief.app",
)
self.assertRedirects(response, reverse("management:referee_management"))
self.assertTrue(EventReferee.objects.filter(event=game, member=self.referee).exists())
def test_removing_from_the_dashboard_redirects_back_to_the_dashboard(self):
game = self.make_game()
assignment = EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member)
self.client.force_login(self.admin_user)
response = self.client.post(
reverse("management:event_referee_remove", args=[game.pk, assignment.pk]),
{"next": reverse("management:referee_management")},
HTTP_HOST="ajax-united.rosterchief.app",
)
self.assertRedirects(response, reverse("management:referee_management"))
self.assertFalse(EventReferee.objects.filter(event=game, member=self.referee).exists())
def test_an_unsafe_next_is_ignored(self):
game = self.make_game()
self.client.force_login(self.admin_user)
response = self.client.post(
reverse("management:event_referee_assign", args=[game.pk]),
{"member": str(self.referee.pk), "next": "https://evil.example.com/"},
HTTP_HOST="ajax-united.rosterchief.app",
)
self.assertRedirects(response, reverse("management:event_detail", args=[game.pk]))
class FeatureGatedSectionsTests(ManagementTestBase):
"""The Shop and Forms sections are still stubs (StubListMixin) and, on top
of being admin-only, only exist for a club at all once their own waffle
Flag ("shop" / "formbuilder") is active for it -- see
club.mixins.FeatureRequiredMixin and management.context_processors.feature_sections."""
def setUp(self):
super().setUp()
# waffle caches Flag lookups outside the DB transaction each test rolls
# back, so a flag created in one test can otherwise leak a stale/invalid
# pk into the next -- see FeatureViewTests in controlpanel/tests.py.
cache.clear()
self.addCleanup(cache.clear)
def activate(self, flag_name):
flag = get_waffle_flag_model().objects.create(name=flag_name)
flag.clubs.add(self.club)
def make_plain_staff(self, email="physio-features@example.com"):
staff_user = User.objects.create_user(email=email, password="pw-secret-123")
staff_member = Member.objects.create(user=staff_user, first_name="Pat", last_name="Physio")
ClubMembership.objects.create(club=self.club, member=staff_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
team = Team.objects.create(club=self.club, name="Physio Team", short_name="PHY")
position = Position.objects.create(club=self.club, name="Physio", short_name="PH", staff_position=True, management_position=False)
StaffAssignment.objects.create(team=team, member=staff_member, season=self.season, position=position)
return staff_user
def test_shop_views_404_when_the_flag_is_not_active(self):
self.client.force_login(self.admin_user)
for name in ["product_list", "order_list", "discount_list", "invoice_list"]:
with self.subTest(name=name):
self.assertEqual(self.club_get(name).status_code, 404)
def test_forms_view_404_when_the_flag_is_not_active(self):
self.client.force_login(self.admin_user)
self.assertEqual(self.club_get("form_list").status_code, 404)
def test_shop_views_are_reachable_once_the_flag_is_active(self):
self.activate("shop")
self.client.force_login(self.admin_user)
for name in ["product_list", "order_list", "discount_list", "invoice_list"]:
with self.subTest(name=name):
self.assertEqual(self.club_get(name).status_code, 200)
def test_forms_view_is_reachable_once_its_own_flag_is_active(self):
self.activate("formbuilder")
self.client.force_login(self.admin_user)
self.assertEqual(self.club_get("form_list").status_code, 200)
def test_the_shop_flag_does_not_also_enable_forms(self):
# Different flags -- see the "(but different feature)" ask.
self.activate("shop")
self.client.force_login(self.admin_user)
self.assertEqual(self.club_get("form_list").status_code, 404)
def test_a_non_admin_still_gets_404_not_403_when_the_flag_is_off(self):
# The section doesn't exist for this club at all -- not a permissions
# question, so even someone who'd otherwise be refused (403) for lack
# of admin rights sees the same 404 an admin would.
self.client.force_login(self.make_plain_staff())
self.assertEqual(self.club_get("product_list").status_code, 404)
def test_a_non_admin_gets_403_once_the_flag_is_active(self):
self.activate("shop")
self.client.force_login(self.make_plain_staff())
self.assertEqual(self.club_get("product_list").status_code, 403)
def test_nav_hides_shop_and_forms_when_their_flags_are_off(self):
self.client.force_login(self.admin_user)
response = self.club_get("home")
self.assertNotContains(response, "Products")
self.assertNotContains(response, "Forms")
def test_nav_shows_shop_once_its_flag_is_active(self):
# Products lives under Finance's sub-nav now, which only expands on a
# Finance-section page -- see test_the_families_nav_link_is_reachable's comment.
self.activate("shop")
self.client.force_login(self.admin_user)
response = self.club_get("membership_list")
self.assertContains(response, "Products")
self.assertNotContains(response, "Forms")
RBIHF_SAMPLE_HTML = """
"""
class RBIHFImportViewTests(ManagementTestBase):
"""The Events page's "Import from RBIHF" button/flow -- admin-only, and
only when the "RBIHF" waffle Flag is active for the club, same gating
machinery as FeatureGatedSectionsTests above. The scrape/diff/apply logic
itself (events.services.rbihf_import) has its own offline tests in
events/tests.py; these exercise the views end to end via a mocked
fetch_html, never touching the network."""
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st")
def setUp(self):
super().setUp()
cache.clear()
self.addCleanup(cache.clear)
def activate_flag(self):
# "RBIHF" is already seeded (migration 0018 links it to the Competition
# row of the same name) -- get_or_create, not create.
flag, _created = get_waffle_flag_model().objects.get_or_create(name="RBIHF")
flag.clubs.add(self.club)
def make_coach(self, email="coach-rbihf@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")
ClubMembership.objects.create(club=self.club, member=coach_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
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_button_only_shows_for_admin_with_the_flag_active(self):
self.activate_flag()
self.client.force_login(self.admin_user)
response = self.club_get("event_list")
self.assertContains(response, "Import from RBIHF")
def test_button_hidden_when_the_flag_is_not_active(self):
self.client.force_login(self.admin_user)
response = self.club_get("event_list")
self.assertNotContains(response, "Import from RBIHF")
def test_button_hidden_from_a_non_admin_even_with_the_flag_active(self):
self.activate_flag()
self.client.force_login(self.make_coach())
response = self.club_get("event_list")
self.assertNotContains(response, "Import from RBIHF")
def test_the_import_views_404_when_the_flag_is_not_active(self):
self.client.force_login(self.admin_user)
self.assertEqual(self.club_get("rbihf_import").status_code, 404)
self.assertEqual(self.club_post("rbihf_import_confirm", {}).status_code, 404)
def test_a_non_admin_gets_403_when_the_flag_is_active(self):
self.activate_flag()
self.client.force_login(self.make_coach())
self.assertEqual(self.club_get("rbihf_import").status_code, 403)
@mock.patch("management.views.fetch_html", return_value=RBIHF_SAMPLE_HTML)
def test_submitting_the_form_shows_a_preview(self, mock_fetch):
self.activate_flag()
self.client.force_login(self.admin_user)
response = self.club_post("rbihf_import", {"url": "https://www.rbihf.be/league/team/4460", "team": str(self.team.pk)})
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Sportoase Antwerp Phantoms")
self.assertContains(response, "5002")
self.assertContains(response, 'name="opponent_5002"')
self.assertContains(response, 'name="location_5002"')
mock_fetch.assert_called_once_with("https://www.rbihf.be/league/team/4460")
@mock.patch("management.views.fetch_html", return_value=RBIHF_SAMPLE_HTML)
def test_confirming_creates_the_event(self, mock_fetch):
self.activate_flag()
self.client.force_login(self.admin_user)
self.club_post("rbihf_import", {"url": "https://www.rbihf.be/league/team/4460", "team": str(self.team.pk)})
response = self.club_post("rbihf_import_confirm", {})
self.assertRedirects(response, reverse("management:event_list"))
event = Event.objects.get(club=self.club, external_game_id="5002")
self.assertEqual(event.opponent.name, "Amsterdam Tigers")
self.assertIn(self.team, event.teams.all())
@mock.patch("management.views.fetch_html", return_value=RBIHF_SAMPLE_HTML)
def test_confirming_respects_the_chosen_location(self, mock_fetch):
self.activate_flag()
location = Location.objects.create(club=self.club, name="Deurne Ice Hall", address="1 St", city="Deurne", zip_code="2100", country="BE")
self.client.force_login(self.admin_user)
self.club_post("rbihf_import", {"url": "https://www.rbihf.be/league/team/4460", "team": str(self.team.pk)})
self.club_post("rbihf_import_confirm", {"location_5002": str(location.pk)})
event = Event.objects.get(club=self.club, external_game_id="5002")
self.assertEqual(event.location, location)
@mock.patch("management.views.fetch_html", return_value=RBIHF_SAMPLE_HTML)
def test_confirming_respects_the_chosen_opponent(self, mock_fetch):
self.activate_flag()
renamed = Opponent.objects.create(club=self.club, name="Amsterdam Tigers HC")
self.client.force_login(self.admin_user)
self.club_post("rbihf_import", {"url": "https://www.rbihf.be/league/team/4460", "team": str(self.team.pk)})
self.club_post("rbihf_import_confirm", {"opponent_5002": str(renamed.pk)})
event = Event.objects.get(club=self.club, external_game_id="5002")
self.assertEqual(event.opponent, renamed)
self.assertFalse(Opponent.objects.filter(club=self.club, name="Amsterdam Tigers").exists())
@mock.patch("management.views.fetch_html", return_value=RBIHF_SAMPLE_HTML)
def test_a_blank_opponent_choice_falls_back_to_the_scraped_name(self, mock_fetch):
self.activate_flag()
self.client.force_login(self.admin_user)
self.club_post("rbihf_import", {"url": "https://www.rbihf.be/league/team/4460", "team": str(self.team.pk)})
self.club_post("rbihf_import_confirm", {})
event = Event.objects.get(club=self.club, external_game_id="5002")
self.assertEqual(event.opponent.name, "Amsterdam Tigers")
def test_confirming_with_nothing_stashed_redirects_with_a_notice(self):
self.activate_flag()
self.client.force_login(self.admin_user)
response = self.club_post("rbihf_import_confirm", {})
self.assertRedirects(response, reverse("management:rbihf_import"))
def test_a_non_rbihf_url_is_a_form_error_not_a_500(self):
self.activate_flag()
self.client.force_login(self.admin_user)
response = self.club_post("rbihf_import", {"url": "https://evil.example.com/x", "team": str(self.team.pk)})
self.assertEqual(response.status_code, 200)
self.assertContains(response, "RBIHF team page")
@mock.patch("management.views.fetch_html", side_effect=RBIHFImportError("Could not reach the page."))
def test_a_fetch_failure_is_a_form_error_not_a_500(self, mock_fetch):
self.activate_flag()
self.client.force_login(self.admin_user)
response = self.club_post("rbihf_import", {"url": "https://www.rbihf.be/league/team/4460", "team": str(self.team.pk)})
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Could not reach the page.")
@mock.patch("management.views.fetch_html", return_value=RBIHF_SAMPLE_HTML)
def test_re_running_the_same_import_shows_it_as_unchanged(self, mock_fetch):
self.activate_flag()
self.client.force_login(self.admin_user)
self.club_post("rbihf_import", {"url": "https://www.rbihf.be/league/team/4460", "team": str(self.team.pk)})
self.club_post("rbihf_import_confirm", {})
response = self.club_post("rbihf_import", {"url": "https://www.rbihf.be/league/team/4460", "team": str(self.team.pk)})
self.assertContains(response, "already up to date")
self.assertEqual(Event.objects.filter(club=self.club, external_game_id="5002").count(), 1)
class MemberListKindFilterTests(ManagementTestBase):
"""?kind= on the member list -- see MemberListView.get_queryset. "member"
(the default) matches the page's original guardian-excluding behaviour;
"guardian" and "both" turn that exclusion into an explicit, reversible
choice instead of a parent silently disappearing after being registered."""
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.family = Family.objects.create()
cls.parent = Member.objects.create(first_name="Pat", last_name="Guardian")
cls.child = Member.objects.create(first_name="Cody", last_name="Kid")
FamilyMembership.objects.create(family=cls.family, member=cls.parent, role=FamilyMembership.FamilyRole.PARENT)
FamilyMembership.objects.create(family=cls.family, member=cls.child, role=FamilyMembership.FamilyRole.CHILD)
ClubMembership.objects.create(club=cls.club, member=cls.parent, season=cls.season, kind=ClubMembership.Kind.GUARDIAN, status=ClubMembership.StatusChoices.ACTIVE)
ClubMembership.objects.create(club=cls.club, member=cls.child, season=cls.season, kind=ClubMembership.Kind.MEMBER, status=ClubMembership.StatusChoices.ACTIVE)
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def get(self, kind=None):
url = reverse("management:member_list")
if kind is not None:
url += f"?kind={kind}"
return self.client.get(url, HTTP_HOST="ajax-united.rosterchief.app")
def test_default_excludes_the_guardian(self):
response = self.get()
self.assertContains(response, "Cody Kid")
self.assertNotContains(response, "Pat Guardian")
self.assertEqual(response.context["selected_kind"], "member")
def test_kind_guardian_shows_only_the_guardian(self):
response = self.get("guardian")
self.assertContains(response, "Pat Guardian")
self.assertNotContains(response, "Cody Kid")
def test_kind_both_shows_everyone(self):
response = self.get("both")
self.assertContains(response, "Pat Guardian")
self.assertContains(response, "Cody Kid")
def test_kind_both_flags_the_guardian_row_but_not_the_member_row(self):
# Both rows show up mixed together in "both" -- the badge is the only thing
# telling them apart, so it must land on the guardian and nowhere else.
response = self.get("both")
self.assertContains(response, ">Guardian<", count=1)
def test_the_guardian_badge_only_shows_in_the_both_view(self):
response = self.get("guardian")
self.assertContains(response, "Pat Guardian")
self.assertNotContains(response, ">Guardian<")
def test_an_unrecognised_kind_falls_back_to_the_default(self):
response = self.get("bogus")
self.assertNotContains(response, "Pat Guardian")
self.assertEqual(response.context["selected_kind"], "member")
def test_a_non_admins_guardian_view_stays_within_their_own_visibility(self):
# _guardians_only(club) is club-wide -- it's intersected with
# members_visible_to() so a non-admin only ever sees guardians of
# someone already visible to them, never every guardian in the club.
coach_user = User.objects.create_user(email="coach-kind@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="U9", short_name="U9")
position = Position.objects.create(club=self.club, name="Coach-kind", short_name="CK", 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.get("both")
self.assertNotContains(response, "Pat Guardian")
class MemberListFilterAndColumnsTests(ManagementTestBase):
"""Status/fee status/team filters, the Licence and (admin-only) Dues columns,
and the search-as-you-type input on the member list."""
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.team = Team.objects.create(club=cls.club, name="U16", short_name="U16")
cls.position = Position.objects.create(club=cls.club, name="Forward", staff_position=False)
cls.active_paid = Member.objects.create(first_name="Ada", last_name="Active")
cls.active_membership = ClubMembership.objects.create(club=cls.club, member=cls.active_paid, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE, fee_status=ClubMembership.FeeStatus.PAID, license="BE-123")
TeamMembership.objects.create(team=cls.team, member=cls.active_paid, season=cls.season, position=cls.position)
cls.pending_unpaid = Member.objects.create(first_name="Pip", last_name="Pending")
ClubMembership.objects.create(club=cls.club, member=cls.pending_unpaid, season=cls.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.UNPAID)
def setUp(self):
self.client.force_login(self.admin_user)
def get(self, **params):
url = reverse("management:member_list")
if params:
url += "?" + "&".join(f"{key}={value}" for key, value in params.items())
return self.client.get(url, HTTP_HOST="ajax-united.rosterchief.app")
def test_status_filter_narrows_to_the_matching_membership(self):
response = self.get(status="pending")
self.assertContains(response, "Pip Pending")
self.assertNotContains(response, "Ada Active")
self.assertEqual(response.context["selected_status"], "pending")
def test_fee_status_filter_narrows_to_the_matching_membership(self):
response = self.get(fee_status="unpaid")
self.assertContains(response, "Pip Pending")
self.assertNotContains(response, "Ada Active")
def test_team_filter_narrows_to_that_teams_roster(self):
response = self.get(team=str(self.team.pk))
self.assertContains(response, "Ada Active")
self.assertNotContains(response, "Pip Pending")
def test_search_input_has_no_submit_button_and_autosubmits(self):
response = self.get()
self.assertNotContains(response, ">Search<")
self.assertContains(response, "data-autosubmit")
def test_licence_column_shows_the_current_membership_license(self):
response = self.get()
self.assertContains(response, "BE-123")
def test_dues_column_only_shows_for_an_admin(self):
admin_response = self.get()
self.assertContains(admin_response, "Paid")
coach_user = User.objects.create_user(email="coach-dues@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="Head Coach", short_name="HCD", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=self.team, member=coach_member, season=self.season, position=position)
self.client.force_login(coach_user)
coach_response = self.get()
self.assertNotContains(coach_response, ">Dues<")
def test_clear_filters_link_only_shows_once_a_filter_is_active(self):
clean_response = self.get()
self.assertNotContains(clean_response, "Clear filters")
filtered_response = self.get(status="pending")
self.assertContains(filtered_response, "Clear filters")
def test_unrostered_filter_matches_the_dashboards_own_needs_attention_row(self):
# Same condition as attention.unrostered on the dashboard (club_attention) --
# active, no TeamMembership this season. cls.active_paid is rostered (U16);
# cls.pending_unpaid isn't ACTIVE at all, so neither counts here.
unrostered_member = Member.objects.create(first_name="Uma", last_name="Unrostered")
ClubMembership.objects.create(club=self.club, member=unrostered_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE, fee_status=ClubMembership.FeeStatus.PAID)
response = self.get(unrostered="1")
self.assertContains(response, "Uma Unrostered")
self.assertNotContains(response, "Ada Active")
self.assertNotContains(response, "Pip Pending")
def test_docs_open_filter_matches_the_dashboards_own_missing_documentation_row(self):
OnboardingRequirement.objects.create(club=self.club, name="Medical certificate")
# Neither fixture has a MemberRequirementStatus row at all -- "required, no
# row yet" is itself open, per OnboardingRequirement's own docstring -- so
# both match regardless of ClubMembership.status (docs tracking doesn't
# care whether they're PENDING or ACTIVE, only fee-driven status does).
response = self.get(docs="open")
self.assertContains(response, "Ada Active")
self.assertContains(response, "Pip Pending")
def test_docs_open_filter_excludes_a_member_who_resolved_everything(self):
requirement = OnboardingRequirement.objects.create(club=self.club, name="Medical certificate")
mark_complete(self.active_membership, requirement, user=self.admin_user)
response = self.get(docs="open")
self.assertNotContains(response, "Ada Active")
def test_documents_column_only_shows_once_the_club_has_requirements(self):
no_requirements_response = self.get()
self.assertNotContains(no_requirements_response, ">Documents<")
OnboardingRequirement.objects.create(club=self.club, name="Medical certificate")
OnboardingRequirement.objects.create(club=self.club, name="Photo")
mark_complete(self.active_membership, OnboardingRequirement.objects.get(name="Photo"), user=self.admin_user)
response = self.get()
self.assertContains(response, ">Documents<")
self.assertContains(response, "1/2")
class FamilyListViewTests(ManagementTestBase):
"""The dedicated Families page -- one row per family, parents/guardians and
children in separate columns, with the family name and a per-row "Edit"
action both landing on family_detail (the same overview-page convention
the Groups list uses for its own Edit button, not a standalone form)."""
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.family = Family.objects.create(name="The Smiths")
cls.parent = Member.objects.create(first_name="Pat", last_name="Smith")
cls.child = Member.objects.create(first_name="Cody", last_name="Smith")
FamilyMembership.objects.create(family=cls.family, member=cls.parent, role=FamilyMembership.FamilyRole.PARENT)
FamilyMembership.objects.create(family=cls.family, member=cls.child, role=FamilyMembership.FamilyRole.CHILD)
ClubMembership.objects.create(club=cls.club, member=cls.parent, season=cls.season, kind=ClubMembership.Kind.GUARDIAN, status=ClubMembership.StatusChoices.ACTIVE)
ClubMembership.objects.create(club=cls.club, member=cls.child, season=cls.season, kind=ClubMembership.Kind.MEMBER, status=ClubMembership.StatusChoices.ACTIVE)
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def test_lists_the_family_with_parents_and_children_columns(self):
response = self.club_get("family_list")
self.assertContains(response, "The Smiths")
self.assertContains(response, "Pat Smith")
self.assertContains(response, "Cody Smith")
def test_the_family_name_and_edit_action_both_link_to_family_detail(self):
response = self.club_get("family_list")
detail_url = reverse("management:family_detail", args=[self.family.pk])
self.assertContains(response, f'href="{detail_url}"', count=2)
def test_a_family_from_another_club_is_excluded(self):
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
other_season = make_season(other_club)
other_family = Family.objects.create()
other_member = Member.objects.create(first_name="Other", last_name="Kid")
ClubMembership.objects.create(club=other_club, member=other_member, season=other_season, status=ClubMembership.StatusChoices.ACTIVE)
FamilyMembership.objects.create(family=other_family, member=other_member, role=FamilyMembership.FamilyRole.CHILD)
response = self.club_get("family_list")
self.assertNotContains(response, "Other Kid")
def test_the_families_nav_link_is_reachable(self):
# The sidebar is two levels now (design_handoff_rosterchief_platform/README.md,
# D1/D2): Households only expands under Members while a Members-section page is
# current, not from every page the way the old flat nav showed it everywhere.
response = self.club_get("member_list")
self.assertContains(response, reverse("management:family_list"))
def test_a_non_admin_only_sees_families_they_have_a_reason_to(self):
# Same visibility rule as FamilyDetailView: a coach with no tie to this
# family shouldn't learn it exists just by opening the Families list.
coach_user = User.objects.create_user(email="coach-fam@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="U11", short_name="U11")
position = Position.objects.create(club=self.club, name="Coach-fam", short_name="CF", 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_get("family_list")
self.assertNotContains(response, "The Smiths")
def test_search_matches_a_parents_name(self):
response = self.client.get(reverse("management:family_list") + "?q=Pat", HTTP_HOST="ajax-united.rosterchief.app")
self.assertContains(response, "The Smiths")
def test_search_matches_a_childs_name(self):
response = self.client.get(reverse("management:family_list") + "?q=Cody", HTTP_HOST="ajax-united.rosterchief.app")
self.assertContains(response, "The Smiths")
def test_search_matches_a_last_name(self):
response = self.client.get(reverse("management:family_list") + "?q=Smith", HTTP_HOST="ajax-united.rosterchief.app")
self.assertContains(response, "The Smiths")
def test_search_with_no_match_excludes_the_family(self):
response = self.client.get(reverse("management:family_list") + "?q=Nobody", HTTP_HOST="ajax-united.rosterchief.app")
self.assertNotContains(response, "The Smiths")
def test_search_term_is_kept_in_the_input(self):
response = self.client.get(reverse("management:family_list") + "?q=Pat", HTTP_HOST="ajax-united.rosterchief.app")
self.assertContains(response, 'value="Pat"')
def test_search_still_respects_visibility(self):
# A search term matching someone real must not surface a family the
# requester otherwise has no reason to see.
coach_user = User.objects.create_user(email="coach-fam2@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-fam2", short_name="CF2", 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.client.get(reverse("management:family_list") + "?q=Pat", HTTP_HOST="ajax-united.rosterchief.app")
self.assertNotContains(response, "The Smiths")
class SidebarCounterTests(ManagementTestBase):
"""The nav's two admin-only badges -- pending parent claims, and upcoming
club-managed games nobody's down to referee yet. See
management.context_processors.sidebar_counters."""
def make_pending_claim(self):
return ParentClaim.objects.create(
club=self.club,
parent_first_name="Pat",
parent_last_name="Parent",
parent_email="pat-claim@example.com",
child_first_name="Cody",
child_last_name="Child",
child_date_of_birth=datetime.date(2015, 1, 1),
)
def make_home_game(self, referee=None):
team = Team.objects.create(club=self.club, name="First Team", short_name="1st")
home_ground = Location.objects.create(club=self.club, name="Home Ground", address="1 St", city="Town", zip_code="1000", country="BE", is_home=True)
event = Event.objects.create(club=self.club, title="Cup game", kind=Event.EventKind.GAME, location=home_ground, start=timezone.now() + datetime.timedelta(days=1))
event.teams.add(team)
if referee is not None:
EventReferee.objects.create(event=event, member=referee, assigned_by=self.admin_member)
return event
def test_zero_is_shown_explicitly_when_nothing_is_pending(self):
self.client.force_login(self.admin_user)
response = self.club_get("home")
self.assertEqual(response.context["pending_parent_claims_count"], 0)
self.assertEqual(response.context["games_missing_referees_count"], 0)
def test_counts_reflect_a_pending_claim_and_an_unrefereed_game(self):
self.make_pending_claim()
self.make_home_game()
self.client.force_login(self.admin_user)
response = self.club_get("home")
self.assertEqual(response.context["pending_parent_claims_count"], 1)
self.assertEqual(response.context["games_missing_referees_count"], 1)
def test_a_refereed_game_does_not_count(self):
referee = Member.objects.create(first_name="Ref", last_name="Eree")
ClubMembership.objects.create(club=self.club, member=referee, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
self.make_home_game(referee=referee)
self.client.force_login(self.admin_user)
response = self.club_get("home")
self.assertEqual(response.context["games_missing_referees_count"], 0)
def test_an_already_reviewed_claim_does_not_count(self):
claim = self.make_pending_claim()
claim.status = ParentClaim.Status.APPROVED
claim.save(update_fields=["status"])
self.client.force_login(self.admin_user)
response = self.club_get("home")
self.assertEqual(response.context["pending_parent_claims_count"], 0)
def test_a_non_admin_gets_no_counters_and_no_badge_links(self):
coach_user = User.objects.create_user(email="coach-badge@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="U10", short_name="U10")
position = Position.objects.create(club=self.club, name="Coach-badge", short_name="CB", 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_get("home")
self.assertIsNone(response.context["pending_parent_claims_count"])
self.assertIsNone(response.context["games_missing_referees_count"])
self.assertNotContains(response, reverse("management:parent_claim_list"))
self.assertNotContains(response, reverse("management:referee_management"))
class ManagementListPaginationTests(ManagementTestBase):
"""paginate_by on the six paginated lists (MemberListView/EventListView/
TeamListView/GroupListView/NewsListView/FamilyListView) and the shared
management/_pagination.html pager. Exercised with a patched-down page size
rather than dozens of fixture rows."""
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def make_members(self, count, last_name="Match"):
members = []
for i in range(count):
member = Member.objects.create(first_name=f"Search{i}", last_name=last_name)
ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
members.append(member)
return members
def test_page_2_shows_different_rows_than_page_1(self):
self.make_members(5)
with mock.patch("management.views.MemberListView.paginate_by", 2):
page1 = self.client.get(reverse("management:member_list") + "?q=Match", HTTP_HOST="ajax-united.rosterchief.app")
page2 = self.client.get(reverse("management:member_list") + "?q=Match&page=2", HTTP_HOST="ajax-united.rosterchief.app")
page1_ids = {member.pk for member in page1.context["members"]}
page2_ids = {member.pk for member in page2.context["members"]}
self.assertEqual(len(page1_ids), 2)
self.assertEqual(len(page2_ids), 2)
self.assertEqual(page1_ids & page2_ids, set())
def test_a_pagination_link_preserves_the_search_query_string(self):
self.make_members(5)
with mock.patch("management.views.MemberListView.paginate_by", 2):
response = self.client.get(reverse("management:member_list") + "?q=Match", HTTP_HOST="ajax-united.rosterchief.app")
self.assertContains(response, "q=Match&page=2")
def test_family_list_pagination_is_wired_and_shows_a_page_count(self):
for i in range(3):
family = Family.objects.create()
member = Member.objects.create(first_name=f"Fam{i}", last_name="Ily")
FamilyMembership.objects.create(family=family, member=member, role=FamilyMembership.FamilyRole.CHILD)
ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
with mock.patch("management.views.FamilyListView.paginate_by", 2):
response = self.club_get("family_list")
self.assertTrue(response.context["is_paginated"])
self.assertContains(response, "Page 1 of 2")
def test_event_team_group_news_lists_are_all_wired_for_pagination(self):
# A lighter "is it wired" check for the remaining four -- MemberListView
# and FamilyListView above already cover the actual pager mechanics
# (page split + query-string preservation), which is shared markup
# (_pagination.html) and shared ListView machinery (paginate_by), not
# something that differs meaningfully per view.
for name in ["event_list", "team_list", "group_list", "news_list"]:
# event_list defaults to the unpaginated D5-style calendar now --
# ?view=list is what opts back into the paginated table this loop means to check.
params = {"view": "list"} if name == "event_list" else None
response = self.club_get(name, params=params)
self.assertIsNotNone(response.context["paginator"], name)
class OnboardingRequirementManagementTests(ManagementTestBase):
"""Club-admin CRUD for OnboardingRequirement -- management:onboarding_requirement_*."""
def setUp(self):
self.client.force_login(self.admin_user)
def test_an_admin_can_create_a_requirement(self):
response = self.club_post("onboarding_requirement_create", {"name": "Photo", "requires_document": "", "is_active": "on", "order": "1"})
self.assertRedirects(response, reverse("management:onboarding_requirement_list"))
self.assertTrue(OnboardingRequirement.objects.filter(club=self.club, name="Photo").exists())
def test_a_coach_cannot_create_one(self):
coach = User.objects.create_user(email="coach-req@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=coach, first_name="Coach", last_name="Person")
ClubMembership.objects.create(club=self.club, member=coach_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
self.client.force_login(coach)
response = self.club_post("onboarding_requirement_create", {"name": "Photo", "order": "1"})
self.assertEqual(response.status_code, 403)
self.assertFalse(OnboardingRequirement.objects.filter(club=self.club).exists())
def test_deleting_a_requirement_keeps_existing_statuses_for_the_record(self):
requirement = OnboardingRequirement.objects.create(club=self.club, name="Waiver")
member = Member.objects.create(first_name="Sam", last_name="Roe")
membership = ClubMembership.objects.create(club=self.club, member=member, season=self.season)
status = MemberRequirementStatus.objects.create(membership=membership, requirement=requirement, is_complete=True)
self.club_post("onboarding_requirement_delete", {}, requirement.pk)
self.assertFalse(OnboardingRequirement.objects.filter(pk=requirement.pk).exists())
# The cascade is deliberate here (unlike the note above) -- see
# club.models.MemberRequirementStatus.requirement's on_delete=CASCADE:
# a deleted requirement has nothing left to be a status *of*.
self.assertFalse(MemberRequirementStatus.objects.filter(pk=status.pk).exists())
class MemberRequirementChecklistTests(ManagementTestBase):
"""Marking a member's checklist items done/undone from their own page, and
downloading whatever document was attached -- management:member_requirement_*.
Uploads land on rosterchief.storage.private_storage's real, configured location
(PRIVATE_MEDIA_ROOT) rather than a tempdir substituted via override_settings: that
storage is a module-level singleton built once at process start (see
rosterchief/storage.py), and Django's override_settings machinery only reloads
django.core.files.storage.storages/default_storage on a STORAGES change -- it has
no way to know about a hand-built FileSystemStorage instance a model field
happens to hold a reference to, so overriding the setting here would silently do
nothing and this class would look green while actually writing outside a tempdir
it thinks it's using. Each uploading test cleans up after itself instead, the
same way FeePayment/Club-logo-style tests elsewhere in this suite do.
"""
def setUp(self):
self.client.force_login(self.admin_user)
self.requirement = OnboardingRequirement.objects.create(club=self.club, name="Medical certificate", requires_document=True)
self.member = Member.objects.create(first_name="Noor", last_name="Somers")
self.membership = ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE, fee_status=ClubMembership.FeeStatus.PAID)
def upload_certificate(self):
upload = SimpleUploadedFile("certificate.pdf", b"%PDF-1.4 fake", content_type="application/pdf")
self.club_post("member_requirement_complete", {"document": upload, "note": ""}, self.member.pk, self.requirement.pk)
status = MemberRequirementStatus.objects.get(membership=self.membership, requirement=self.requirement)
self.addCleanup(status.document.delete, save=False)
return status
def test_staff_can_mark_a_requirement_complete_with_a_document(self):
upload = SimpleUploadedFile("certificate.pdf", b"%PDF-1.4 fake", content_type="application/pdf")
response = self.club_post("member_requirement_complete", {"document": upload, "note": "handed in at practice"}, self.member.pk, self.requirement.pk)
self.assertRedirects(response, reverse("management:member_detail", args=[self.member.pk]))
status = MemberRequirementStatus.objects.get(membership=self.membership, requirement=self.requirement)
self.addCleanup(status.document.delete, save=False)
self.assertTrue(status.is_complete)
self.assertEqual(status.completed_by, self.admin_user)
self.assertTrue(status.document.name)
# The whole point: paid AND active, but still had an open requirement a moment
# ago -- marking it complete must never touch either field.
self.membership.refresh_from_db()
self.assertEqual(self.membership.status, ClubMembership.StatusChoices.ACTIVE)
self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PAID)
def test_a_coach_can_still_mark_one_complete(self):
# Any staff can update a member's checklist -- same visibility as the rest of
# their profile (ClubStaffRequiredMixin), not admin-only like defining the
# requirement itself. That still means a *real* staff assignment though: a
# coach only sees members on a team they're staffed on (members_visible_to),
# so the fixture needs to put self.member on that coach's own roster, not
# just any staff role in the club.
team = Team.objects.create(club=self.club, name="U16")
coach_position = Position.objects.create(club=self.club, name="Head Coach", staff_position=True, management_position=True)
player_position = Position.objects.create(club=self.club, name="Forward", staff_position=False)
coach = User.objects.create_user(email="coach-checklist@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=coach, first_name="Coach", last_name="Person")
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=coach_position)
enrol_mfa(coach)
TeamMembership.objects.create(team=team, member=self.member, season=self.season, position=player_position, jersey_number=9)
self.client.force_login(coach)
response = self.club_post("member_requirement_complete", {"note": ""}, self.member.pk, self.requirement.pk)
self.assertRedirects(response, reverse("management:member_detail", args=[self.member.pk]))
self.assertTrue(MemberRequirementStatus.objects.get(membership=self.membership, requirement=self.requirement).is_complete)
def test_marking_incomplete_keeps_the_document_on_file(self):
self.upload_certificate()
self.club_post("member_requirement_incomplete", {}, self.member.pk, self.requirement.pk)
status = MemberRequirementStatus.objects.get(membership=self.membership, requirement=self.requirement)
self.assertFalse(status.is_complete)
self.assertTrue(status.document.name)
def test_downloading_the_document_requires_management_access(self):
self.upload_certificate()
response = self.club_get("member_requirement_document", self.member.pk, self.requirement.pk)
self.assertEqual(response.status_code, 200)
self.assertEqual(b"".join(response.streaming_content), b"%PDF-1.4 fake")
self.client.logout()
anonymous_response = self.club_get("member_requirement_document", self.member.pk, self.requirement.pk)
self.assertEqual(anonymous_response.status_code, 302)
def test_downloading_with_no_document_uploaded_is_a_404(self):
response = self.club_get("member_requirement_document", self.member.pk, self.requirement.pk)
self.assertEqual(response.status_code, 404)
def test_the_document_is_never_reachable_through_the_public_media_url(self):
# The whole point of a separate, private storage (rosterchief/storage.py):
# nothing here ever gets a /media/ URL at all.
status = self.upload_certificate()
with self.assertRaises(ValueError):
_ = status.document.url
def test_staff_can_bypass_a_requirement_with_a_note(self):
response = self.club_post("member_requirement_bypass", {"note": "already has a recent photo on file"}, self.member.pk, self.requirement.pk)
self.assertRedirects(response, reverse("management:member_detail", args=[self.member.pk]))
status = MemberRequirementStatus.objects.get(membership=self.membership, requirement=self.requirement)
self.assertTrue(status.is_bypassed)
self.assertFalse(status.is_complete)
self.assertEqual(status.completed_by, self.admin_user)
def test_bypassing_without_a_note_is_rejected(self):
self.club_post("member_requirement_bypass", {"note": ""}, self.member.pk, self.requirement.pk)
self.assertFalse(MemberRequirementStatus.objects.filter(membership=self.membership, requirement=self.requirement, is_bypassed=True).exists())
def test_bypass_honours_next_for_the_signup_page(self):
response = self.club_post("member_requirement_bypass", {"note": "waived", "next": reverse("management:signup_list")}, self.member.pk, self.requirement.pk)
self.assertRedirects(response, reverse("management:signup_list"))
def test_complete_honours_next_for_the_signup_page(self):
response = self.club_post("member_requirement_complete", {"note": "", "next": reverse("management:signup_list")}, self.member.pk, self.requirement.pk)
self.assertRedirects(response, reverse("management:signup_list"))
class SignupDashboardTests(ManagementTestBase):
"""The admin-only Sign-up page -- management:signup_*."""
def setUp(self):
self.client.force_login(self.admin_user)
def make_pending_member(self, fee_status=ClubMembership.FeeStatus.UNPAID):
member = Member.objects.create(first_name="Noor", last_name="Somers")
membership = ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=fee_status)
return member, membership
def test_is_admin_only(self):
coach = User.objects.create_user(email="coach-signup@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=coach, first_name="Cara", last_name="Coach")
team = Team.objects.create(club=self.club, name="U9", short_name="U9")
position = Position.objects.create(club=self.club, name="Coach-signup", short_name="CS", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
self.client.force_login(coach)
self.assertEqual(self.club_get("signup_list").status_code, 403)
def test_lists_every_current_season_member_kind_membership(self):
member, _membership = self.make_pending_member()
response = self.club_get("signup_list")
self.assertContains(response, "Somers")
self.assertContains(response, member.first_name)
def test_the_table_shows_each_members_fee_status(self):
# FeeStatus labels are stored lowercase ("paid") -- .badge's own CSS
# (text-transform: uppercase) is what makes it read "PAID" on screen.
self.make_pending_member(fee_status=ClubMembership.FeeStatus.PAID)
response = self.club_get("signup_list")
self.assertContains(response, ">paid<")
def test_a_guardian_kind_membership_is_excluded(self):
guardian = Member.objects.create(first_name="Pat", 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("signup_list")
self.assertNotContains(response, "Guardian")
def test_shows_the_teams_a_member_has_been_placed_on(self):
member, _membership = self.make_pending_member()
team = Team.objects.create(club=self.club, name="U9", short_name="U9")
position = Position.objects.create(club=self.club, name="Forward", staff_position=False)
TeamMembership.objects.create(team=team, member=member, season=self.season, position=position)
response = self.club_get("signup_list")
self.assertContains(response, "U9")
def test_approve_all_clean_activates_a_paid_and_fully_checked_member(self):
_member, membership = self.make_pending_member(fee_status=ClubMembership.FeeStatus.PAID)
requirement = OnboardingRequirement.objects.create(club=self.club, name="Photo")
mark_complete(membership, requirement, user=self.admin_user)
response = self.club_post("signup_approve_all_clean", {})
self.assertRedirects(response, reverse("management:signup_list"))
membership.refresh_from_db()
self.assertEqual(membership.status, ClubMembership.StatusChoices.ACTIVE)
def test_approve_all_clean_is_admin_only(self):
coach = User.objects.create_user(email="coach-approve@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=coach, first_name="Cara", last_name="Coach")
team = Team.objects.create(club=self.club, name="U9", short_name="U9")
position = Position.objects.create(club=self.club, name="Coach-approve", short_name="CA", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
self.client.force_login(coach)
self.assertEqual(self.club_post("signup_approve_all_clean", {}).status_code, 403)
def test_approve_one_activates_a_clean_pending_membership(self):
_member, membership = self.make_pending_member(fee_status=ClubMembership.FeeStatus.PAID)
response = self.club_post("signup_approve_one", {}, membership.member.pk)
self.assertRedirects(response, reverse("management:signup_list"))
membership.refresh_from_db()
self.assertEqual(membership.status, ClubMembership.StatusChoices.ACTIVE)
def test_approve_one_leaves_an_unpaid_membership_pending(self):
_member, membership = self.make_pending_member(fee_status=ClubMembership.FeeStatus.UNPAID)
self.club_post("signup_approve_one", {}, membership.member.pk)
membership.refresh_from_db()
self.assertEqual(membership.status, ClubMembership.StatusChoices.PENDING)
def test_approve_one_is_admin_only(self):
_member, membership = self.make_pending_member(fee_status=ClubMembership.FeeStatus.PAID)
coach = User.objects.create_user(email="coach-approveone@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=coach, first_name="Cara", last_name="Coach")
team = Team.objects.create(club=self.club, name="U9", short_name="U9")
position = Position.objects.create(club=self.club, name="Coach-approveone", short_name="CO", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
self.client.force_login(coach)
self.assertEqual(self.club_post("signup_approve_one", {}, membership.member.pk).status_code, 403)
def test_the_longest_waiting_pending_member_is_highlighted(self):
_older_member, older = self.make_pending_member()
older.created = timezone.now() - datetime.timedelta(days=5)
older.save(update_fields=["created"])
self.make_pending_member()
response = self.club_get("signup_list")
by_pk = {membership.pk: membership for membership in response.context["memberships"]}
self.assertTrue(by_pk[older.pk].is_oldest_pending)
self.assertEqual(sum(1 for m in response.context["memberships"] if m.is_oldest_pending), 1)
def test_an_already_active_membership_is_never_flagged_as_oldest_pending(self):
member = Member.objects.create(first_name="Ada", last_name="Active")
ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE, fee_status=ClubMembership.FeeStatus.PAID)
response = self.club_get("signup_list")
self.assertFalse(any(m.is_oldest_pending for m in response.context["memberships"]))
def test_an_active_and_fully_clean_membership_is_dropped_from_the_queue(self):
# Regression: an already-ACTIVE member whose checklist is fully resolved
# and fees are paid has nothing left for this page to do -- it used to
# trail at the bottom of the table forever, reading as if it still
# needed processing.
requirement = OnboardingRequirement.objects.create(club=self.club, name="Photo")
_member, membership = self.make_pending_member(fee_status=ClubMembership.FeeStatus.PAID)
membership.status = ClubMembership.StatusChoices.ACTIVE
membership.save(update_fields=["status"])
mark_complete(membership, requirement, user=self.admin_user)
response = self.club_get("signup_list")
self.assertNotIn(membership.pk, {m.pk for m in response.context["memberships"]})
def test_an_active_but_not_yet_clean_membership_still_shows(self):
_member, membership = self.make_pending_member(fee_status=ClubMembership.FeeStatus.UNPAID)
membership.status = ClubMembership.StatusChoices.ACTIVE
membership.save(update_fields=["status"])
response = self.club_get("signup_list")
self.assertIn(membership.pk, {m.pk for m in response.context["memberships"]})
def test_two_members_sharing_an_unstarted_requirement_get_distinct_bypass_dialog_ids(self):
# Regression: the bypass dialog id used to fall back to the requirement's
# own pk whenever a member hadn't started it yet, so every member sharing
# an untouched requirement rendered an *identical* dialog id -- clicking
# "Bypass" for anyone but the first member in the list opened the first
# member's dialog instead, since getElementById only ever returns the
# first DOM match.
requirement = OnboardingRequirement.objects.create(club=self.club, name="Photo")
_first_member, first_membership = self.make_pending_member()
_second_member, second_membership = self.make_pending_member()
response = self.club_get("signup_list")
self.assertContains(response, f'id="bypass_{first_membership.pk}_{requirement.pk}"')
self.assertContains(response, f'id="bypass_{second_membership.pk}_{requirement.pk}"')
def test_place_in_team_rosters_a_still_pending_member(self):
member, _membership = self.make_pending_member()
team = Team.objects.create(club=self.club, name="U9", short_name="U9")
Position.objects.create(club=self.club, name="Forward", staff_position=False)
response = self.club_post("signup_place_in_team", {"team": team.pk}, member.pk)
self.assertRedirects(response, reverse("management:signup_list"))
self.assertTrue(TeamMembership.objects.filter(team=team, member=member, season=self.season).exists())
def test_place_in_team_over_ajax_returns_json_instead_of_redirecting(self):
# signup_list.html's own fetch() handler -- X-Requested-With is what tells
# the view to answer with JSON rather than a normal redirect+Django message.
member, _membership = self.make_pending_member()
team = Team.objects.create(club=self.club, name="U9", short_name="U9")
Position.objects.create(club=self.club, name="Forward", staff_position=False)
response = self.client.post(
reverse("management:signup_place_in_team", args=[member.pk]),
{"team": team.pk},
HTTP_HOST="ajax-united.rosterchief.app",
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["ok"], True)
self.assertTrue(TeamMembership.objects.filter(team=team, member=member, season=self.season).exists())
def test_place_in_team_over_ajax_reports_failure_without_placing_twice(self):
member, _membership = self.make_pending_member()
team = Team.objects.create(club=self.club, name="U9", short_name="U9")
position = Position.objects.create(club=self.club, name="Forward", staff_position=False)
TeamMembership.objects.create(team=team, member=member, season=self.season, position=position)
response = self.client.post(
reverse("management:signup_place_in_team", args=[member.pk]),
{"team": team.pk},
HTTP_HOST="ajax-united.rosterchief.app",
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["ok"], False)
self.assertEqual(TeamMembership.objects.filter(team=team, member=member, season=self.season).count(), 1)
def test_place_in_team_does_not_require_eligible_roster_members(self):
# eligible_roster_members(club) -- what TeamMembershipForm's own dropdown
# uses -- would exclude a still-PENDING member entirely; this path must not.
member, membership = self.make_pending_member()
self.assertNotIn(member, eligible_roster_members(self.club))
team = Team.objects.create(club=self.club, name="U9", short_name="U9")
Position.objects.create(club=self.club, name="Forward", staff_position=False)
self.club_post("signup_place_in_team", {"team": team.pk}, member.pk)
self.assertTrue(TeamMembership.objects.filter(team=team, member=member, season=self.season).exists())
membership.refresh_from_db()
self.assertEqual(membership.status, ClubMembership.StatusChoices.PENDING)
def test_place_in_team_is_position_free_and_leaves_it_blank(self):
# No dropdown, no jersey number -- just the team. Position stays blank
# (TeamMembership.position is nullable) rather than guessed at: it's
# the team manager's own call to get right later (team_roster_update),
# and a blank position is also what groups every just-placed member
# together (e.g. via the API) until it's set.
member, _membership = self.make_pending_member()
team = Team.objects.create(club=self.club, name="U9", short_name="U9")
Position.objects.create(club=self.club, name="Forward", staff_position=False)
self.club_post("signup_place_in_team", {"team": team.pk}, member.pk)
roster_spot = TeamMembership.objects.get(team=team, member=member, season=self.season)
self.assertIsNone(roster_spot.position)
def test_place_in_team_disabled_button_for_an_already_placed_team_is_a_no_op(self):
# The button for a team the member is already on renders disabled (see
# signup_list.html) -- but the endpoint itself has to handle a resend
# gracefully too (unique_member_per_team_per_season), not 500.
member, _membership = self.make_pending_member()
team = Team.objects.create(club=self.club, name="U9", short_name="U9")
position = Position.objects.create(club=self.club, name="Forward", staff_position=False)
TeamMembership.objects.create(team=team, member=member, season=self.season, position=position)
response = self.club_post("signup_place_in_team", {"team": team.pk}, member.pk)
self.assertRedirects(response, reverse("management:signup_list"))
self.assertEqual(TeamMembership.objects.filter(team=team, member=member, season=self.season).count(), 1)
class MemberAdminAccessTests(ManagementTestBase):
"""MEMBER_ADMIN: full read/write on people, teams, referee setup, and onboarding
requirements -- but not Finance/Shop, Club identity, Sponsors, Roles, or the
Sign-up page (all of those stay ClubAdminRequiredMixin, real admin only)."""
def setUp(self):
self.member_admin_user = User.objects.create_user(email="memberadmin@example.com", password="pw-secret-123")
member_admin_member = Member.objects.create(user=self.member_admin_user, first_name="Mo", last_name="Admin")
ClubMembership.objects.create(club=self.club, member=member_admin_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
ClubRole.objects.filter(club=self.club, member=member_admin_member).update(role=ClubRole.Roles.MEMBER_ADMIN)
enrol_mfa(self.member_admin_user)
self.client.force_login(self.member_admin_user)
def test_can_create_a_member(self):
response = self.club_post("member_create", {"first_name": "New", "last_name": "Player", "email": "newplayer@example.com"})
self.assertTrue(Member.objects.filter(first_name="New", last_name="Player").exists())
self.assertNotEqual(response.status_code, 403)
def test_can_create_a_team(self):
response = self.club_post("team_create", {"name": "U12", "short_name": "U12", "referee_management": Team.RefereeManagement.CLUB})
self.assertTrue(Team.objects.filter(club=self.club, name="U12").exists())
self.assertNotEqual(response.status_code, 403)
def test_can_reach_referee_management(self):
self.assertEqual(self.club_get("referee_management").status_code, 200)
def test_can_reach_onboarding_requirements(self):
self.assertEqual(self.club_get("onboarding_requirement_list").status_code, 200)
def test_can_reach_group_list(self):
self.assertEqual(self.club_get("group_list").status_code, 200)
def test_cannot_reach_finance(self):
self.assertEqual(self.club_get("membership_list").status_code, 403)
def test_cannot_reach_club_settings(self):
self.assertEqual(self.club_get("club_settings").status_code, 403)
def test_cannot_reach_roles(self):
self.assertEqual(self.club_get("role_list").status_code, 403)
def test_cannot_grant_a_role(self):
target = Member.objects.create(first_name="Target", last_name="Person")
ClubMembership.objects.create(club=self.club, member=target, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
response = self.club_post("role_create", {"member": target.pk, "role": ClubRole.Roles.ADMIN})
self.assertEqual(response.status_code, 403)
def test_cannot_reach_sponsors(self):
self.assertEqual(self.club_get("sponsor_list").status_code, 403)
def test_cannot_reach_signup(self):
self.assertEqual(self.club_get("signup_list").status_code, 403)
def test_cannot_reach_positions(self):
self.assertEqual(self.club_get("position_create").status_code, 403)