Files
RosterChief/management/tests.py
Bernard Siebens 5ef6ea5529 Move the Evaluations placeholder nav entry to Members
Fits better next to Groups/Households than under Settings -- it's about
people, not club configuration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-22 18:51:59 +02:00

7985 lines
405 KiB
Python

import datetime
import json
import os
import sys
from decimal import Decimal
from io import BytesIO
from unittest import mock
import openpyxl
from allauth.mfa.models import Authenticator
from django.contrib.auth import get_user_model
from django.core import mail
from django.core.cache import cache
from django.core.files.uploadedfile import SimpleUploadedFile
from django.template.loader import render_to_string
from django.test import TestCase, override_settings
from django.urls import NoReverseMatch, reverse
from django.utils import timezone
from waffle import get_waffle_flag_model
from billing.models import Plan, PlanPrice
from billing.services.dues import record_payment, subscribe
from club.models import Club, ClubMembership, ClubRole, DuesInvoice, FeePayment, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
from club.services.invoicing import DuesInvoicePDFError
from club.services.onboarding import mark_complete
from events.models import Attendance, Competition, Event, EventReferee, EventSeries, Location, Opponent, RefereeSignup
from events.services.rbihf_import import RBIHFImportError
from events.services.recurrence import detach_occurrence, generate_occurrences
from events.services.referees import add_external_referee
from management.bulk_import import TEMPLATE_COLUMNS
from management.email_previews import EMAIL_PREVIEWS
from management.pdf import PDFExportError, _tint_with_white, referee_form_colors, render_pdf
from management.pdf_previews import PDF_PREVIEWS
from management.recurrence_ui import build_rrule, describe_rrule, parse_rrule
from members.models import Family, FamilyMembership, Group, GroupMembership, Member, ParentClaim
from members.services.claims import children_awaiting_a_parent
from news.models import News, NewsPhoto
from notifications.models import Notification
from shop.models import Order
from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership, TeamPhoto
from teams.services import eligible_roster_members
User = get_user_model()
XLSX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
def make_import_workbook(rows):
"""An in-memory .xlsx upload -- header matching the real template, plus
whatever data rows a test wants to exercise."""
workbook = openpyxl.Workbook()
sheet = workbook.active
sheet.append(TEMPLATE_COLUMNS)
for row in rows:
sheet.append(row)
buffer = BytesIO()
workbook.save(buffer)
buffer.seek(0)
return SimpleUploadedFile("members.xlsx", buffer.read(), content_type=XLSX_CONTENT_TYPE)
def enrol_mfa(user):
return Authenticator.objects.create(user=user, type=Authenticator.Type.TOTP, data={"secret": "JBSWY3DPEHPK3PXP"})
def make_season(club):
# Must genuinely cover *today*: current_season()/teams_staffed_by() key off
# Season.covering(club, timezone.localdate()), not just any season row.
today = timezone.localdate()
return Season.objects.create(club=club, start_date=today - datetime.timedelta(days=30), end_date=today + datetime.timedelta(days=300))
@override_settings(
ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "rival-fc.rosterchief.app", "testserver"],
)
class ManagementTestBase(TestCase):
# setUpTestData, not setUp: the club/season/admin fixture is read-only for
# almost every test, so building it once per class (inside the class-wide
# transaction Django rolls back) instead of once per test saves the whole
# suite a lot of inserts and password hashes. Django hands each test its own
# deepcopy of these attributes, so a test that mutates one stays isolated.
@classmethod
def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
cls.season = make_season(cls.club)
cls.admin_user = User.objects.create_user(email="admin@example.com", password="pw-secret-123")
cls.admin_member = Member.objects.create(user=cls.admin_user, first_name="Ada", last_name="Admin")
ClubMembership.objects.create(club=cls.club, member=cls.admin_member, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE)
ClubRole.objects.filter(club=cls.club, member=cls.admin_member).update(role=ClubRole.Roles.ADMIN)
enrol_mfa(cls.admin_user)
def club_get(self, name, *args, params=None):
return self.client.get(reverse(f"management:{name}", args=args), data=params, HTTP_HOST="ajax-united.rosterchief.app")
def club_post(self, name, data, *args):
return self.client.post(reverse(f"management:{name}", args=args), data, HTTP_HOST="ajax-united.rosterchief.app")
class AccessTests(ManagementTestBase):
def test_anonymous_is_sent_to_login(self):
response = self.club_get("home")
self.assertEqual(response.status_code, 302)
self.assertIn(reverse("account_login"), response.url)
def test_a_club_member_with_no_role_or_staff_assignment_gets_403(self):
plain_user = User.objects.create_user(email="plain@example.com", password="pw-secret-123")
self.client.force_login(plain_user)
self.assertEqual(self.club_get("home").status_code, 403)
def test_a_plain_active_club_member_gets_403(self):
# An active ClubMembership auto-grants a MEMBER ClubRole (club/signals.py) --
# every signed-up player has one. That alone must not be enough to get in, or
# players would reach the staff UI they're explicitly excluded from.
player_user = User.objects.create_user(email="player@example.com", password="pw-secret-123")
player_member = Member.objects.create(user=player_user, first_name="Paul", last_name="Player")
ClubMembership.objects.create(club=self.club, member=player_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
self.client.force_login(player_user)
self.assertEqual(self.club_get("home").status_code, 403)
def test_a_club_admin_can_reach_it(self):
self.client.force_login(self.admin_user)
self.assertEqual(self.club_get("home").status_code, 200)
def test_it_does_not_exist_on_the_base_domain(self):
# The management UI manages one club; the mirror image of controlpanel
# refusing to exist on a club subdomain.
self.client.force_login(self.admin_user)
response = self.client.get(reverse("management:home"), HTTP_HOST="rosterchief.app")
self.assertEqual(response.status_code, 404)
def test_staff_with_only_a_staff_assignment_can_reach_staff_pages(self):
# No ClubRole at all -- authority comes purely from a current-season
# StaffAssignment, per club.services.access.
coach_user = User.objects.create_user(email="coach@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
team = Team.objects.create(club=self.club, name="U12", short_name="U12")
position = Position.objects.create(club=self.club, name="Coach", short_name="C", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
self.client.force_login(coach_user)
self.assertEqual(self.club_get("member_list").status_code, 200)
def test_staff_without_admin_role_cannot_reach_admin_only_pages(self):
coach_user = User.objects.create_user(email="coach2@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cody", last_name="Coach")
team = Team.objects.create(club=self.club, name="U13", short_name="U13")
position = Position.objects.create(club=self.club, name="Coach2", short_name="C2", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
self.client.force_login(coach_user)
# position_list itself is open to any staff (see TeamAndPositionAccessTests)
# -- creating and editing positions stays admin-only.
self.assertEqual(self.club_get("position_create").status_code, 403)
self.assertEqual(self.club_post("member_create", {"first_name": "X", "last_name": "Y"}).status_code, 403)
class NavLinkTests(ManagementTestBase):
"""The global navbar's "Management" link, next to Django admin -- see
management.context_processors.management_link and templates/_base.html."""
def test_a_club_admin_sees_the_link_on_the_club_subdomain(self):
self.client.force_login(self.admin_user)
self.assertContains(self.club_get("home"), reverse("management:home"))
def test_a_plain_active_club_member_does_not_see_the_link(self):
player_user = User.objects.create_user(email="player2@example.com", password="pw-secret-123")
player_member = Member.objects.create(user=player_user, first_name="Pia", last_name="Player")
ClubMembership.objects.create(club=self.club, member=player_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
self.client.login(email="player2@example.com", password="pw-secret-123")
# 403 on /manage/ itself, but the link must not appear on pages this user *can*
# reach either -- assert against a page outside the gate: the account view.
response = self.client.get(reverse("mfa_index"), HTTP_HOST="ajax-united.rosterchief.app")
self.assertNotContains(response, reverse("management:home"))
def test_the_link_is_absent_on_the_base_domain_even_for_a_club_admin(self):
# has_management_access requires a resolved club; the control panel/base domain
# has none, so the link -- which points at a single club's management app --
# correctly never appears there regardless of who's signed in.
self.client.force_login(self.admin_user)
response = self.client.get(reverse("mfa_index"), HTTP_HOST="rosterchief.app")
self.assertNotContains(response, reverse("management:home"))
class ActiveNavHighlightTests(ManagementTestBase):
"""The sidebar/mobile nav highlights whichever section the current page
belongs to -- see management.context_processors.active_nav_section."""
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def test_the_members_list_page_highlights_members(self):
response = self.club_get("member_list")
self.assertContains(response, f'class="nav-item active" href="{reverse("management:member_list")}"')
self.assertNotContains(response, f'class="nav-item active" href="{reverse("management:home")}"')
def test_a_member_detail_sub_page_still_highlights_members(self):
# member_detail has no nav entry of its own -- it belongs to the Members
# section, same as member_list, member_update, family_detail, etc.
member = Member.objects.create(first_name="Sub", last_name="Page")
ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
response = self.club_get("member_detail", member.pk)
self.assertContains(response, f'class="nav-item active" href="{reverse("management:member_list")}"')
def test_the_dashboard_highlights_dashboard_only(self):
response = self.club_get("home")
self.assertContains(response, f'class="nav-item active" href="{reverse("management:home")}"')
self.assertNotContains(response, f'class="nav-item active" href="{reverse("management:member_list")}"')
def test_roles_positions_and_referee_levels_pages_all_highlight_settings(self):
# Roles/Positions/Referee levels moved out of Members/Teams and into Settings --
# club-wide setup, not day-to-day people/roster work.
for name in ("role_list", "position_list", "referee_level_list"):
with self.subTest(name=name):
response = self.club_get(name)
self.assertContains(response, f'class="nav-item active" href="{reverse("management:club_settings")}"')
def test_referee_management_page_highlights_calendar(self):
# Referee management is the operational "who's covering this game" page, so it
# lives under Calendar with Events/Locations/Opponents, not under Settings.
response = self.club_get("referee_management")
self.assertContains(response, f'class="nav-item active" href="{reverse("management:event_list")}"')
def test_sponsor_list_page_highlights_finance(self):
# Sponsors sit under Finance, underneath Dues & billing.
response = self.club_get("sponsor_list")
self.assertContains(response, f'class="nav-item active" href="{reverse("management:membership_list")}"')
class SidebarThemingTests(ManagementTestBase):
"""The sidebar's own background/foreground follow Club.primary_color (base.html
emits --tenant-sidebar-bg/fg; assets/management.css's --color-sidebar-* tokens
fall back to the fixed dark-ink look when unset). Both primary_content_color and
secondary_content_color are the WCAG-luminance black-or-white choice already
computed on Club itself (club.tests.ClubBrandingModelTests covers that math) --
this just checks the value actually reaches the page."""
def setUp(self):
self.client.force_login(self.admin_user)
def test_a_set_primary_colour_feeds_the_sidebar_background(self):
self.club.primary_color = "#0f766e"
self.club.save(update_fields=["primary_color"])
response = self.club_get("home")
self.assertContains(response, "--tenant-sidebar-bg: #0f766e;")
def test_no_primary_colour_means_no_sidebar_override(self):
response = self.club_get("home")
self.assertNotContains(response, "--tenant-sidebar-bg")
def test_a_pale_primary_colour_switches_the_sidebar_text_to_black(self):
self.club.primary_color = "#fef08a"
self.club.save(update_fields=["primary_color"])
response = self.club_get("home")
self.assertContains(response, "--tenant-sidebar-fg: #000000;")
def test_a_dark_primary_colour_keeps_the_sidebar_text_white(self):
self.club.primary_color = "#1e40af"
self.club.save(update_fields=["primary_color"])
response = self.club_get("home")
self.assertContains(response, "--tenant-sidebar-fg: #ffffff;")
def test_a_set_secondary_colour_also_feeds_the_active_nav_items_text_colour(self):
self.club.secondary_color = "#fef08a"
self.club.save(update_fields=["secondary_color"])
response = self.club_get("home")
self.assertContains(response, "--tenant-club-content: #000000;")
def test_no_secondary_colour_means_no_active_nav_text_override(self):
response = self.club_get("home")
self.assertNotContains(response, "--tenant-club-content")
class MemberManagementTests(ManagementTestBase):
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def test_member_list_is_scoped_to_the_club(self):
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
other_season = make_season(other_club)
other_member = Member.objects.create(first_name="Other", last_name="Person")
ClubMembership.objects.create(club=other_club, member=other_member, season=other_season, status=ClubMembership.StatusChoices.ACTIVE)
response = self.club_get("member_list")
self.assertNotContains(response, "Other Person")
def test_creating_a_member_also_signs_them_up_for_the_current_season(self):
response = self.club_post("member_create", {"first_name": "New", "last_name": "Player", "email": "new@example.com"})
member = Member.objects.get(first_name="New", last_name="Player")
self.assertRedirects(response, reverse("management:member_detail", args=[member.pk]))
self.assertTrue(ClubMembership.objects.filter(club=self.club, member=member, season=self.season).exists())
def test_updating_a_member(self):
member = Member.objects.create(first_name="Old", last_name="Name")
ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
# This member has a current-season membership, so its section renders too --
# one combined submit, so its (required) fields must come along.
self.club_post(
"member_update",
{"first_name": "New", "last_name": "Name", "kind": ClubMembership.Kind.MEMBER, "status": ClubMembership.StatusChoices.ACTIVE, "fee_status": ClubMembership.FeeStatus.UNPAID},
member.pk,
)
member.refresh_from_db()
self.assertEqual(member.first_name, "New")
class MemberAttendanceSparklineTests(ManagementTestBase):
"""The D7-alike attendance card on member_detail.html --
events.services.attendance.member_attendance_sparkline/_counts."""
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st")
cls.position = Position.objects.create(club=cls.club, name="Forward", short_name="FW")
cls.player = Member.objects.create(first_name="Peter", last_name="Player")
ClubMembership.objects.create(club=cls.club, member=cls.player, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE)
TeamMembership.objects.create(team=cls.team, member=cls.player, season=cls.season, position=cls.position)
def setUp(self):
self.client.force_login(self.admin_user)
def make_attendance(self, *, status, start, showed_up=None):
event = Event.objects.create(club=self.club, title="Training", kind=Event.EventKind.TRAINING, start=start, season=self.season)
# A future event's roster is auto-synced on event.teams.add() (see
# events/signals.py), which already creates this row -- a past event's
# isn't (history is never rewritten), so get_or_create covers both.
event.teams.add(self.team)
attendance, created = Attendance.objects.get_or_create(event=event, member=self.player, defaults={"status": status, "showed_up": showed_up})
if not created:
attendance.status, attendance.showed_up = status, showed_up
attendance.save(update_fields=["status", "showed_up"])
return attendance
def test_the_card_is_hidden_for_a_member_not_rostered_this_season(self):
unrostered = Member.objects.create(first_name="Not", last_name="Rostered")
ClubMembership.objects.create(club=self.club, member=unrostered, season=self.season, status=ClubMembership.StatusChoices.PENDING)
response = self.club_get("member_detail", unrostered.pk)
self.assertNotContains(response, "attendance-sparkline")
def test_the_card_is_hidden_for_a_guardian(self):
guardian = Member.objects.create(first_name="Gale", last_name="Guardian")
ClubMembership.objects.create(club=self.club, member=guardian, season=self.season, kind=ClubMembership.Kind.GUARDIAN, status=ClubMembership.StatusChoices.ACTIVE)
response = self.club_get("member_detail", guardian.pk)
self.assertNotContains(response, "attendance-sparkline")
def test_a_past_present_event_counts_as_present(self):
self.make_attendance(status=Attendance.AttendanceStatus.PRESENT, start=timezone.now() - datetime.timedelta(days=2))
response = self.club_get("member_detail", self.player.pk)
self.assertEqual(response.context["attendance_counts"]["present"], 1)
self.assertEqual(response.context["attendance_sparkline"][0]["state"], "present")
def test_a_past_absent_event_counts_as_absent(self):
self.make_attendance(status=Attendance.AttendanceStatus.ABSENT, start=timezone.now() - datetime.timedelta(days=2))
response = self.club_get("member_detail", self.player.pk)
self.assertEqual(response.context["attendance_counts"]["absent"], 1)
self.assertEqual(response.context["attendance_sparkline"][0]["state"], "absent")
def test_a_future_event_is_upcoming_regardless_of_rsvp_status(self):
self.make_attendance(status=Attendance.AttendanceStatus.SELECTED, start=timezone.now() + datetime.timedelta(days=2))
response = self.club_get("member_detail", self.player.pk)
self.assertEqual(response.context["attendance_sparkline"][0]["state"], "upcoming")
# Only past events feed the Present/Absent/No-reply totals.
self.assertEqual(response.context["attendance_counts"]["present"], 0)
self.assertEqual(response.context["attendance_counts"]["absent"], 0)
self.assertEqual(response.context["attendance_counts"]["no_reply"], 0)
def test_a_past_unanswered_event_counts_as_no_reply(self):
self.make_attendance(status=Attendance.AttendanceStatus.NO_RESPONSE, start=timezone.now() - datetime.timedelta(days=2))
response = self.club_get("member_detail", self.player.pk)
self.assertEqual(response.context["attendance_counts"]["no_reply"], 1)
def test_the_sparkline_is_capped_at_twelve_bars_oldest_first(self):
for day in range(15, 0, -1):
self.make_attendance(status=Attendance.AttendanceStatus.PRESENT, start=timezone.now() - datetime.timedelta(days=day))
response = self.club_get("member_detail", self.player.pk)
sparkline = response.context["attendance_sparkline"]
self.assertEqual(len(sparkline), 12)
starts = [bar["event"].start for bar in sparkline]
self.assertEqual(starts, sorted(starts))
class TeamManagementTests(ManagementTestBase):
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def test_team_list_is_scoped_to_the_club(self):
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
Team.objects.create(club=other_club, name="Rival Team", short_name="RT")
response = self.club_get("team_list")
self.assertNotContains(response, "Rival Team")
def test_creating_a_team(self):
response = self.club_post("team_create", {"name": "U15", "short_name": "U15", "referee_management": "club"})
team = Team.objects.get(club=self.club, name="U15")
self.assertRedirects(response, reverse("management:team_detail", args=[team.pk]))
def test_creating_a_federation_managed_team(self):
self.club_post("team_create", {"name": "U15", "short_name": "U15", "referee_management": "federation"})
team = Team.objects.get(club=self.club, name="U15")
self.assertEqual(team.referee_management, Team.RefereeManagement.FEDERATION)
def test_deleting_a_team(self):
team = Team.objects.create(club=self.club, name="U16", short_name="U16")
response = self.club_post("team_delete", {}, team.pk)
self.assertRedirects(response, reverse("management:team_list"))
self.assertFalse(Team.objects.filter(pk=team.pk).exists())
def test_deleting_a_team_cascades_its_roster_and_staff(self):
team = Team.objects.create(club=self.club, name="U17", short_name="U17")
position = Position.objects.create(club=self.club, name="Coach17", short_name="C17", staff_position=True)
member = Member.objects.create(first_name="Sam", last_name="Staffer")
StaffAssignment.objects.create(team=team, member=member, season=self.season, position=position)
self.club_post("team_delete", {}, team.pk)
self.assertFalse(StaffAssignment.objects.filter(team=team).exists())
def test_a_non_admin_cannot_delete_a_team(self):
team = Team.objects.create(club=self.club, name="U18", short_name="U18")
coach_user = User.objects.create_user(email="coach-team-delete@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
position = Position.objects.create(club=self.club, name="CoachTeamDelete", short_name="CTD", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
self.client.force_login(coach_user)
response = self.club_post("team_delete", {}, team.pk)
self.assertEqual(response.status_code, 403)
self.assertTrue(Team.objects.filter(pk=team.pk).exists())
class TeamRosterStaffTests(ManagementTestBase):
"""Roster/staff management folded into the team page -- see
management.views.TeamDetailView and the TeamRoster*/TeamStaff* views."""
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st")
cls.other_team = Team.objects.create(club=cls.club, name="Second Team", short_name="2nd")
cls.player_position = Position.objects.create(club=cls.club, name="Forward", short_name="FW", staff_position=False)
cls.coach_position = Position.objects.create(club=cls.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True)
cls.player = Member.objects.create(first_name="Peter", last_name="Player")
ClubMembership.objects.create(club=cls.club, member=cls.player, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE)
# A coach for each team: "this team's coach may, that team's coach may not"
# is the whole point of this class, so both actors are standing fixtures.
cls.team_coach = cls.make_team_coach(cls.team, "coach-roster@example.com")
cls.other_team_coach = cls.make_team_coach(cls.other_team, "coach-roster-other@example.com")
@classmethod
def make_team_coach(cls, team, email):
coach_user = User.objects.create_user(email=email, password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
StaffAssignment.objects.create(team=team, member=coach_member, season=cls.season, position=cls.coach_position)
return coach_user
def test_nav_no_longer_lists_roster_or_staff(self):
# Not a bare "Roster"/"Staff" substring check -- "RosterChief" branding is
# on every page regardless. The nav's old icons are a safe, specific proxy.
self.client.force_login(self.admin_user)
response = self.club_get("team_list")
self.assertNotContains(response, "clipboard-list")
self.assertNotContains(response, "hard-hat")
def test_roster_and_staff_urls_no_longer_resolve(self):
with self.assertRaises(NoReverseMatch):
reverse("management:roster_list")
with self.assertRaises(NoReverseMatch):
reverse("management:staff_list")
def test_season_switcher_defaults_to_the_current_season(self):
self.client.force_login(self.admin_user)
TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position)
response = self.club_get("team_detail", self.team.pk)
self.assertContains(response, "Peter Player")
def test_season_switcher_honours_the_query_param(self):
other_season = Season.objects.create(club=self.club, start_date=datetime.date(2020, 1, 1), end_date=datetime.date(2020, 12, 31))
TeamMembership.objects.create(team=self.team, season=other_season, member=self.player, position=self.player_position)
self.client.force_login(self.admin_user)
default_response = self.club_get("team_detail", self.team.pk)
other_response = self.client.get(f"{reverse('management:team_detail', args=[self.team.pk])}?season={other_season.pk}", HTTP_HOST="ajax-united.rosterchief.app")
# Not assertNotContains("Peter Player") on the default response -- he's
# still a valid pick in the "Add player" combobox even when he isn't on
# *this* season's roster, so his name legitimately appears there too.
self.assertContains(default_response, "No one on the roster for this season yet.")
self.assertContains(other_response, "Peter Player")
def test_a_teams_own_coach_can_add_a_player(self):
self.client.force_login(self.team_coach)
response = self.club_post("team_roster_add", {"member": str(self.player.pk), "position": str(self.player_position.pk), "jersey_number": "9"}, self.team.pk, self.season.pk)
self.assertRedirects(response, f"{reverse('management:team_detail', args=[self.team.pk])}?season={self.season.pk}")
membership = TeamMembership.objects.get(team=self.team, season=self.season, member=self.player)
self.assertEqual(membership.jersey_number, 9)
def test_a_different_teams_coach_cannot_add_a_player(self):
self.client.force_login(self.other_team_coach)
response = self.club_post("team_roster_add", {"member": str(self.player.pk), "position": str(self.player_position.pk)}, self.team.pk, self.season.pk)
self.assertEqual(response.status_code, 403)
self.assertFalse(TeamMembership.objects.filter(team=self.team, season=self.season).exists())
def test_a_different_teams_coach_can_still_view_the_team(self):
self.client.force_login(self.other_team_coach)
response = self.club_get("team_detail", self.team.pk)
self.assertEqual(response.status_code, 200)
def test_admin_can_add_a_player_to_any_team(self):
self.client.force_login(self.admin_user)
self.club_post("team_roster_add", {"member": str(self.player.pk), "position": str(self.player_position.pk)}, self.team.pk, self.season.pk)
self.assertTrue(TeamMembership.objects.filter(team=self.team, season=self.season, member=self.player).exists())
def test_the_add_player_dropdown_excludes_a_member_with_no_active_membership(self):
lapsed_player = Member.objects.create(first_name="Lex", last_name="Lapsed")
ClubMembership.objects.create(club=self.club, member=lapsed_player, season=self.season, status=ClubMembership.StatusChoices.LAPSED)
self.client.force_login(self.admin_user)
response = self.club_get("team_detail", self.team.pk)
self.assertNotContains(response, "Lex Lapsed")
def test_the_add_player_dropdown_includes_a_member_active_only_next_season(self):
next_season = Season.objects.create(club=self.club, start_date=self.season.end_date + datetime.timedelta(days=1), end_date=self.season.end_date + datetime.timedelta(days=300))
upcoming_player = Member.objects.create(first_name="Uma", last_name="Upcoming")
ClubMembership.objects.create(club=self.club, member=upcoming_player, season=next_season, status=ClubMembership.StatusChoices.ACTIVE)
self.client.force_login(self.admin_user)
response = self.club_get("team_detail", self.team.pk)
self.assertContains(response, "Uma Upcoming")
def test_the_add_player_dropdown_excludes_a_member_active_only_in_a_past_season(self):
past_season = Season.objects.create(club=self.club, start_date=datetime.date(2020, 1, 1), end_date=datetime.date(2020, 12, 31))
past_player = Member.objects.create(first_name="Pip", last_name="Past")
ClubMembership.objects.create(club=self.club, member=past_player, season=past_season, status=ClubMembership.StatusChoices.ACTIVE)
self.client.force_login(self.admin_user)
response = self.club_get("team_detail", self.team.pk)
self.assertNotContains(response, "Pip Past")
def test_adding_the_same_member_twice_fails_with_a_form_error_not_a_500(self):
self.client.force_login(self.admin_user)
TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position)
response = self.club_post("team_roster_add", {"member": str(self.player.pk), "position": str(self.player_position.pk)}, self.team.pk, self.season.pk)
self.assertEqual(response.status_code, 302)
self.assertEqual(TeamMembership.objects.filter(team=self.team, season=self.season).count(), 1)
def test_a_duplicate_jersey_number_fails_with_a_form_error_not_a_500(self):
# team/season aren't TeamMembershipForm fields, so Django's own
# validate_unique() can't see unique_jersey_number_per_team_per_season --
# this constraint only gets checked because the form does it by hand.
other_player = Member.objects.create(first_name="Olly", last_name="Other")
ClubMembership.objects.create(club=self.club, member=other_player, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position, jersey_number=7)
self.client.force_login(self.admin_user)
response = self.club_post("team_roster_add", {"member": str(other_player.pk), "position": str(self.player_position.pk), "jersey_number": "7"}, self.team.pk, self.season.pk)
self.assertEqual(response.status_code, 302)
self.assertFalse(TeamMembership.objects.filter(team=self.team, season=self.season, member=other_player).exists())
def test_editing_a_roster_entry_to_a_clashing_jersey_number_fails_gracefully(self):
other_player = Member.objects.create(first_name="Olly", last_name="Other")
ClubMembership.objects.create(club=self.club, member=other_player, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position, jersey_number=7)
other_membership = TeamMembership.objects.create(team=self.team, season=self.season, member=other_player, position=self.player_position, jersey_number=8)
self.client.force_login(self.admin_user)
response = self.club_post(
"team_roster_update",
{"member": str(other_player.pk), "position": str(self.player_position.pk), "jersey_number": "7"},
self.team.pk,
other_membership.pk,
)
self.assertEqual(response.status_code, 302)
other_membership.refresh_from_db()
self.assertEqual(other_membership.jersey_number, 8)
def test_editing_a_roster_entry_updates_it(self):
membership = TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position, jersey_number=9)
self.client.force_login(self.admin_user)
self.club_post(
"team_roster_update",
{"member": str(self.player.pk), "position": str(self.player_position.pk), "jersey_number": "10", "is_captain": "on"},
self.team.pk,
membership.pk,
)
membership.refresh_from_db()
self.assertEqual(membership.jersey_number, 10)
self.assertTrue(membership.is_captain)
def test_removing_a_roster_entry_deletes_it(self):
membership = TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position)
self.client.force_login(self.admin_user)
self.club_post("team_roster_remove", {}, self.team.pk, membership.pk)
self.assertFalse(TeamMembership.objects.filter(pk=membership.pk).exists())
def test_a_teams_own_coach_can_assign_staff(self):
physio_position = Position.objects.create(club=self.club, name="Physio", short_name="PH", staff_position=True)
physio = Member.objects.create(first_name="Pat", last_name="Physio")
ClubMembership.objects.create(club=self.club, member=physio, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
self.client.force_login(self.team_coach)
self.club_post("team_staff_add", {"member": str(physio.pk), "position": str(physio_position.pk)}, self.team.pk, self.season.pk)
self.assertTrue(StaffAssignment.objects.filter(team=self.team, season=self.season, member=physio).exists())
def test_the_assign_staff_dropdown_excludes_a_member_active_only_in_a_past_season(self):
# Same eligibility rule as the "Add player" dropdown -- see
# test_the_add_player_dropdown_excludes_a_member_active_only_in_a_past_season.
past_season = Season.objects.create(club=self.club, start_date=datetime.date(2020, 1, 1), end_date=datetime.date(2020, 12, 31))
past_member = Member.objects.create(first_name="Sam", last_name="Stale")
ClubMembership.objects.create(club=self.club, member=past_member, season=past_season, status=ClubMembership.StatusChoices.ACTIVE)
self.client.force_login(self.admin_user)
response = self.club_get("team_detail", self.team.pk)
self.assertNotContains(response, "Sam Stale")
def test_a_different_teams_coach_cannot_assign_staff(self):
physio_position = Position.objects.create(club=self.club, name="Physio", short_name="PH", staff_position=True)
physio = Member.objects.create(first_name="Pat", last_name="Physio")
self.client.force_login(self.other_team_coach)
response = self.club_post("team_staff_add", {"member": str(physio.pk), "position": str(physio_position.pk)}, self.team.pk, self.season.pk)
self.assertEqual(response.status_code, 403)
def test_removing_a_staff_assignment_deletes_it(self):
assignment = StaffAssignment.objects.create(team=self.team, season=self.season, member=self.player, position=self.coach_position)
self.client.force_login(self.admin_user)
self.club_post("team_staff_remove", {}, self.team.pk, assignment.pk)
self.assertFalse(StaffAssignment.objects.filter(pk=assignment.pk).exists())
class TeamBulkAddTests(ManagementTestBase):
"""Adding many people to a team's roster/staff in one submit -- see
management.views.TeamBulkAddView. One formset row per assignment; the
position picked decides whether the row means a roster entry or a staff
one, and one bad row rejects the whole submit rather than half-saving."""
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st")
cls.other_team = Team.objects.create(club=cls.club, name="Second Team", short_name="2nd")
cls.player_position = Position.objects.create(club=cls.club, name="Forward", short_name="FW", staff_position=False)
cls.coach_position = Position.objects.create(club=cls.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True)
cls.player = Member.objects.create(first_name="Peter", last_name="Player")
ClubMembership.objects.create(club=cls.club, member=cls.player, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE)
cls.other_player = Member.objects.create(first_name="Olly", last_name="Other")
ClubMembership.objects.create(club=cls.club, member=cls.other_player, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE)
# A third eligible player, so a submit can pair one perfectly valid row with
# one bad one -- see test_a_good_row_alongside_a_bad_one_is_not_saved_either.
cls.third_player = Member.objects.create(first_name="Tara", last_name="Third")
ClubMembership.objects.create(club=cls.club, member=cls.third_player, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE)
coach_user = User.objects.create_user(email="coach-bulk@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
StaffAssignment.objects.create(team=cls.other_team, member=coach_member, season=cls.season, position=cls.coach_position)
cls.other_team_coach = coach_user
def bulk_data(self, *rows):
"""A formset POST body: one dict per row, plus the management form."""
data = {"form-TOTAL_FORMS": str(len(rows)), "form-INITIAL_FORMS": "0", "form-MIN_NUM_FORMS": "0", "form-MAX_NUM_FORMS": "1000"}
for index, row in enumerate(rows):
for name, value in row.items():
data[f"form-{index}-{name}"] = value
return data
def row(self, member, position, jersey_number="", is_captain=False, is_alternate_captain=False):
row = {"member": str(member.pk), "position": str(position.pk), "jersey_number": str(jersey_number)}
# Unchecked boxes aren't submitted at all, so only add the key when set --
# sending "off" would still read as True to a Django BooleanField.
if is_captain:
row["is_captain"] = "on"
if is_alternate_captain:
row["is_alternate_captain"] = "on"
return row
def bulk_add(self, *rows):
return self.club_post("team_bulk_add", self.bulk_data(*rows), self.team.pk, self.season.pk)
def test_the_picker_offers_eligible_members_and_marks_who_is_already_on(self):
TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position)
self.client.force_login(self.admin_user)
response = self.club_get("team_bulk_add", self.team.pk, self.season.pk)
self.assertContains(response, "Peter Player — on roster")
self.assertContains(response, "Olly Other")
def test_the_position_picker_marks_staff_positions_for_the_jersey_toggle(self):
# PositionSelect stamps data-staff on staff options; bulk-add-rows.js reads
# it to grey out the jersey input, which a StaffAssignment has no field for.
self.client.force_login(self.admin_user)
response = self.club_get("team_bulk_add", self.team.pk, self.season.pk)
self.assertContains(response, "data-staff")
def test_a_lapsed_member_is_not_offered(self):
lapsed = Member.objects.create(first_name="Lex", last_name="Lapsed")
ClubMembership.objects.create(club=self.club, member=lapsed, season=self.season, status=ClubMembership.StatusChoices.LAPSED)
self.client.force_login(self.admin_user)
response = self.club_get("team_bulk_add", self.team.pk, self.season.pk)
self.assertNotContains(response, "Lex Lapsed")
def test_admin_can_add_two_players_in_one_submit(self):
self.client.force_login(self.admin_user)
response = self.bulk_add(self.row(self.player, self.player_position, 9), self.row(self.other_player, self.player_position))
self.assertRedirects(response, f"{reverse('management:team_detail', args=[self.team.pk])}?season={self.season.pk}")
self.assertEqual(TeamMembership.objects.filter(team=self.team, season=self.season).count(), 2)
self.assertEqual(TeamMembership.objects.get(team=self.team, member=self.player).jersey_number, 9)
def test_a_staff_position_creates_a_staff_assignment_not_a_roster_entry(self):
# The position is what picks the role -- there is no separate control.
self.client.force_login(self.admin_user)
self.bulk_add(self.row(self.player, self.coach_position))
self.assertTrue(StaffAssignment.objects.filter(team=self.team, season=self.season, member=self.player).exists())
self.assertFalse(TeamMembership.objects.filter(team=self.team, season=self.season, member=self.player).exists())
def test_a_member_can_be_added_as_both_player_and_staff_via_two_rows(self):
self.client.force_login(self.admin_user)
self.bulk_add(self.row(self.player, self.player_position), self.row(self.player, self.coach_position))
self.assertTrue(TeamMembership.objects.filter(team=self.team, season=self.season, member=self.player).exists())
self.assertTrue(StaffAssignment.objects.filter(team=self.team, season=self.season, member=self.player).exists())
def test_a_jersey_number_on_a_staff_row_is_rejected(self):
self.client.force_login(self.admin_user)
response = self.bulk_add(self.row(self.player, self.coach_position, 9))
self.assertEqual(response.status_code, 200)
self.assertFalse(StaffAssignment.objects.filter(team=self.team, season=self.season).exists())
def test_a_jersey_clashing_with_an_existing_player_rejects_the_submit(self):
TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position, jersey_number=7)
self.client.force_login(self.admin_user)
response = self.bulk_add(self.row(self.other_player, self.player_position, 7))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "already taken")
self.assertFalse(TeamMembership.objects.filter(team=self.team, season=self.season, member=self.other_player).exists())
# All-or-nothing: nothing was saved, so the roster is untouched apart from
# the entry that was already there before the submit.
self.assertEqual(TeamMembership.objects.filter(team=self.team, season=self.season).count(), 1)
def test_a_good_row_alongside_a_bad_one_is_not_saved_either(self):
# The all-or-nothing guarantee proper: the other tests here submit rows that
# are either all bad or bad in pairs, so none of them would notice a valid
# row slipping through on its own. If it did, the re-rendered form would
# re-add it on the next submit.
TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position, jersey_number=7)
self.client.force_login(self.admin_user)
response = self.bulk_add(self.row(self.other_player, self.player_position, 7), self.row(self.third_player, self.player_position, 12))
self.assertEqual(response.status_code, 200)
self.assertFalse(TeamMembership.objects.filter(team=self.team, season=self.season, member=self.third_player).exists())
self.assertEqual(TeamMembership.objects.filter(team=self.team, season=self.season).count(), 1)
def test_captaincy_is_saved(self):
self.client.force_login(self.admin_user)
self.bulk_add(
self.row(self.player, self.player_position, 9, is_captain=True),
self.row(self.other_player, self.player_position, 10, is_alternate_captain=True),
)
self.assertTrue(TeamMembership.objects.get(team=self.team, member=self.player).is_captain)
self.assertTrue(TeamMembership.objects.get(team=self.team, member=self.other_player).is_alternate_captain)
def test_a_row_left_alone_is_neither_captain_nor_alternate(self):
self.client.force_login(self.admin_user)
self.bulk_add(self.row(self.player, self.player_position))
membership = TeamMembership.objects.get(team=self.team, member=self.player)
self.assertFalse(membership.is_captain)
self.assertFalse(membership.is_alternate_captain)
def test_captain_and_alternate_on_one_row_is_rejected(self):
# Contradictory rather than a club policy -- how many captains a team may
# have is deliberately left unconstrained, see TeamBulkAddRowForm.clean.
self.client.force_login(self.admin_user)
response = self.bulk_add(self.row(self.player, self.player_position, is_captain=True, is_alternate_captain=True))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "not both")
self.assertFalse(TeamMembership.objects.filter(team=self.team, season=self.season).exists())
def test_captaincy_on_a_staff_row_is_rejected(self):
# Captaincy lives on TeamMembership; a staff row becomes a StaffAssignment,
# which has no such field to put it in.
self.client.force_login(self.admin_user)
response = self.bulk_add(self.row(self.player, self.coach_position, is_captain=True))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "doesn&#x27;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&#x27;s roster")
self.assertEqual(TeamMembership.objects.filter(team=self.team, season=self.season, member=self.player).count(), 1)
def test_a_lapsed_member_cannot_be_added_via_a_crafted_post(self):
# Eligibility is recomputed from eligible_roster_members server-side, so a
# member id that was never offered fails the field's own queryset lookup.
lapsed = Member.objects.create(first_name="Lex", last_name="Lapsed")
ClubMembership.objects.create(club=self.club, member=lapsed, season=self.season, status=ClubMembership.StatusChoices.LAPSED)
self.client.force_login(self.admin_user)
response = self.bulk_add(self.row(lapsed, self.player_position))
self.assertEqual(response.status_code, 200)
self.assertFalse(TeamMembership.objects.filter(team=self.team, season=self.season, member=lapsed).exists())
def test_submitting_nothing_says_so_instead_of_adding(self):
self.client.force_login(self.admin_user)
response = self.club_post("team_bulk_add", self.bulk_data({}, {}), self.team.pk, self.season.pk)
self.assertRedirects(response, f"{reverse('management:team_detail', args=[self.team.pk])}?season={self.season.pk}")
self.assertEqual(TeamMembership.objects.filter(team=self.team, season=self.season).count(), 0)
def test_a_different_teams_coach_cannot_bulk_add(self):
self.client.force_login(self.other_team_coach)
response = self.bulk_add(self.row(self.player, self.player_position))
self.assertEqual(response.status_code, 403)
class GroupManagementTests(ManagementTestBase):
"""Generic named collections of members -- see management.views.Group* and
management.forms.GroupForm. Deliberately has no team/referee knowledge at
all; see MemberRefereeEligibilityTests for that (teams.RefereeProfile)."""
@classmethod
def setUpTestData(cls):
super().setUpTestData()
# One team only -- groups are deliberately team-agnostic; the team exists
# solely so make_non_admin_coach has somewhere to be staffed.
cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st")
cls.member = Member.objects.create(first_name="Peter", last_name="Player")
ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE)
def make_non_admin_coach(self, email="coach-groups@example.com"):
coach_user = User.objects.create_user(email=email, password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=self.team, member=coach_member, season=self.season, position=position)
return coach_user
def test_list_is_admin_only(self):
self.client.force_login(self.make_non_admin_coach())
self.assertEqual(self.club_get("group_list").status_code, 403)
def test_the_lists_edit_button_goes_to_the_group_page_not_straight_to_the_form(self):
# Same as Teams: Edit lands on the overview, which is where the members
# live, and offers its own Edit for the rename form. The list pages that
# do jump straight to a form (Locations, Opponents, Sponsors, Positions,
# Referee levels) have no detail page to land on at all.
group = Group.objects.create(club=self.club, name="Referees")
self.client.force_login(self.admin_user)
response = self.club_get("group_list")
self.assertContains(response, reverse("management:group_detail", args=[group.pk]))
self.assertNotContains(response, reverse("management:group_update", args=[group.pk]))
def test_admin_can_create_a_group(self):
self.client.force_login(self.admin_user)
response = self.club_post("group_create", {"name": "Referees"})
group = Group.objects.get(club=self.club, name="Referees")
self.assertRedirects(response, reverse("management:group_detail", args=[group.pk]))
def test_editing_a_group_renames_it(self):
self.client.force_login(self.admin_user)
group = Group.objects.create(club=self.club, name="Old name")
self.club_post("group_update", {"name": "New name"}, group.pk)
group.refresh_from_db()
self.assertEqual(group.name, "New name")
def test_deleting_a_group_removes_it(self):
self.client.force_login(self.admin_user)
group = Group.objects.create(club=self.club, name="Doomed")
self.club_post("group_delete", {}, group.pk)
self.assertFalse(Group.objects.filter(pk=group.pk).exists())
def bulk_data(self, *member_pks):
"""A formset POST body: one row per member, plus the management form."""
data = {"form-TOTAL_FORMS": str(len(member_pks)), "form-INITIAL_FORMS": "0", "form-MIN_NUM_FORMS": "0", "form-MAX_NUM_FORMS": "1000"}
for index, member_pk in enumerate(member_pks):
data[f"form-{index}-member"] = str(member_pk)
return data
def test_bulk_add_offers_members_and_marks_the_ones_already_in(self):
group = Group.objects.create(club=self.club, name="Referees")
GroupMembership.objects.create(group=group, member=self.member)
other_member = Member.objects.create(first_name="Olly", last_name="Other")
ClubMembership.objects.create(club=self.club, member=other_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
self.client.force_login(self.admin_user)
response = self.club_get("group_bulk_add", group.pk)
self.assertContains(response, "already in this group")
self.assertContains(response, "Olly Other")
def test_bulk_add_adds_the_members_in_the_rows(self):
group = Group.objects.create(club=self.club, name="Referees")
other_member = Member.objects.create(first_name="Olly", last_name="Other")
ClubMembership.objects.create(club=self.club, member=other_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
self.client.force_login(self.admin_user)
response = self.club_post("group_bulk_add", self.bulk_data(self.member.pk, other_member.pk), group.pk)
self.assertRedirects(response, reverse("management:group_detail", args=[group.pk]))
self.assertEqual(GroupMembership.objects.filter(group=group).count(), 2)
def test_bulk_add_cannot_re_add_an_existing_member_via_a_crafted_post(self):
group = Group.objects.create(club=self.club, name="Referees")
GroupMembership.objects.create(group=group, member=self.member)
self.client.force_login(self.admin_user)
response = self.club_post("group_bulk_add", self.bulk_data(self.member.pk), group.pk)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "already in this group")
self.assertEqual(GroupMembership.objects.filter(group=group, member=self.member).count(), 1)
def test_bulk_add_rejects_the_same_member_listed_twice(self):
group = Group.objects.create(club=self.club, name="Referees")
self.client.force_login(self.admin_user)
response = self.club_post("group_bulk_add", self.bulk_data(self.member.pk, self.member.pk), group.pk)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "listed twice")
self.assertEqual(GroupMembership.objects.filter(group=group).count(), 0)
def test_bulk_add_with_no_rows_filled_in_adds_nothing(self):
group = Group.objects.create(club=self.club, name="Referees")
self.client.force_login(self.admin_user)
response = self.club_post("group_bulk_add", self.bulk_data("", ""), group.pk)
self.assertRedirects(response, reverse("management:group_detail", args=[group.pk]))
self.assertEqual(GroupMembership.objects.filter(group=group).count(), 0)
def test_removing_a_member_deletes_the_membership(self):
group = Group.objects.create(club=self.club, name="Referees")
membership = GroupMembership.objects.create(group=group, member=self.member)
self.client.force_login(self.admin_user)
self.club_post("group_member_remove", {}, group.pk, membership.pk)
self.assertFalse(GroupMembership.objects.filter(pk=membership.pk).exists())
def test_groups_are_scoped_to_the_club(self):
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
other_group = Group.objects.create(club=other_club, name="Rival Referees")
self.client.force_login(self.admin_user)
response = self.club_get("group_list")
self.assertNotContains(response, "Rival Referees")
self.assertEqual(self.club_get("group_detail", other_group.pk).status_code, 404)
ONE_PIXEL_PNG = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
def make_image_file(name="photo.png"):
return SimpleUploadedFile(name, ONE_PIXEL_PNG, content_type="image/png")
class TeamPhotoTests(ManagementTestBase):
"""One photo per (team, season), uploaded from the team page -- see
management.views.TeamPhotoSetView/TeamPhotoDeleteView."""
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st")
cls.other_team = Team.objects.create(club=cls.club, name="Second Team", short_name="2nd")
cls.coach_position = Position.objects.create(club=cls.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True)
cls.team_coach = cls.make_team_coach(cls.team, "coach-photo@example.com")
cls.other_team_coach = cls.make_team_coach(cls.other_team, "coach-photo-other@example.com")
# A staff member on this team with a *non*-management position: allowed
# into the management app, but never offered the upload controls.
staff_user = User.objects.create_user(email="physio-photo@example.com", password="pw-secret-123")
staff_member = Member.objects.create(user=staff_user, first_name="Pat", last_name="Physio")
position = Position.objects.create(club=cls.club, name="Physio", short_name="PH", staff_position=True, management_position=False)
StaffAssignment.objects.create(team=cls.team, member=staff_member, season=cls.season, position=position)
cls.plain_staff = staff_user
@classmethod
def make_team_coach(cls, team, email):
coach_user = User.objects.create_user(email=email, password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
StaffAssignment.objects.create(team=team, member=coach_member, season=cls.season, position=cls.coach_position)
return coach_user
def test_uploading_creates_a_photo(self):
self.client.force_login(self.admin_user)
response = self.club_post("team_photo_set", {"image": make_image_file()}, self.team.pk, self.season.pk)
self.assertRedirects(response, f"{reverse('management:team_detail', args=[self.team.pk])}?season={self.season.pk}")
self.assertEqual(TeamPhoto.objects.filter(team=self.team, season=self.season).count(), 1)
def test_uploading_again_replaces_it_in_place(self):
self.client.force_login(self.admin_user)
self.club_post("team_photo_set", {"image": make_image_file("first.png")}, self.team.pk, self.season.pk)
first = TeamPhoto.objects.get(team=self.team, season=self.season)
self.club_post("team_photo_set", {"image": make_image_file("second.png")}, self.team.pk, self.season.pk)
self.assertEqual(TeamPhoto.objects.filter(team=self.team, season=self.season).count(), 1)
second = TeamPhoto.objects.get(team=self.team, season=self.season)
self.assertEqual(first.pk, second.pk)
self.assertIn("second", second.image.name)
def test_a_different_teams_coach_cannot_upload(self):
self.client.force_login(self.other_team_coach)
response = self.club_post("team_photo_set", {"image": make_image_file()}, self.team.pk, self.season.pk)
self.assertEqual(response.status_code, 403)
self.assertFalse(TeamPhoto.objects.filter(team=self.team).exists())
def test_this_teams_coach_can_upload(self):
self.client.force_login(self.team_coach)
response = self.club_post("team_photo_set", {"image": make_image_file()}, self.team.pk, self.season.pk)
self.assertRedirects(response, f"{reverse('management:team_detail', args=[self.team.pk])}?season={self.season.pk}")
self.assertTrue(TeamPhoto.objects.filter(team=self.team, season=self.season).exists())
def test_deleting_removes_the_photo(self):
TeamPhoto.objects.create(team=self.team, season=self.season, image=make_image_file())
self.client.force_login(self.admin_user)
response = self.club_post("team_photo_delete", {}, self.team.pk, self.season.pk)
self.assertRedirects(response, f"{reverse('management:team_detail', args=[self.team.pk])}?season={self.season.pk}")
self.assertFalse(TeamPhoto.objects.filter(team=self.team, season=self.season).exists())
def test_the_team_page_shows_the_photo_when_set(self):
TeamPhoto.objects.create(team=self.team, season=self.season, image=make_image_file())
self.client.force_login(self.admin_user)
response = self.club_get("team_detail", self.team.pk)
self.assertContains(response, "Replace photo")
self.assertNotContains(response, "No photo uploaded")
def test_the_team_page_shows_a_placeholder_when_not_set(self):
self.client.force_login(self.admin_user)
response = self.club_get("team_detail", self.team.pk)
self.assertContains(response, "No photo uploaded")
self.assertContains(response, "Upload photo")
def test_upload_ui_is_hidden_from_a_plain_staff_member(self):
TeamPhoto.objects.create(team=self.team, season=self.season, image=make_image_file())
self.client.force_login(self.plain_staff)
response = self.club_get("team_detail", self.team.pk)
self.assertNotContains(response, "Replace photo")
self.assertNotContains(response, "Upload photo")
class PositionManagementTests(ManagementTestBase):
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def test_position_list_is_scoped_to_the_club(self):
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
Position.objects.create(club=other_club, name="Rival Coach", short_name="RC", staff_position=True)
response = self.club_get("position_list")
self.assertNotContains(response, "Rival Coach")
def test_creating_a_position(self):
response = self.club_post("position_create", {"name": "Physio", "short_name": "PH", "ordering": 0, "staff_position": "on", "management_position": ""})
position = Position.objects.get(club=self.club, name="Physio")
self.assertRedirects(response, reverse("management:position_list"))
self.assertTrue(position.staff_position)
self.assertFalse(position.management_position)
def test_updating_a_position(self):
position = Position.objects.create(club=self.club, name="Old name", short_name="ON")
self.club_post("position_update", {"name": "New name", "short_name": "NN", "ordering": 0}, position.pk)
position.refresh_from_db()
self.assertEqual(position.name, "New name")
def test_a_management_position_must_also_be_a_staff_position(self):
response = self.club_post("position_create", {"name": "Bad", "short_name": "B", "ordering": 0, "management_position": "on"})
self.assertEqual(response.status_code, 200)
self.assertFalse(Position.objects.filter(club=self.club, name="Bad").exists())
self.assertFormError(response.context["form"], "management_position", "A management position must also be a staff position.")
class RefereeLevelManagementTests(ManagementTestBase):
"""Admin-managed referee qualification tiers -- see
management.views.RefereeLevel* and teams.RefereeLevel."""
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st")
cls.other_team = Team.objects.create(club=cls.club, name="Second Team", short_name="2nd")
def make_non_admin_coach(self, email="coach-levels@example.com"):
coach_user = User.objects.create_user(email=email, password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=self.team, member=coach_member, season=self.season, position=position)
return coach_user
def test_list_is_visible_to_any_staff(self):
RefereeLevel.objects.create(club=self.club, name="Regional")
self.client.force_login(self.make_non_admin_coach())
response = self.club_get("referee_level_list")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Regional")
def test_create_is_admin_only(self):
self.client.force_login(self.make_non_admin_coach())
response = self.club_post("referee_level_create", {"name": "Regional", "teams": []})
self.assertEqual(response.status_code, 403)
def test_admin_can_create_a_level_with_teams(self):
self.client.force_login(self.admin_user)
response = self.club_post("referee_level_create", {"name": "Regional", "teams": [str(self.team.pk), str(self.other_team.pk)]})
level = RefereeLevel.objects.get(club=self.club, name="Regional")
self.assertRedirects(response, reverse("management:referee_level_list"))
self.assertEqual(set(level.teams.all()), {self.team, self.other_team})
def test_admin_can_update_a_levels_teams(self):
level = RefereeLevel.objects.create(club=self.club, name="Regional")
level.teams.add(self.team)
self.client.force_login(self.admin_user)
self.club_post("referee_level_update", {"name": "Regional", "teams": [str(self.other_team.pk)]}, level.pk)
self.assertEqual(set(level.teams.all()), {self.other_team})
def test_list_scoped_to_the_club(self):
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
RefereeLevel.objects.create(club=other_club, name="Rival Level")
self.client.force_login(self.admin_user)
response = self.club_get("referee_level_list")
self.assertNotContains(response, "Rival Level")
def test_admin_can_link_a_level_to_inherit_from_another(self):
regional = RefereeLevel.objects.create(club=self.club, name="Regional")
regional.teams.add(self.team)
self.client.force_login(self.admin_user)
response = self.club_post("referee_level_create", {"name": "National", "teams": [str(self.other_team.pk)], "inherits_from": str(regional.pk)})
national = RefereeLevel.objects.get(club=self.club, name="National")
self.assertRedirects(response, reverse("management:referee_level_list"))
self.assertEqual(national.inherits_from, regional)
self.assertEqual(national.eligible_team_ids(), {self.team.pk, self.other_team.pk})
def test_a_level_cannot_be_set_to_inherit_from_itself(self):
level = RefereeLevel.objects.create(club=self.club, name="Regional")
self.client.force_login(self.admin_user)
self.club_post("referee_level_update", {"name": "Regional", "teams": [], "inherits_from": str(level.pk)}, level.pk)
level.refresh_from_db()
self.assertIsNone(level.inherits_from)
def test_the_inherits_from_dropdown_only_offers_this_clubs_levels(self):
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
RefereeLevel.objects.create(club=other_club, name="Rival Level")
self.client.force_login(self.admin_user)
response = self.club_get("referee_level_create")
self.assertNotContains(response, "Rival Level")
def test_the_list_shows_what_a_level_inherits(self):
regional = RefereeLevel.objects.create(club=self.club, name="Regional")
RefereeLevel.objects.create(club=self.club, name="National", inherits_from=regional)
self.client.force_login(self.admin_user)
response = self.club_get("referee_level_list")
self.assertContains(response, "Regional")
self.assertContains(response, "everything")
class RefereeListViewTests(ManagementTestBase):
"""The club-wide referee overview -- see management.views.RefereeListView."""
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st")
cls.level = RefereeLevel.objects.create(club=cls.club, name="Regional")
cls.level.teams.add(cls.team)
cls.member = Member.objects.create(first_name="Ref", last_name="Eree")
ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE)
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def test_lists_a_valid_referee_with_level_and_teams(self):
RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=timezone.localdate() + datetime.timedelta(days=30))
response = self.club_get("referee_list")
self.assertContains(response, "Ref Eree")
self.assertContains(response, "Regional")
self.assertContains(response, "First Team")
self.assertContains(response, "Valid")
def test_shows_expired_status(self):
RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=timezone.localdate() - datetime.timedelta(days=1))
response = self.club_get("referee_list")
self.assertContains(response, "Expired")
def test_shows_no_level_status(self):
RefereeProfile.objects.create(member=self.member, valid_until=timezone.localdate() + datetime.timedelta(days=30))
response = self.club_get("referee_list")
self.assertContains(response, "No level")
def test_shows_no_validity_set_status(self):
RefereeProfile.objects.create(member=self.member, level=self.level)
response = self.club_get("referee_list")
self.assertContains(response, "No validity set")
def test_a_member_with_no_referee_profile_is_not_listed(self):
other_member = Member.objects.create(first_name="Not", last_name="Referee")
ClubMembership.objects.create(club=self.club, member=other_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
response = self.club_get("referee_list")
self.assertNotContains(response, "Not Referee")
def test_visible_to_any_staff(self):
coach_user = User.objects.create_user(email="coach-reflist@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
coach_position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=self.team, member=coach_member, season=self.season, position=coach_position)
RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=timezone.localdate() + datetime.timedelta(days=30))
# Give the coach visibility into self.member too (members_visible_to
# scopes a non-admin to teams they're staffed on).
player_position = Position.objects.create(club=self.club, name="Forward", short_name="FW")
TeamMembership.objects.create(team=self.team, member=self.member, season=self.season, position=player_position)
self.client.force_login(coach_user)
response = self.club_get("referee_list")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Ref Eree")
class ClubRoleManagementTests(ManagementTestBase):
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.member = Member.objects.create(first_name="Future", last_name="Editor")
# An active membership already grants an implicit MEMBER role (club/signals.py) --
# granting EDITOR must promote that row, not insert a second one.
ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE)
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def test_granting_a_role_promotes_the_existing_membership_role(self):
self.club_post("role_create", {"member": str(self.member.pk), "role": ClubRole.Roles.EDITOR})
role = ClubRole.objects.get(club=self.club, member=self.member)
self.assertEqual(role.role, ClubRole.Roles.EDITOR)
self.assertEqual(ClubRole.objects.filter(club=self.club, member=self.member).count(), 1)
def test_revoking_a_role(self):
role = ClubRole.objects.get(club=self.club, member=self.member)
self.club_post("role_revoke", {}, role.pk)
self.assertFalse(ClubRole.objects.filter(pk=role.pk).exists())
def test_the_plain_member_role_never_appears_on_the_list(self):
# self.admin_member and self.member both hold an implicit MEMBER role --
# noise this page must never show. (self.member's name still legitimately
# appears once, in the "Grant role" modal's member picker.)
response = self.club_get("role_list")
self.assertContains(response, "No one has the Editor role yet.")
def test_a_granted_role_appears_under_its_own_section(self):
self.club_post("role_create", {"member": str(self.member.pk), "role": ClubRole.Roles.EDITOR})
response = self.club_get("role_list")
self.assertContains(response, "Future Editor")
def test_grant_role_is_a_modal_on_the_list_page(self):
response = self.club_get("role_list")
self.assertContains(response, 'id="grant_role_modal"')
def test_each_section_explains_what_the_role_grants(self):
response = self.club_get("role_list")
self.assertContains(response, "Full control over the club")
self.assertContains(response, "Can create and edit events")
def test_an_invalid_submission_redirects_back_to_the_list_instead_of_a_page(self):
response = self.club_post("role_create", {"member": "", "role": ClubRole.Roles.EDITOR})
self.assertRedirects(response, reverse("management:role_list"))
class FamilyManagementTests(ManagementTestBase):
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def test_registering_a_family_creates_a_login_parent_and_a_login_less_child(self):
response = self.club_post(
"family_create",
{
"parent_first_name": "Pat",
"parent_last_name": "Parent",
"parent_email": "pat.parent@example.com",
"child_first_name": "Cody",
"child_last_name": "Child",
"child_date_of_birth": "2015-04-01",
},
)
family = Family.objects.get(memberships__member__first_name="Cody")
self.assertRedirects(response, reverse("management:family_detail", args=[family.pk]))
parent = Member.objects.get(first_name="Pat", last_name="Parent")
child = Member.objects.get(first_name="Cody", last_name="Child")
self.assertEqual(parent.user.email, "pat.parent@example.com")
self.assertFalse(parent.user.has_usable_password(), "should set a password via the reset link, not be given one")
self.assertIsNone(child.user)
self.assertEqual(FamilyMembership.objects.get(family=family, member=parent).role, FamilyMembership.FamilyRole.PARENT)
self.assertEqual(FamilyMembership.objects.get(family=family, member=child).role, FamilyMembership.FamilyRole.CHILD)
# Both get signed up for the current season, same as a plain MemberCreateView.
self.assertTrue(ClubMembership.objects.filter(club=self.club, member=parent, season=self.season).exists())
self.assertTrue(ClubMembership.objects.filter(club=self.club, member=child, season=self.season).exists())
def test_reusing_an_existing_login_by_email(self):
# A parent who's already a Member elsewhere (an existing login) must be
# reused, not duplicated, when registered onto a second family.
existing_user = User.objects.create_user(email="existing@example.com", password="pw-secret-123")
existing_member = Member.objects.create(user=existing_user, first_name="Existing", last_name="Parent")
self.club_post(
"family_create",
{
"parent_first_name": "Ignored",
"parent_last_name": "Ignored",
"parent_email": "existing@example.com",
"child_first_name": "New",
"child_last_name": "Kid",
},
)
self.assertEqual(Member.objects.filter(user=existing_user).count(), 1)
family = Family.objects.get(memberships__member__first_name="New")
self.assertIn(existing_member, family.guardians)
def make_existing_family(self):
# A family only counts as "of this club" once at least one of its members
# has actually signed up (families_of_club, management/views.py) -- exactly
# what registering the first child through this app already does.
family = Family.objects.create()
first_kid = Member.objects.create(first_name="First", last_name="Kid")
FamilyMembership.objects.create(family=family, member=first_kid, role=FamilyMembership.FamilyRole.CHILD)
ClubMembership.objects.create(club=self.club, member=first_kid, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
return family
def test_adding_a_child_to_an_existing_family(self):
family = self.make_existing_family()
response = self.club_post("family_add_child", {"first_name": "Second", "last_name": "Kid", "date_of_birth": "2018-01-01"}, family.pk)
self.assertRedirects(response, reverse("management:family_detail", args=[family.pk]))
self.assertEqual(family.children.count(), 2)
self.assertTrue(ClubMembership.objects.filter(club=self.club, member__first_name="Second", season=self.season).exists())
def test_adding_a_parent_to_an_existing_family_is_idempotent(self):
family = self.make_existing_family()
self.club_post("family_add_parent", {"email": "new.parent@example.com", "first_name": "New", "last_name": "Parent"}, family.pk)
self.club_post("family_add_parent", {"email": "new.parent@example.com", "first_name": "", "last_name": ""}, family.pk)
self.assertEqual(family.guardians.count(), 1)
class ParentClaimViewTests(ManagementTestBase):
"""The public claim form and the admin review queue -- see
members.services.claims; the service-level guarantees live in
members.tests.ParentClaimTests."""
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.child = Member.objects.create(first_name="Jamie", last_name="Doe", date_of_birth=datetime.date(2014, 3, 2))
ClubMembership.objects.create(club=cls.club, member=cls.child, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE)
family = Family.objects.create()
FamilyMembership.objects.create(family=family, member=cls.child, role=FamilyMembership.FamilyRole.CHILD)
def claim_payload(self, **overrides):
payload = {
"parent_first_name": "Taylor",
"parent_last_name": "Doe",
"parent_email": "taylor.doe@example.com",
"child_first_name": "Jamie",
"child_last_name": "Doe",
"child_date_of_birth": "2014-03-02",
}
payload.update(overrides)
return payload
def submit(self, *, follow=False, **overrides):
return self.client.post(reverse("members:parent_claim"), self.claim_payload(**overrides), HTTP_HOST="ajax-united.rosterchief.app", follow=follow)
def test_the_claim_form_is_reachable_without_signing_in(self):
response = self.client.get(reverse("members:parent_claim"), HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.status_code, 200)
def test_submitting_records_a_pending_claim_and_redirects_back_with_a_flash(self):
response = self.submit()
# fetch_redirect_response=False: assertRedirects' own probe GET would
# otherwise consume the one-shot flash before the assertion below gets to see it.
self.assertRedirects(response, reverse("members:parent_claim"), fetch_redirect_response=False)
self.assertEqual(ParentClaim.objects.filter(club=self.club, status=ParentClaim.Status.PENDING).count(), 1)
page = self.client.get(reverse("members:parent_claim"), HTTP_HOST="ajax-united.rosterchief.app")
self.assertContains(page, "Request received")
def test_an_unmatched_claim_flashes_the_same_message_as_a_matched_one(self):
# The page must not tell an anonymous submitter which children exist.
matched = self.submit(follow=True)
unmatched = self.submit(child_first_name="Nobody", child_last_name="Here", parent_email="other@example.com", follow=True)
self.assertEqual([str(m) for m in matched.context["messages"]], [str(m) for m in unmatched.context["messages"]])
def test_the_flash_mentions_the_clubs_contact_email_when_set(self):
self.club.contact_email = "info@ajax-united.example.com"
self.club.save(update_fields=["contact_email"])
response = self.submit(follow=True)
self.assertContains(response, "info@ajax-united.example.com")
def test_submitting_creates_no_account(self):
# A public form that made a User per submission would be a spam magnet;
# the account is created on approval, once a human has vouched for it.
self.submit()
self.assertFalse(User.objects.filter(email="taylor.doe@example.com").exists())
def test_open_signup_is_closed(self):
response = self.client.get(reverse("account_signup"), HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.status_code, 403)
def test_the_queue_is_admin_only(self):
coach_user = User.objects.create_user(email="coach-claims@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
team = Team.objects.create(club=self.club, name="First Team", short_name="1st")
position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
self.client.force_login(coach_user)
self.assertEqual(self.club_get("parent_claim_list").status_code, 403)
def test_the_queue_lists_a_pending_claim_and_the_unclaimed_child(self):
self.submit()
self.client.force_login(self.admin_user)
response = self.club_get("parent_claim_list")
self.assertContains(response, "taylor.doe@example.com")
self.assertContains(response, "Jamie Doe")
def test_the_child_dropdown_matches_the_height_of_the_buttons_beside_it(self):
# Regression: ClaimReviewForm.child used to hardcode its own class=
# attrs, which rendered a second class="..." on the <select> alongside
# the one templatetags/field.html builds (including the size modifier)
# -- the two never merge, so select-sm silently never reached the page
# and the dropdown stood taller than the btn-sm/input-sm around it.
self.submit()
self.client.force_login(self.admin_user)
response = self.club_get("parent_claim_list")
self.assertContains(response, "select-sm")
html = response.content.decode()
select_tag = html[html.index("<select") : html.index(">", html.index("<select"))]
self.assertEqual(select_tag.count('class="'), 1)
def test_approving_links_the_parent_as_a_guardian(self):
self.submit()
claim = ParentClaim.objects.get(club=self.club)
self.client.force_login(self.admin_user)
self.club_post("parent_claim_approve", {"child": str(self.child.pk)}, claim.pk)
parent = Member.objects.get(user__email="taylor.doe@example.com")
self.assertEqual(ClubMembership.objects.get(club=self.club, member=parent).kind, ClubMembership.Kind.GUARDIAN)
claim.refresh_from_db()
self.assertEqual(claim.status, ParentClaim.Status.APPROVED)
def test_approving_emails_the_parent_a_working_set_password_link(self):
self.submit()
claim = ParentClaim.objects.get(club=self.club)
self.client.force_login(self.admin_user)
self.club_post("parent_claim_approve", {"child": str(self.child.pk)}, claim.pk)
self.assertEqual(len(mail.outbox), 1)
sent = mail.outbox[0]
self.assertEqual(sent.to, ["taylor.doe@example.com"])
self.assertIn("Jamie", sent.body)
[reset_path] = [line for line in sent.body.splitlines() if "/accounts/password/reset/key/" in line]
parent = Member.objects.get(user__email="taylor.doe@example.com")
self.assertFalse(parent.user.has_usable_password())
response = self.client.get(reset_path.strip(), HTTP_HOST="ajax-united.rosterchief.app", follow=True)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "password1")
def test_the_email_carries_an_html_alternative_alongside_the_plain_text(self):
self.submit()
claim = ParentClaim.objects.get(club=self.club)
self.client.force_login(self.admin_user)
self.club_post("parent_claim_approve", {"child": str(self.child.pk)}, claim.pk)
sent = mail.outbox[0]
[(html_body, mimetype)] = sent.alternatives
self.assertEqual(mimetype, "text/html")
self.assertIn("Jamie", html_body)
self.assertIn(self.club.name, html_body)
[reset_path] = [line for line in sent.body.splitlines() if "/accounts/password/reset/key/" in line]
self.assertIn(reset_path.strip(), html_body)
def test_the_initials_badge_text_contrasts_against_the_fallback_colour(self):
# self.club has no secondary_color set, so the badge falls back to
# #ec4899 -- white text on that (the old hardcoded default) reads
# far worse than black (contrast_color("#ec4899") == "#000000"), see
# club/templatetags/club_email.py::contrast_color.
self.submit()
claim = ParentClaim.objects.get(club=self.club)
self.client.force_login(self.admin_user)
self.club_post("parent_claim_approve", {"child": str(self.child.pk)}, claim.pk)
[(html_body, _mimetype)] = mail.outbox[0].alternatives
self.assertIn("background-color:#ec4899", html_body)
self.assertIn("color:#000000", html_body)
def test_the_email_mentions_the_clubs_contact_email_when_set(self):
self.club.contact_email = "info@ajax-united.example.com"
self.club.save(update_fields=["contact_email"])
self.submit()
claim = ParentClaim.objects.get(club=self.club)
self.client.force_login(self.admin_user)
self.club_post("parent_claim_approve", {"child": str(self.child.pk)}, claim.pk)
self.assertIn("info@ajax-united.example.com", mail.outbox[0].body)
def test_a_send_failure_still_leaves_the_claim_linked(self):
self.submit()
claim = ParentClaim.objects.get(club=self.club)
self.client.force_login(self.admin_user)
with mock.patch("members.services.claims.EmailMultiAlternatives.send", side_effect=OSError("smtp down")):
response = self.club_post("parent_claim_approve", {"child": str(self.child.pk)}, claim.pk)
self.assertRedirects(response, reverse("management:parent_claim_list"))
claim.refresh_from_db()
self.assertEqual(claim.status, ParentClaim.Status.APPROVED)
self.assertTrue(Member.objects.filter(user__email="taylor.doe@example.com").exists())
def test_approving_without_choosing_a_child_changes_nothing(self):
self.submit()
claim = ParentClaim.objects.get(club=self.club)
self.client.force_login(self.admin_user)
self.club_post("parent_claim_approve", {}, claim.pk)
claim.refresh_from_db()
self.assertTrue(claim.is_pending)
self.assertFalse(User.objects.filter(email="taylor.doe@example.com").exists())
def test_a_child_who_already_has_a_parent_cannot_be_chosen(self):
# The shortlist is only ever children with nobody on file, so approving
# can never quietly re-parent a child who already has one.
other_child = Member.objects.create(first_name="Sam", last_name="Roe", date_of_birth=datetime.date(2013, 1, 1))
ClubMembership.objects.create(club=self.club, member=other_child, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
family = Family.objects.create()
FamilyMembership.objects.create(family=family, member=other_child, role=FamilyMembership.FamilyRole.CHILD)
existing_parent = Member.objects.create(first_name="Existing", last_name="Roe")
FamilyMembership.objects.create(family=family, member=existing_parent, role=FamilyMembership.FamilyRole.PARENT)
self.submit()
claim = ParentClaim.objects.get(club=self.club)
self.client.force_login(self.admin_user)
self.club_post("parent_claim_approve", {"child": str(other_child.pk)}, claim.pk)
claim.refresh_from_db()
self.assertTrue(claim.is_pending)
def test_rejecting_records_the_reason_and_links_nobody(self):
self.submit()
claim = ParentClaim.objects.get(club=self.club)
self.client.force_login(self.admin_user)
self.club_post("parent_claim_reject", {"note": "Not on our records."}, claim.pk)
claim.refresh_from_db()
self.assertEqual(claim.status, ParentClaim.Status.REJECTED)
self.assertFalse(User.objects.filter(email="taylor.doe@example.com").exists())
def test_the_parent_sees_their_child_after_approval(self):
self.submit()
claim = ParentClaim.objects.get(club=self.club)
self.client.force_login(self.admin_user)
self.club_post("parent_claim_approve", {"child": str(self.child.pk)}, claim.pk)
self.client.force_login(User.objects.get(email="taylor.doe@example.com"))
response = self.client.get(reverse("members:my_family"), HTTP_HOST="ajax-united.rosterchief.app")
self.assertContains(response, "Jamie Doe")
def test_the_child_dropdown_is_searchable_and_the_top_match_is_preselected(self):
self.submit()
self.client.force_login(self.admin_user)
response = self.club_get("parent_claim_list")
self.assertContains(response, 'data-searchable="true"')
html = response.content.decode()
select_tag = html[html.index("<select") : html.index("</select>", html.index("<select"))]
self.assertIn(f'value="{self.child.pk}" selected', select_tag)
def test_a_reject_modal_exists_for_each_pending_claim(self):
self.submit()
claim = ParentClaim.objects.get(club=self.club)
self.client.force_login(self.admin_user)
response = self.club_get("parent_claim_list")
self.assertContains(response, f'id="claim_reject_modal_{claim.pk}"')
self.assertContains(response, f"document.getElementById('claim_reject_modal_{claim.pk}').showModal()")
def test_the_history_section_shows_a_claim_reviewed_this_season(self):
self.submit()
claim = ParentClaim.objects.get(club=self.club)
self.client.force_login(self.admin_user)
self.club_post("parent_claim_approve", {"child": str(self.child.pk)}, claim.pk)
response = self.club_get("parent_claim_list")
self.assertContains(response, "Already dealt with")
self.assertContains(response, "taylor.doe@example.com")
def test_the_history_section_hides_a_claim_reviewed_last_season(self):
self.submit()
claim = ParentClaim.objects.get(club=self.club)
self.client.force_login(self.admin_user)
self.club_post("parent_claim_approve", {"child": str(self.child.pk)}, claim.pk)
claim.refresh_from_db()
claim.reviewed_at = timezone.make_aware(datetime.datetime.combine(self.season.start_date - datetime.timedelta(days=1), datetime.time()))
claim.save(update_fields=["reviewed_at"])
response = self.club_get("parent_claim_list")
self.assertNotContains(response, "Already dealt with")
def test_the_history_section_is_empty_without_a_current_season(self):
self.submit()
claim = ParentClaim.objects.get(club=self.club)
self.client.force_login(self.admin_user)
self.club_post("parent_claim_approve", {"child": str(self.child.pk)}, claim.pk)
# Push the season into the future rather than deleting it -- ClubMembership.season
# is PROTECT, and the club being between seasons is the scenario under test,
# not the absence of any Season row at all.
future_start = timezone.localdate() + datetime.timedelta(days=100)
self.season.start_date = future_start
self.season.end_date = future_start + datetime.timedelta(days=300)
self.season.save(update_fields=["start_date", "end_date"])
response = self.club_get("parent_claim_list")
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, "Already dealt with")
def test_a_signed_in_parent_sees_locked_read_only_fields_instead_of_blank_inputs(self):
# Re-typing name/email risks a typo forking off a second account -- the
# fields are shown read-only rather than editable-but-pre-filled.
user = User.objects.create_user(email="already.parent@example.com", password="pw-secret-123")
Member.objects.create(user=user, first_name="Already", last_name="Parent")
self.client.force_login(user)
response = self.client.get(reverse("members:parent_claim"), HTTP_HOST="ajax-united.rosterchief.app")
self.assertContains(response, "Submitting as")
self.assertContains(response, "Already Parent")
self.assertContains(response, "already.parent@example.com")
self.assertNotContains(response, 'name="parent_first_name"')
self.assertNotContains(response, 'name="parent_last_name"')
self.assertNotContains(response, 'name="parent_email"')
def test_a_signed_in_parents_claim_reuses_their_account_and_ignores_posted_parent_fields(self):
user = User.objects.create_user(email="already.parent@example.com", password="pw-secret-123")
Member.objects.create(user=user, first_name="Already", last_name="Parent")
self.client.force_login(user)
response = self.client.post(
reverse("members:parent_claim"),
{
# Even if a tampered request smuggled these in, the fields don't
# exist on the locked form and the view never reads POST for them.
"parent_first_name": "Someone",
"parent_last_name": "Else",
"parent_email": "not-me@example.com",
"child_first_name": "Jamie",
"child_last_name": "Doe",
"child_date_of_birth": "2014-03-02",
},
HTTP_HOST="ajax-united.rosterchief.app",
)
self.assertEqual(response.status_code, 302)
claim = ParentClaim.objects.get(club=self.club, child_first_name="Jamie")
self.assertEqual(claim.submitted_by_user, user)
self.assertEqual(claim.parent_first_name, "Already")
self.assertEqual(claim.parent_last_name, "Parent")
self.assertEqual(claim.parent_email, "already.parent@example.com")
self.assertFalse(User.objects.filter(email="not-me@example.com").exists())
def test_an_anonymous_submission_has_no_linked_user(self):
self.submit()
claim = ParentClaim.objects.get(club=self.club)
self.assertIsNone(claim.submitted_by_user)
def test_a_second_claim_from_a_signed_in_parent_merges_the_new_child_into_their_existing_family(self):
self.submit()
first_claim = ParentClaim.objects.get(club=self.club)
self.client.force_login(self.admin_user)
self.club_post("parent_claim_approve", {"child": str(self.child.pk)}, first_claim.pk)
parent_user = User.objects.get(email="taylor.doe@example.com")
second_child = Member.objects.create(first_name="Robin", last_name="Doe", date_of_birth=datetime.date(2016, 5, 1))
ClubMembership.objects.create(club=self.club, member=second_child, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
second_family = Family.objects.create()
FamilyMembership.objects.create(family=second_family, member=second_child, role=FamilyMembership.FamilyRole.CHILD)
self.client.force_login(parent_user)
self.client.post(
reverse("members:parent_claim"),
{"child_first_name": "Robin", "child_last_name": "Doe", "child_date_of_birth": "2016-05-01"},
HTTP_HOST="ajax-united.rosterchief.app",
)
second_claim = ParentClaim.objects.get(club=self.club, child_first_name="Robin")
self.client.force_login(self.admin_user)
self.club_post("parent_claim_approve", {"child": str(second_child.pk)}, second_claim.pk)
# One account, one household with both children -- not a parent split
# across two Family rows, and not a duplicate User/Member.
self.assertEqual(User.objects.filter(email="taylor.doe@example.com").count(), 1)
parent = Member.objects.get(user=parent_user)
family = FamilyMembership.objects.get(member=parent).family
self.assertCountEqual(family.children, [self.child, second_child])
self.assertFalse(Family.objects.filter(pk=second_family.pk).exists())
class GuardianViewTests(ManagementTestBase):
"""How a guardian -- a parent attached to the club only through their child --
behaves across the management UI. See club.models.ClubMembership.Kind; the
model-level guarantees live in club.tests.GuardianMembershipTests."""
def family_payload(self, **overrides):
payload = {
"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",
}
payload.update(overrides)
return payload
def test_registering_a_family_makes_the_parent_a_guardian_and_the_child_a_member(self):
self.client.force_login(self.admin_user)
self.club_post("family_create", self.family_payload())
parent = Member.objects.get(first_name="Pat", last_name="Parent")
child = Member.objects.get(first_name="Cody", last_name="Child")
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_ticking_also_a_member_enrols_the_parent_as_one(self):
self.client.force_login(self.admin_user)
self.club_post("family_create", self.family_payload(parent_is_member="on"))
parent = Member.objects.get(first_name="Pat", last_name="Parent")
self.assertEqual(ClubMembership.objects.get(club=self.club, member=parent).kind, ClubMembership.Kind.MEMBER)
def test_a_guardian_is_absent_from_the_member_list(self):
self.client.force_login(self.admin_user)
self.club_post("family_create", self.family_payload())
response = self.club_get("member_list")
self.assertContains(response, "Cody Child")
self.assertNotContains(response, "Pat Parent")
def test_a_guardian_is_not_counted_in_the_membership_kpis(self):
# Measured as a delta, not an absolute: the base fixture's admin is a
# member too, and this is about what registering a family *adds*.
self.client.force_login(self.admin_user)
before = self.club_get("membership_list").context["kpi_total"]
self.club_post("family_create", self.family_payload())
after = self.club_get("membership_list").context["kpi_total"]
# The child only. A guardian owes nothing, so counting them would
# overstate the roll and everything derived from it.
self.assertEqual(after - before, 1)
def test_a_guardians_own_page_is_still_reachable(self):
# Excluded from the member *list*, not from the club: an admin has to be
# able to open them, edit them, and switch them to a member.
self.client.force_login(self.admin_user)
self.club_post("family_create", self.family_payload())
parent = Member.objects.get(first_name="Pat", last_name="Parent")
self.assertEqual(self.club_get("member_detail", parent.pk).status_code, 200)
self.assertEqual(self.club_get("member_update", parent.pk).status_code, 200)
def test_a_guardian_can_still_be_put_in_a_group(self):
# The stated exception: a parent may well sit on a committee.
self.client.force_login(self.admin_user)
self.club_post("family_create", self.family_payload())
parent = Member.objects.get(first_name="Pat", last_name="Parent")
group = Group.objects.create(club=self.club, name="Committee")
response = self.club_get("group_bulk_add", group.pk)
self.assertContains(response, str(parent.pk))
def test_a_guardian_is_not_offered_for_a_team_roster(self):
self.client.force_login(self.admin_user)
self.club_post("family_create", self.family_payload())
parent = Member.objects.get(first_name="Pat", last_name="Parent")
team = Team.objects.create(club=self.club, name="First Team", short_name="1st")
response = self.club_get("team_bulk_add", team.pk, self.season.pk)
self.assertNotContains(response, str(parent.pk))
class MemberListFamilyColumnTests(ManagementTestBase):
"""The member list is one flat table -- family is a column (each member's family/
role attached in Python, management.views.MemberListView), not a grouping."""
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def make_family(self, parent_name, child_name):
family = Family.objects.create()
parent = Member.objects.create(first_name=parent_name, last_name="Guardian")
child = Member.objects.create(first_name=child_name, last_name="Kid")
FamilyMembership.objects.create(family=family, member=parent, role=FamilyMembership.FamilyRole.PARENT)
FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD)
ClubMembership.objects.create(club=self.club, member=parent, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
ClubMembership.objects.create(club=self.club, member=child, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
return family, parent, child
def test_a_family_renders_as_one_group_on_the_member_list(self):
family, parent, child = self.make_family("Pat", "Cody")
response = self.club_get("member_list")
self.assertContains(response, str(family))
self.assertContains(response, str(parent))
self.assertContains(response, str(child))
def test_a_member_with_no_family_has_an_empty_family_column(self):
loner = Member.objects.create(first_name="Lone", last_name="Member")
ClubMembership.objects.create(club=self.club, member=loner, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
response = self.club_get("member_list")
members_by_pk = {member.pk: member for member in response.context["members"]}
self.assertEqual(members_by_pk[loner.pk].family_memberships_display, [])
def test_a_family_members_column_shows_the_family_and_role(self):
family, parent, child = self.make_family("Pat", "Cody")
response = self.club_get("member_list")
members_by_pk = {member.pk: member for member in response.context["members"]}
parent_fms = members_by_pk[parent.pk].family_memberships_display
child_fms = members_by_pk[child.pk].family_memberships_display
self.assertEqual([fm.family for fm in parent_fms], [family])
self.assertEqual(parent_fms[0].role, "parent")
self.assertEqual(child_fms[0].role, "child")
def test_a_member_in_two_families_shows_both_on_the_member_list(self):
family, parent, _child = self.make_family("Pat", "Cody")
other_family = Family.objects.create()
FamilyMembership.objects.create(family=other_family, member=parent, role=FamilyMembership.FamilyRole.OTHER)
response = self.club_get("member_list")
members_by_pk = {member.pk: member for member in response.context["members"]}
parent_fms = members_by_pk[parent.pk].family_memberships_display
self.assertEqual({fm.family for fm in parent_fms}, {family, other_family})
def test_families_of_other_clubs_do_not_appear(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="Kid")
ClubMembership.objects.create(club=other_club, member=other_member, season=other_season, status=ClubMembership.StatusChoices.ACTIVE)
other_family = Family.objects.create()
FamilyMembership.objects.create(family=other_family, member=other_member, role=FamilyMembership.FamilyRole.CHILD)
response = self.club_get("member_list")
self.assertNotContains(response, str(other_family))
self.assertNotContains(response, "Other Kid")
def test_a_non_admin_never_sees_a_family_mate_outside_their_own_visibility(self):
# group_by_family must bucket the already-scoped members_visible_to() result,
# never Family.guardians/.children directly -- those ignore scoping entirely
# and would leak a family-mate a non-admin has no other reason to see.
_family, parent, child = self.make_family("Pat", "Cody")
coach_user = User.objects.create_user(email="coach3@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="Coach3", short_name="C3", 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("member_list")
self.assertNotContains(response, str(parent))
self.assertNotContains(response, str(child))
class MemberListStatusColumnTests(ManagementTestBase):
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def test_current_season_status_is_shown(self):
member = Member.objects.create(first_name="Sam", last_name="Pending")
ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.PENDING)
response = self.club_get("member_list")
members_by_pk = {m.pk: m for m in response.context["members"]}
self.assertEqual(members_by_pk[member.pk].current_membership.status, ClubMembership.StatusChoices.PENDING)
self.assertContains(response, "Pending")
def test_a_member_with_no_current_season_membership_shows_a_dash(self):
ClubMembership.objects.filter(club=self.club, member=self.admin_member).delete()
response = self.club_get("member_list")
members_by_pk = {m.pk: m for m in response.context["members"]}
self.assertIsNone(members_by_pk[self.admin_member.pk].current_membership)
def test_a_lapsed_season_membership_does_not_count_as_current(self):
lapsed_season = Season.objects.create(club=self.club, start_date=self.season.start_date - datetime.timedelta(days=400), end_date=self.season.start_date - datetime.timedelta(days=40))
member = Member.objects.create(first_name="Old", last_name="Season")
ClubMembership.objects.create(club=self.club, member=member, season=lapsed_season, status=ClubMembership.StatusChoices.ACTIVE)
response = self.club_get("member_list")
members_by_pk = {m.pk: m for m in response.context["members"]}
self.assertIsNone(members_by_pk[member.pk].current_membership)
class MemberGrantLoginTests(ManagementTestBase):
"""A login-less child getting their own account -- see
members.services.family.grant_login and management.forms.GrantLoginForm."""
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.family = Family.objects.create()
cls.child = Member.objects.create(first_name="Cody", last_name="Kid")
FamilyMembership.objects.create(family=cls.family, member=cls.child, role=FamilyMembership.FamilyRole.CHILD)
ClubMembership.objects.create(club=cls.club, member=cls.child, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE)
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def test_family_page_offers_the_button_for_a_login_less_child(self):
response = self.club_get("family_detail", self.family.pk)
self.assertContains(response, reverse("management:member_grant_login", args=[self.child.pk]))
def test_no_button_once_the_child_already_has_a_login(self):
self.child.user = User.objects.create_user(email="already@example.com", password="pw-secret-123")
self.child.save()
response = self.club_get("family_detail", self.family.pk)
self.assertNotContains(response, reverse("management:member_grant_login", args=[self.child.pk]))
def test_no_button_for_a_parent(self):
parent = Member.objects.create(first_name="Pat", last_name="Parent")
FamilyMembership.objects.create(family=self.family, member=parent, role=FamilyMembership.FamilyRole.PARENT)
ClubMembership.objects.create(club=self.club, member=parent, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
response = self.club_get("family_detail", self.family.pk)
self.assertNotContains(response, reverse("management:member_grant_login", args=[parent.pk]))
def test_the_form_is_prefilled_with_the_childs_contact_email_if_set(self):
self.child.email = "cody.kid@example.com"
self.child.save()
response = self.club_get("family_detail", self.family.pk)
self.assertContains(response, "cody.kid@example.com")
def test_granting_a_login_creates_a_usable_account(self):
response = self.club_post("member_grant_login", {"email": "cody.kid@example.com"}, self.child.pk)
self.assertRedirects(response, reverse("management:member_detail", args=[self.child.pk]))
self.child.refresh_from_db()
self.assertIsNotNone(self.child.user)
self.assertEqual(self.child.user.email, "cody.kid@example.com")
self.assertFalse(self.child.user.has_usable_password())
def test_an_email_already_in_use_is_rejected(self):
User.objects.create_user(email="taken@example.com", password="pw-secret-123")
response = self.club_post("member_grant_login", {"email": "taken@example.com"}, self.child.pk)
self.assertRedirects(response, reverse("management:member_detail", args=[self.child.pk]))
self.child.refresh_from_db()
self.assertIsNone(self.child.user)
def test_non_admin_gets_403(self):
coach_user = User.objects.create_user(email="coach-grant@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="U18", short_name="U18")
position = Position.objects.create(club=self.club, name="Coach7", short_name="C7", staff_position=True)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
self.client.force_login(coach_user)
response = self.club_post("member_grant_login", {"email": "cody.kid@example.com"}, self.child.pk)
self.assertEqual(response.status_code, 403)
self.child.refresh_from_db()
self.assertIsNone(self.child.user)
class MemberRefereeEligibilityTests(ManagementTestBase):
"""A member's referee level and validity, set from their own page -- see
management.views.MemberRefereeEligibilityUpdateView and
teams.RefereeProfile. Eligible teams are derived from the level
(teams.RefereeLevel), not picked here. Deliberately unrelated to
members.Group."""
@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.level = RefereeLevel.objects.create(club=cls.club, name="Regional")
cls.level.teams.add(cls.team, cls.other_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)
cls.future_date = timezone.localdate() + datetime.timedelta(days=30)
def test_member_page_shows_not_eligible_for_any_team_by_default(self):
self.client.force_login(self.admin_user)
response = self.club_get("member_detail", self.member.pk)
self.assertContains(response, "Not eligible to referee for any team.")
def test_admin_can_set_level_and_validity_for_a_member_with_no_profile_yet(self):
self.client.force_login(self.admin_user)
response = self.club_post("member_referee_eligibility_update", {"level": str(self.level.pk), "valid_until": self.future_date.isoformat()}, self.member.pk)
self.assertRedirects(response, reverse("management:member_detail", args=[self.member.pk]))
profile = RefereeProfile.objects.get(member=self.member)
self.assertEqual(profile.level, self.level)
self.assertEqual(profile.valid_until, self.future_date)
self.assertEqual(set(profile.eligible_teams), {self.team, self.other_team})
def test_admin_can_update_an_existing_profile(self):
other_level = RefereeLevel.objects.create(club=self.club, name="National")
profile = RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=self.future_date)
self.client.force_login(self.admin_user)
self.club_post("member_referee_eligibility_update", {"level": str(other_level.pk), "valid_until": self.future_date.isoformat()}, self.member.pk)
profile.refresh_from_db()
self.assertEqual(profile.level, other_level)
def test_admin_can_clear_the_level_to_make_someone_ineligible(self):
profile = RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=self.future_date)
self.client.force_login(self.admin_user)
self.club_post("member_referee_eligibility_update", {"level": "", "valid_until": self.future_date.isoformat()}, self.member.pk)
profile.refresh_from_db()
self.assertIsNone(profile.level)
self.assertFalse(profile.is_eligible)
def test_member_page_shows_eligible_teams_when_valid(self):
RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=self.future_date)
self.client.force_login(self.admin_user)
response = self.club_get("member_detail", self.member.pk)
self.assertContains(response, "First Team")
def test_member_page_shows_a_warning_once_expired(self):
RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=timezone.localdate() - datetime.timedelta(days=1))
self.client.force_login(self.admin_user)
response = self.club_get("member_detail", self.member.pk)
self.assertContains(response, "Not currently eligible to referee")
self.assertNotContains(response, "First Team")
def test_team_page_lists_eligible_referees(self):
RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=self.future_date)
self.client.force_login(self.admin_user)
response = self.club_get("team_detail", self.team.pk)
self.assertContains(response, "Ref Eree")
def test_team_page_lists_a_referee_eligible_via_inheritance(self):
# self.level ("Regional") already qualifies for self.team; a higher
# level that inherits from it (with no teams of its own) should still
# show up here -- see RefereeLevel.eligible_team_ids.
national = RefereeLevel.objects.create(club=self.club, name="National", inherits_from=self.level)
national_ref = Member.objects.create(first_name="Nat", last_name="Ional")
RefereeProfile.objects.create(member=national_ref, level=national, valid_until=self.future_date)
self.client.force_login(self.admin_user)
response = self.club_get("team_detail", self.team.pk)
self.assertContains(response, "Nat Ional")
def test_team_page_excludes_an_expired_referee(self):
# "Ref Eree" alone also matches the (unrelated) add-player/assign-staff
# dropdowns, which list every active club member regardless of referee
# status -- assert on the eligible-referees panel's own empty state.
RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=timezone.localdate() - datetime.timedelta(days=1))
self.client.force_login(self.admin_user)
response = self.club_get("team_detail", self.team.pk)
self.assertContains(response, "No one yet.")
def test_team_page_shows_a_federation_note_instead_of_eligible_referees(self):
RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=self.future_date)
self.team.referee_management = Team.RefereeManagement.FEDERATION
self.team.save(update_fields=["referee_management"])
self.client.force_login(self.admin_user)
response = self.club_get("team_detail", self.team.pk)
self.assertContains(response, "managed by the federation")
self.assertNotContains(response, "No one yet.")
def test_non_admin_gets_403(self):
coach_user = User.objects.create_user(email="coach-referee-elig@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="HC", 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)
response = self.club_post("member_referee_eligibility_update", {"level": str(self.level.pk), "valid_until": self.future_date.isoformat()}, self.member.pk)
self.assertEqual(response.status_code, 403)
self.assertFalse(RefereeProfile.objects.filter(member=self.member).exists())
def test_non_admin_does_not_see_the_edit_button(self):
coach_user = User.objects.create_user(email="coach-referee-elig2@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)
# Puts self.member within the coach's visibility (members_visible_to) so
# the response is a real 200 -- otherwise "not contains" would trivially
# pass on a 404 for the wrong reason.
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("member_detail", self.member.pk)
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, reverse("management:member_referee_eligibility_update", args=[self.member.pk]))
class MemberFamilyAttachDetachTests(ManagementTestBase):
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.standalone = Member.objects.create(first_name="Stan", last_name="Alone")
ClubMembership.objects.create(club=cls.club, member=cls.standalone, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE)
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def test_attaching_to_a_new_family(self):
response = self.club_post("member_attach_family", {"role": FamilyMembership.FamilyRole.PARENT, "family": ""}, self.standalone.pk)
self.assertRedirects(response, reverse("management:member_detail", args=[self.standalone.pk]))
family = Family.objects.get(memberships__member=self.standalone)
self.assertIn(self.standalone, family.guardians)
def test_attaching_to_an_existing_family(self):
family = Family.objects.create()
kid = Member.objects.create(first_name="Existing", last_name="Kid")
ClubMembership.objects.create(club=self.club, member=kid, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
FamilyMembership.objects.create(family=family, member=kid, role=FamilyMembership.FamilyRole.CHILD)
self.club_post("member_attach_family", {"role": FamilyMembership.FamilyRole.PARENT, "family": str(family.pk)}, self.standalone.pk)
self.assertIn(self.standalone, family.guardians)
self.assertEqual(Family.objects.filter(memberships__member=self.standalone).count(), 1)
def test_detaching_from_family_removes_it_when_left_empty(self):
family = Family.objects.create()
FamilyMembership.objects.create(family=family, member=self.standalone, role=FamilyMembership.FamilyRole.CHILD)
response = self.club_post("member_detach_family", {}, self.standalone.pk, family.pk)
self.assertRedirects(response, reverse("management:member_detail", args=[self.standalone.pk]))
self.assertFalse(FamilyMembership.objects.filter(member=self.standalone).exists())
self.assertFalse(Family.objects.filter(pk=family.pk).exists())
def test_detaching_from_family_keeps_it_when_others_remain(self):
family = Family.objects.create()
sibling = Member.objects.create(first_name="Sibling", last_name="Kid")
ClubMembership.objects.create(club=self.club, member=sibling, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
FamilyMembership.objects.create(family=family, member=self.standalone, role=FamilyMembership.FamilyRole.CHILD)
FamilyMembership.objects.create(family=family, member=sibling, role=FamilyMembership.FamilyRole.CHILD)
self.club_post("member_detach_family", {}, self.standalone.pk, family.pk)
self.assertTrue(Family.objects.filter(pk=family.pk).exists())
self.assertIn(sibling, family.children)
def test_detaching_from_one_family_keeps_membership_in_another(self):
family = Family.objects.create()
other_family = Family.objects.create()
FamilyMembership.objects.create(family=family, member=self.standalone, role=FamilyMembership.FamilyRole.CHILD)
FamilyMembership.objects.create(family=other_family, member=self.standalone, role=FamilyMembership.FamilyRole.OTHER)
self.club_post("member_detach_family", {}, self.standalone.pk, family.pk)
self.assertFalse(FamilyMembership.objects.filter(member=self.standalone, family=family).exists())
self.assertTrue(FamilyMembership.objects.filter(member=self.standalone, family=other_family).exists())
def test_a_member_can_join_a_second_family(self):
family = Family.objects.create()
FamilyMembership.objects.create(family=family, member=self.standalone, role=FamilyMembership.FamilyRole.CHILD)
response = self.club_post("member_attach_family", {"role": FamilyMembership.FamilyRole.OTHER, "family": ""}, self.standalone.pk)
self.assertRedirects(response, reverse("management:member_detail", args=[self.standalone.pk]))
self.assertEqual(FamilyMembership.objects.filter(member=self.standalone).count(), 2)
self.assertTrue(FamilyMembership.objects.filter(member=self.standalone, family=family).exists())
def test_member_detail_shows_a_card_per_family(self):
family = Family.objects.create()
other_family = Family.objects.create()
FamilyMembership.objects.create(family=family, member=self.standalone, role=FamilyMembership.FamilyRole.CHILD)
FamilyMembership.objects.create(family=other_family, member=self.standalone, role=FamilyMembership.FamilyRole.OTHER)
response = self.club_get("member_detail", self.standalone.pk)
self.assertEqual(len(response.context["family_groups"]), 2)
self.assertContains(response, str(family))
self.assertContains(response, str(other_family))
# "Add to family" stays available even though the member is already in two.
self.assertContains(response, "Add to family")
def test_attach_form_excludes_families_already_joined(self):
family = Family.objects.create()
FamilyMembership.objects.create(family=family, member=self.standalone, role=FamilyMembership.FamilyRole.CHILD)
response = self.club_get("member_detail", self.standalone.pk)
self.assertNotIn(family, response.context["attach_to_family_form"].fields["family"].queryset)
def test_a_childs_page_shows_their_guardians_phone_numbers(self):
family = Family.objects.create()
parent = Member.objects.create(first_name="Pat", last_name="Parent", phone="+32470000001", emergency_phone="+32470000002")
FamilyMembership.objects.create(family=family, member=self.standalone, role=FamilyMembership.FamilyRole.CHILD)
FamilyMembership.objects.create(family=family, member=parent, role=FamilyMembership.FamilyRole.PARENT)
response = self.club_get("member_detail", self.standalone.pk)
self.assertIn(parent, response.context["guardians"])
self.assertContains(response, "Pat Parent")
self.assertContains(response, "tel:+32 470 00 00 01")
self.assertContains(response, "tel:+32 470 00 00 02")
def test_a_parents_page_does_not_show_guardian_numbers(self):
# self.standalone has no CHILD membership anywhere -- the section must not appear.
response = self.club_get("member_detail", self.standalone.pk)
self.assertEqual(len(response.context["guardians"]), 0)
self.assertNotContains(response, "Parent/guardian contact")
def test_a_guardian_with_no_phone_numbers_gets_no_dial_buttons(self):
family = Family.objects.create()
parent = Member.objects.create(first_name="Pat", last_name="Parent")
FamilyMembership.objects.create(family=family, member=self.standalone, role=FamilyMembership.FamilyRole.CHILD)
FamilyMembership.objects.create(family=family, member=parent, role=FamilyMembership.FamilyRole.PARENT)
response = self.club_get("member_detail", self.standalone.pk)
self.assertContains(response, "Pat Parent")
self.assertNotContains(response, "tel:")
class MemberDeleteTests(ManagementTestBase):
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.member = Member.objects.create(first_name="Doomed", last_name="Member")
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_deleting_a_member(self):
response = self.club_post("member_delete", {}, self.member.pk)
self.assertRedirects(response, reverse("management:member_list"))
self.assertFalse(Member.objects.filter(pk=self.member.pk).exists())
def test_deleting_the_last_member_of_a_family_cleans_it_up(self):
family = Family.objects.create()
FamilyMembership.objects.create(family=family, member=self.member, role=FamilyMembership.FamilyRole.CHILD)
self.club_post("member_delete", {}, self.member.pk)
self.assertFalse(Family.objects.filter(pk=family.pk).exists())
def test_a_member_referenced_by_an_order_cannot_be_deleted(self):
# shop.Order.purchaser is PROTECT -- deleting must fail gracefully, not 500.
Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("10.00"))
response = self.club_post("member_delete", {}, self.member.pk)
self.assertRedirects(response, reverse("management:member_detail", args=[self.member.pk]))
self.assertTrue(Member.objects.filter(pk=self.member.pk).exists())
class MemberClubMembershipFormTests(ManagementTestBase):
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.member = Member.objects.create(first_name="Fee", last_name="Payer")
cls.membership = 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_editing_a_member_also_updates_their_current_membership(self):
response = self.club_post(
"member_update",
{
"first_name": "Fee",
"last_name": "Payer",
"kind": ClubMembership.Kind.MEMBER,
"license": "BE-9999",
"status": ClubMembership.StatusChoices.ACTIVE,
"fee_status": ClubMembership.FeeStatus.PAID,
},
self.member.pk,
)
self.assertRedirects(response, reverse("management:member_detail", args=[self.member.pk]))
self.membership.refresh_from_db()
self.assertEqual(self.membership.license, "BE-9999")
self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PAID)
def test_membership_section_is_fillable_when_not_yet_rostered_this_season(self):
member = Member.objects.create(first_name="Unrostered", last_name="Member")
# Visible (has *a* membership in this club), but not for the current season --
# this is exactly the case that should now offer a fillable section to sign
# them up, rather than hiding it because nothing exists yet.
old_season = Season.objects.create(club=self.club, start_date=datetime.date(2020, 8, 1), end_date=datetime.date(2021, 5, 31))
ClubMembership.objects.create(club=self.club, member=member, season=old_season, status=ClubMembership.StatusChoices.LAPSED)
response = self.club_get("member_update", member.pk)
self.assertContains(response, "This season")
self.assertFalse(ClubMembership.objects.filter(club=self.club, member=member, season=self.season).exists())
def test_saving_the_membership_section_signs_up_a_previously_unrostered_member(self):
member = Member.objects.create(first_name="Unrostered", last_name="Member")
# A ClubRole, not a ClubMembership -- gives the admin visibility into this
# member without a season-bound row already existing, which is exactly the
# "not signed up for anything yet" case this test means to exercise.
ClubRole.objects.create(club=self.club, member=member, role=ClubRole.Roles.MEMBER)
response = self.club_post(
"member_update",
{"first_name": "Unrostered", "last_name": "Member", "kind": ClubMembership.Kind.MEMBER, "status": ClubMembership.StatusChoices.ACTIVE, "fee_status": ClubMembership.FeeStatus.PAID},
member.pk,
)
self.assertRedirects(response, reverse("management:member_detail", args=[member.pk]))
membership = ClubMembership.objects.get(club=self.club, member=member, season=self.season)
self.assertEqual(membership.status, ClubMembership.StatusChoices.ACTIVE)
self.assertEqual(membership.fee_status, ClubMembership.FeeStatus.PAID)
def test_membership_section_absent_with_no_season_at_all(self):
# A fresh club, never given a season -- there's nothing sensible to sign up
# for, so unlike the "not rostered yet" case above, this stays hidden.
empty_club = Club.objects.create(name="No Season FC", slug="no-season-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="noseasonadmin@example.com", password="pw-secret-123")
admin_member.user = admin_user
admin_member.save()
enrol_mfa(admin_user)
self.client.force_login(admin_user)
member = Member.objects.create(first_name="No", last_name="Season")
ClubRole.objects.create(club=empty_club, member=member, role=ClubRole.Roles.MEMBER)
with override_settings(ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "no-season-fc.rosterchief.app", "testserver"]):
response = self.client.get(reverse("management:member_update", args=[member.pk]), HTTP_HOST="no-season-fc.rosterchief.app")
self.assertNotContains(response, "This season")
def test_detail_page_shows_season_history(self):
old_season = Season.objects.create(club=self.club, start_date=datetime.date(2020, 8, 1), end_date=datetime.date(2021, 5, 31))
ClubMembership.objects.create(club=self.club, member=self.member, season=old_season, status=ClubMembership.StatusChoices.LAPSED, fee_status=ClubMembership.FeeStatus.UNPAID)
response = self.club_get("member_detail", self.member.pk)
self.assertContains(response, f"{self.season.start_date:%Y} - {self.season.end_date:%Y}")
self.assertContains(response, f"{old_season.start_date:%Y} - {old_season.end_date:%Y}")
self.assertContains(response, "lapsed")
def test_formatted_phone_number_is_shown(self):
self.member.phone = "+32476123456"
self.member.save()
response = self.club_get("member_detail", self.member.pk)
self.assertContains(response, "+32 476 12 34 56")
class FamilyDetailViewTests(ManagementTestBase):
"""The family overview page, reached by clicking a family's name -- there's no
standalone "Families" nav entry or list."""
@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, status=ClubMembership.StatusChoices.ACTIVE)
ClubMembership.objects.create(club=cls.club, member=cls.child, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE)
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def test_shows_every_member_of_the_family(self):
response = self.club_get("family_detail", self.family.pk)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Guardian")
self.assertContains(response, "Kid")
def test_a_family_from_another_club_404s(self):
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
other_family = Family.objects.create()
other_member = Member.objects.create(first_name="Other", last_name="Kid")
other_season = make_season(other_club)
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_detail", other_family.pk)
self.assertEqual(response.status_code, 404)
def test_only_admins_see_edit_and_delete_actions(self):
# Give the coach visibility into this family's child (rostered on their team),
# so the assertion below actually exercises the admin-only button gating --
# not just "the coach can't see this row at all".
coach_user = User.objects.create_user(email="coach4@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
team = Team.objects.create(club=self.club, name="U15", short_name="U15")
staff_position = Position.objects.create(club=self.club, name="Coach4", short_name="C4", staff_position=True, management_position=True)
player_position = Position.objects.create(club=self.club, name="Player4", short_name="P4", staff_position=False)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=staff_position)
TeamMembership.objects.create(team=team, member=self.child, season=self.season, position=player_position)
self.client.force_login(coach_user)
response = self.club_get("family_detail", self.family.pk)
self.assertContains(response, "Kid")
# The edit icon links straight to member_detail, same as the row's own name
# link -- not admin-exclusive, so member_delete is the actual gating signal.
self.assertNotContains(response, reverse("management:member_delete", args=[self.child.pk]))
def test_admin_sees_a_remove_from_family_action_per_row(self):
response = self.club_get("family_detail", self.family.pk)
self.assertContains(response, reverse("management:member_detach_family", args=[self.parent.pk, self.family.pk]))
self.assertContains(response, reverse("management:member_detach_family", args=[self.child.pk, self.family.pk]))
def test_non_admin_does_not_see_the_remove_from_family_action(self):
coach_user = User.objects.create_user(email="coach6@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
team = Team.objects.create(club=self.club, name="U17", short_name="U17")
staff_position = Position.objects.create(club=self.club, name="Coach6", short_name="C6", staff_position=True, management_position=True)
player_position = Position.objects.create(club=self.club, name="Player6", short_name="P6", staff_position=False)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=staff_position)
TeamMembership.objects.create(team=team, member=self.child, season=self.season, position=player_position)
self.client.force_login(coach_user)
response = self.club_get("family_detail", self.family.pk)
self.assertNotContains(response, reverse("management:member_detach_family", args=[self.child.pk, self.family.pk]))
def test_removing_a_family_member_from_the_row_button_detaches_only_them(self):
response = self.club_post("member_detach_family", {}, self.child.pk, self.family.pk)
self.assertRedirects(response, reverse("management:member_detail", args=[self.child.pk]))
self.assertFalse(FamilyMembership.objects.filter(family=self.family, member=self.child).exists())
self.assertTrue(FamilyMembership.objects.filter(family=self.family, member=self.parent).exists())
class FamilyMembershipRoleUpdateTests(ManagementTestBase):
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.family = Family.objects.create()
cls.member = Member.objects.create(first_name="Cody", last_name="Kid")
cls.membership = FamilyMembership.objects.create(family=cls.family, member=cls.member, role=FamilyMembership.FamilyRole.CHILD)
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_admin_can_change_the_role(self):
response = self.club_post("family_membership_role_update", {"role": FamilyMembership.FamilyRole.GUARDIAN}, self.family.pk, self.member.pk)
self.assertRedirects(response, reverse("management:family_detail", args=[self.family.pk]))
self.membership.refresh_from_db()
self.assertEqual(self.membership.role, FamilyMembership.FamilyRole.GUARDIAN)
def test_only_changes_the_role_in_the_targeted_family(self):
other_family = Family.objects.create()
other_membership = FamilyMembership.objects.create(family=other_family, member=self.member, role=FamilyMembership.FamilyRole.OTHER)
self.club_post("family_membership_role_update", {"role": FamilyMembership.FamilyRole.PARENT}, self.family.pk, self.member.pk)
self.membership.refresh_from_db()
other_membership.refresh_from_db()
self.assertEqual(self.membership.role, FamilyMembership.FamilyRole.PARENT)
self.assertEqual(other_membership.role, FamilyMembership.FamilyRole.OTHER)
def test_an_invalid_role_is_rejected(self):
response = self.club_post("family_membership_role_update", {"role": "not-a-role"}, self.family.pk, self.member.pk)
self.assertRedirects(response, reverse("management:family_detail", args=[self.family.pk]))
self.membership.refresh_from_db()
self.assertEqual(self.membership.role, FamilyMembership.FamilyRole.CHILD)
def test_non_admin_gets_403(self):
coach_user = User.objects.create_user(email="coach-role@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
team = Team.objects.create(club=self.club, name="U16", short_name="U16")
position = Position.objects.create(club=self.club, name="Coach5", short_name="C5", staff_position=True)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
self.client.force_login(coach_user)
response = self.club_post("family_membership_role_update", {"role": FamilyMembership.FamilyRole.PARENT}, self.family.pk, self.member.pk)
self.assertEqual(response.status_code, 403)
self.membership.refresh_from_db()
self.assertEqual(self.membership.role, FamilyMembership.FamilyRole.CHILD)
def test_a_family_from_another_club_404s(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_post("family_membership_role_update", {"role": FamilyMembership.FamilyRole.PARENT}, other_family.pk, other_member.pk)
self.assertEqual(response.status_code, 404)
def test_the_dropdown_reflects_the_role_specific_to_that_family(self):
# Same member, a different role in a second family -- the "others" bucket
# used to read person.family_memberships.first(), which could silently show
# a role from the wrong family. This is exactly that scenario.
other_family = Family.objects.create()
FamilyMembership.objects.create(family=other_family, member=self.member, role=FamilyMembership.FamilyRole.OTHER)
this_family_response = self.club_get("family_detail", self.family.pk)
other_family_response = self.club_get("family_detail", other_family.pk)
self.assertContains(this_family_response, 'value="child" selected')
self.assertContains(other_family_response, 'value="other" selected')
def test_redirects_to_next_when_changed_from_the_member_page(self):
next_url = reverse("management:member_detail", args=[self.member.pk])
response = self.club_post("family_membership_role_update", {"role": FamilyMembership.FamilyRole.GUARDIAN, "next": next_url}, self.family.pk, self.member.pk)
self.assertRedirects(response, next_url)
def test_ignores_an_unsafe_next_url(self):
response = self.club_post("family_membership_role_update", {"role": FamilyMembership.FamilyRole.GUARDIAN, "next": "https://evil.example.com/steal"}, self.family.pk, self.member.pk)
self.assertRedirects(response, reverse("management:family_detail", args=[self.family.pk]))
def test_member_detail_page_sends_its_own_url_as_next(self):
response = self.club_get("member_detail", self.member.pk)
self.assertContains(response, f'name="next" value="{reverse("management:member_detail", args=[self.member.pk])}"')
def test_family_detail_page_sends_no_next(self):
response = self.club_get("family_detail", self.family.pk)
self.assertNotContains(response, 'name="next"')
class MembershipListViewTests(ManagementTestBase):
@classmethod
def setUpTestData(cls):
super().setUpTestData()
# The base class's own admin ClubMembership (fee_status defaults to UNPAID)
# would otherwise pollute every count below -- ClubRole (not ClubMembership)
# is what actually makes them an admin, so this is safe to drop.
ClubMembership.objects.filter(club=cls.club, member=cls.admin_member).delete()
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def make_membership(self, first_name, last_name, *, status=ClubMembership.StatusChoices.ACTIVE, fee_status=ClubMembership.FeeStatus.UNPAID, season=None, license=""):
member = Member.objects.create(first_name=first_name, last_name=last_name)
return ClubMembership.objects.create(club=self.club, member=member, season=season or self.season, status=status, fee_status=fee_status, license=license)
def test_kpi_counts_for_the_current_season(self):
self.make_membership("Paid", "One", fee_status=ClubMembership.FeeStatus.PAID)
self.make_membership("Partial", "One", fee_status=ClubMembership.FeeStatus.PARTIALLY_PAID)
self.make_membership("Unpaid", "One", fee_status=ClubMembership.FeeStatus.UNPAID)
self.make_membership("Unpaid", "Two", fee_status=ClubMembership.FeeStatus.UNPAID)
self.make_membership("Waived", "One", fee_status=ClubMembership.FeeStatus.WAIVED)
response = self.club_get("membership_list")
self.assertEqual(response.context["kpi_total"], 5)
self.assertEqual(response.context["kpi_paid"], 1)
self.assertEqual(response.context["kpi_partial"], 1)
self.assertEqual(response.context["kpi_unpaid"], 2)
self.assertEqual(response.context["kpi_waived"], 1)
self.assertEqual(response.context["kpi_paid_rate"], 20)
def test_default_list_excludes_paid_but_includes_waived(self):
paid = self.make_membership("Paid", "One", fee_status=ClubMembership.FeeStatus.PAID)
unpaid = self.make_membership("Unpaid", "One", fee_status=ClubMembership.FeeStatus.UNPAID)
waived = self.make_membership("Waived", "One", fee_status=ClubMembership.FeeStatus.WAIVED)
response = self.club_get("membership_list")
ids = {m.pk for m in response.context["memberships"]}
self.assertNotIn(paid.pk, ids)
self.assertIn(unpaid.pk, ids)
self.assertIn(waived.pk, ids)
def test_fee_status_filter_narrows_to_one_value(self):
unpaid = self.make_membership("Unpaid", "One", fee_status=ClubMembership.FeeStatus.UNPAID)
waived = self.make_membership("Waived", "One", fee_status=ClubMembership.FeeStatus.WAIVED)
response = self.client.get(reverse("management:membership_list") + "?fee_status=waived", HTTP_HOST="ajax-united.rosterchief.app")
ids = {m.pk for m in response.context["memberships"]}
self.assertNotIn(unpaid.pk, ids)
self.assertIn(waived.pk, ids)
def test_fee_status_all_includes_paid(self):
paid = self.make_membership("Paid", "One", fee_status=ClubMembership.FeeStatus.PAID)
response = self.client.get(reverse("management:membership_list") + "?fee_status=all", HTTP_HOST="ajax-united.rosterchief.app")
ids = {m.pk for m in response.context["memberships"]}
self.assertIn(paid.pk, ids)
def test_status_filter(self):
active = self.make_membership("Active", "One", status=ClubMembership.StatusChoices.ACTIVE)
pending = self.make_membership("Pending", "One", status=ClubMembership.StatusChoices.PENDING)
response = self.client.get(reverse("management:membership_list") + "?status=pending", HTTP_HOST="ajax-united.rosterchief.app")
ids = {m.pk for m in response.context["memberships"]}
self.assertNotIn(active.pk, ids)
self.assertIn(pending.pk, ids)
def test_team_filter(self):
on_team = self.make_membership("OnTeam", "Kid")
off_team = self.make_membership("OffTeam", "Kid")
team = Team.objects.create(club=self.club, name="U10", short_name="U10")
position = Position.objects.create(club=self.club, name="Player", short_name="P")
TeamMembership.objects.create(team=team, member=on_team.member, season=self.season, position=position)
response = self.client.get(reverse("management:membership_list") + f"?team={team.pk}", HTTP_HOST="ajax-united.rosterchief.app")
ids = {m.pk for m in response.context["memberships"]}
self.assertIn(on_team.pk, ids)
self.assertNotIn(off_team.pk, ids)
def test_search_filter(self):
match = self.make_membership("Findme", "Person")
other = self.make_membership("Other", "Person")
response = self.client.get(reverse("management:membership_list") + "?q=Findme", HTTP_HOST="ajax-united.rosterchief.app")
ids = {m.pk for m in response.context["memberships"]}
self.assertIn(match.pk, ids)
self.assertNotIn(other.pk, ids)
def test_search_matches_by_family_surname(self):
# Searching "Smith" should find a family member even when their own name
# isn't Smith -- e.g. a parent with a different surname than their kid.
family = Family.objects.create()
smith_kid = self.make_membership("Junior", "Smith")
FamilyMembership.objects.create(family=family, member=smith_kid.member, role=FamilyMembership.FamilyRole.CHILD)
other_parent = self.make_membership("Alex", "Jones")
FamilyMembership.objects.create(family=family, member=other_parent.member, role=FamilyMembership.FamilyRole.PARENT)
unrelated = self.make_membership("Nobody", "Related")
response = self.client.get(reverse("management:membership_list") + "?q=Smith", HTTP_HOST="ajax-united.rosterchief.app")
ids = {m.pk for m in response.context["memberships"]}
self.assertIn(smith_kid.pk, ids)
self.assertIn(other_parent.pk, ids)
self.assertNotIn(unrelated.pk, ids)
def test_search_matches_an_explicit_family_name(self):
family = Family.objects.create(name="The Andersons")
membership = self.make_membership("Pat", "Vandermeer")
FamilyMembership.objects.create(family=family, member=membership.member, role=FamilyMembership.FamilyRole.PARENT)
unrelated = self.make_membership("Nobody", "Related")
response = self.client.get(reverse("management:membership_list") + "?q=Anderson", HTTP_HOST="ajax-united.rosterchief.app")
ids = {m.pk for m in response.context["memberships"]}
self.assertIn(membership.pk, ids)
self.assertNotIn(unrelated.pk, ids)
def test_family_column_shows_every_family_a_member_belongs_to(self):
family = Family.objects.create()
other_family = Family.objects.create()
membership = self.make_membership("Multi", "Family")
FamilyMembership.objects.create(family=family, member=membership.member, role=FamilyMembership.FamilyRole.CHILD)
FamilyMembership.objects.create(family=other_family, member=membership.member, role=FamilyMembership.FamilyRole.OTHER)
response = self.club_get("membership_list")
by_pk = {m.pk: m for m in response.context["memberships"]}
family_ids = {fm.family_id for fm in by_pk[membership.pk].member.family_memberships_display}
self.assertEqual(family_ids, {family.pk, other_family.pk})
def test_season_filter_switches_away_from_current(self):
old_season = Season.objects.create(club=self.club, start_date=datetime.date(2020, 8, 1), end_date=datetime.date(2021, 5, 31))
current_membership = self.make_membership("Current", "Season")
old_membership = self.make_membership("Old", "Season", season=old_season, fee_status=ClubMembership.FeeStatus.UNPAID)
response = self.client.get(reverse("management:membership_list") + f"?season={old_season.pk}", HTTP_HOST="ajax-united.rosterchief.app")
ids = {m.pk for m in response.context["memberships"]}
self.assertIn(old_membership.pk, ids)
self.assertNotIn(current_membership.pk, ids)
# KPIs stay pinned to the *current* season regardless of the list's own filter.
self.assertEqual(response.context["current_season"], self.season)
def test_non_admin_gets_403(self):
coach_user = User.objects.create_user(email="coach-membership@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="U19", short_name="U19")
position = Position.objects.create(club=self.club, name="Coach8", short_name="C8", 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_list")
self.assertEqual(response.status_code, 403)
def test_nav_entry_is_admin_only(self):
admin_response = self.club_get("home")
self.assertContains(admin_response, reverse("management:membership_list"))
class MembershipMarkPaidTests(ManagementTestBase):
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.member = Member.objects.create(first_name="Owed", last_name="Fee")
cls.membership = ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.UNPAID)
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def test_marking_paid_settles_the_fee_but_leaves_status_pending(self):
# Settling the fee, even through this explicit admin action, never activates
# on its own -- club.services.onboarding.approve_one/approve_all_clean (the
# Sign-up page) is the only path to ACTIVE, since it's the one place that
# also checks the onboarding checklist.
response = self.club_post("membership_mark_paid", {"membership_ids": [str(self.membership.pk)]})
self.assertRedirects(response, reverse("management:membership_list"))
self.membership.refresh_from_db()
self.assertEqual(self.membership.status, ClubMembership.StatusChoices.PENDING)
self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PAID)
self.assertIsNone(self.membership.activated_at)
def test_marking_paid_does_not_grant_the_member_role(self):
# MEMBER is granted off status ACTIVE (club/signals.py's post_save signal) --
# paying the fee alone doesn't reach that, so the role must not appear either.
self.club_post("membership_mark_paid", {"membership_ids": [str(self.membership.pk)]})
self.assertFalse(ClubRole.objects.filter(club=self.club, member=self.member, role=ClubRole.Roles.MEMBER).exists())
def test_does_not_overwrite_an_existing_activated_at(self):
earlier = datetime.date(2026, 1, 1)
self.membership.activated_at = earlier
self.membership.save()
self.club_post("membership_mark_paid", {"membership_ids": [str(self.membership.pk)]})
self.membership.refresh_from_db()
self.assertEqual(self.membership.activated_at, earlier)
def test_does_not_touch_unselected_rows(self):
other = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="Not", last_name="Selected"), season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.UNPAID)
self.club_post("membership_mark_paid", {"membership_ids": [str(self.membership.pk)]})
other.refresh_from_db()
self.assertEqual(other.status, ClubMembership.StatusChoices.PENDING)
self.assertEqual(other.fee_status, ClubMembership.FeeStatus.UNPAID)
def test_a_membership_from_another_club_is_ignored(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="Club")
other_membership = ClubMembership.objects.create(club=other_club, member=other_member, season=other_season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.UNPAID)
self.club_post("membership_mark_paid", {"membership_ids": [str(other_membership.pk)]})
other_membership.refresh_from_db()
self.assertEqual(other_membership.status, ClubMembership.StatusChoices.PENDING)
def test_redirects_to_a_safe_next(self):
next_url = reverse("management:membership_list") + "?status=pending"
response = self.club_post("membership_mark_paid", {"membership_ids": [str(self.membership.pk)], "next": next_url})
self.assertRedirects(response, next_url)
def test_ignores_an_unsafe_next(self):
response = self.club_post("membership_mark_paid", {"membership_ids": [str(self.membership.pk)], "next": "https://evil.example.com/"})
self.assertRedirects(response, reverse("management:membership_list"))
def test_nothing_selected_is_a_harmless_no_op(self):
response = self.club_post("membership_mark_paid", {})
self.assertRedirects(response, reverse("management:membership_list"))
self.membership.refresh_from_db()
self.assertEqual(self.membership.status, ClubMembership.StatusChoices.PENDING)
def test_non_admin_gets_403(self):
coach_user = User.objects.create_user(email="coach-markpaid@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="U20", short_name="U20")
position = Position.objects.create(club=self.club, name="Coach9", short_name="C9", staff_position=True)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
self.client.force_login(coach_user)
response = self.club_post("membership_mark_paid", {"membership_ids": [str(self.membership.pk)]})
self.assertEqual(response.status_code, 403)
self.membership.refresh_from_db()
self.assertEqual(self.membership.status, ClubMembership.StatusChoices.PENDING)
class MembershipRecordPaymentTests(ManagementTestBase):
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.member = Member.objects.create(first_name="Owed", last_name="Fee")
cls.membership = ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.UNPAID, fee_amount=Decimal("150.00"))
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def test_recording_a_partial_payment(self):
response = self.club_post("membership_record_payment", {"amount": "50.00", "method": FeePayment.Method.CASH, "reference": "R1"}, self.membership.pk)
self.assertRedirects(response, reverse("management:membership_list"))
self.membership.refresh_from_db()
self.assertEqual(self.membership.amount_paid, Decimal("50.00"))
self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PARTIALLY_PAID)
payment = FeePayment.objects.get(membership=self.membership)
self.assertEqual(payment.reference, "R1")
self.assertEqual(payment.recorded_by, self.admin_user)
def test_recording_the_full_remaining_amount_settles_the_fee_but_leaves_status_pending(self):
self.club_post("membership_record_payment", {"amount": "150.00", "method": FeePayment.Method.BANK_TRANSFER}, self.membership.pk)
self.membership.refresh_from_db()
self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PAID)
self.assertEqual(self.membership.status, ClubMembership.StatusChoices.PENDING)
def test_an_invalid_amount_is_rejected_without_recording_anything(self):
response = self.club_post("membership_record_payment", {"amount": "0", "method": FeePayment.Method.CASH}, self.membership.pk)
self.assertRedirects(response, reverse("management:membership_list"))
self.assertFalse(FeePayment.objects.filter(membership=self.membership).exists())
self.membership.refresh_from_db()
self.assertEqual(self.membership.amount_paid, Decimal("0.00"))
def test_non_admin_gets_403(self):
coach_user = User.objects.create_user(email="coach-recordpay@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="U22", short_name="U22")
position = Position.objects.create(club=self.club, name="Coach11", short_name="C11", staff_position=True)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
self.client.force_login(coach_user)
response = self.club_post("membership_record_payment", {"amount": "50.00", "method": FeePayment.Method.CASH}, self.membership.pk)
self.assertEqual(response.status_code, 403)
self.assertFalse(FeePayment.objects.filter(membership=self.membership).exists())
class MembershipMarkFullyPaidTests(ManagementTestBase):
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.member = Member.objects.create(first_name="Owed", last_name="Fee")
cls.membership = ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.UNPAID, fee_amount=Decimal("150.00"))
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def test_settles_the_remaining_balance_in_one_click(self):
response = self.club_post("membership_mark_fully_paid", {}, self.membership.pk)
self.assertRedirects(response, reverse("management:membership_list"))
self.membership.refresh_from_db()
self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PAID)
self.assertEqual(self.membership.status, ClubMembership.StatusChoices.PENDING)
self.assertEqual(self.membership.amount_paid, Decimal("150.00"))
def test_a_membership_from_another_club_404s(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="Club")
other_membership = ClubMembership.objects.create(club=other_club, member=other_member, season=other_season, fee_amount=Decimal("100.00"))
response = self.club_post("membership_mark_fully_paid", {}, other_membership.pk)
self.assertEqual(response.status_code, 404)
def test_redirects_to_a_safe_next(self):
next_url = reverse("management:membership_list") + "?status=pending"
response = self.club_post("membership_mark_fully_paid", {"next": next_url}, self.membership.pk)
self.assertRedirects(response, next_url)
def test_non_admin_gets_403(self):
coach_user = User.objects.create_user(email="coach-fullypaid@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="U23", short_name="U23")
position = Position.objects.create(club=self.club, name="Coach12", short_name="C12", staff_position=True)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
self.client.force_login(coach_user)
response = self.club_post("membership_mark_fully_paid", {}, self.membership.pk)
self.assertEqual(response.status_code, 403)
self.membership.refresh_from_db()
self.assertEqual(self.membership.status, ClubMembership.StatusChoices.PENDING)
class MembershipListButtonVisibilityTests(ManagementTestBase):
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def make_membership(self, fee_status):
member = Member.objects.create(first_name=fee_status, last_name="Row")
return ClubMembership.objects.create(club=self.club, member=member, season=self.season, fee_status=fee_status, fee_amount=Decimal("100.00"))
def test_buttons_hidden_for_a_paid_row(self):
membership = self.make_membership(ClubMembership.FeeStatus.PAID)
response = self.client.get(reverse("management:membership_list") + "?fee_status=all", HTTP_HOST="ajax-united.rosterchief.app")
self.assertNotContains(response, reverse("management:membership_mark_fully_paid", args=[membership.pk]))
def test_buttons_hidden_for_a_waived_row(self):
membership = self.make_membership(ClubMembership.FeeStatus.WAIVED)
response = self.client.get(reverse("management:membership_list") + "?fee_status=all", HTTP_HOST="ajax-united.rosterchief.app")
self.assertNotContains(response, reverse("management:membership_mark_fully_paid", args=[membership.pk]))
def test_buttons_shown_for_an_unpaid_row(self):
membership = self.make_membership(ClubMembership.FeeStatus.UNPAID)
response = self.client.get(reverse("management:membership_list") + "?fee_status=all", HTTP_HOST="ajax-united.rosterchief.app")
self.assertContains(response, reverse("management:membership_mark_fully_paid", args=[membership.pk]))
class RenderPdfTests(TestCase):
"""management.pdf.render_pdf itself -- see billing/tests.py's equivalent
coverage of billing.services.invoices.render_pdf, same lazy-import shape."""
def test_the_pdf_library_is_only_needed_when_a_pdf_is_asked_for(self):
with mock.patch.dict(sys.modules, {"weasyprint": mock.MagicMock()}):
sys.modules["weasyprint"].HTML.return_value.write_pdf.return_value = b"%PDF-1.7"
self.assertEqual(render_pdf("<p>hi</p>"), 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("<p>hi</p>")
class MembershipExportPdfTests(ManagementTestBase):
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.member = Member.objects.create(first_name="Print", last_name="Me")
ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE, fee_status=ClubMembership.FeeStatus.UNPAID)
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def test_downloads_as_a_pdf(self):
with mock.patch("management.views.membership_list_pdf", return_value=b"%PDF-fake") as renderer:
response = self.club_get("membership_export_pdf")
self.assertEqual(response["Content-Type"], "application/pdf")
self.assertIn(".pdf", response["Content-Disposition"])
self.assertEqual(response.content, b"%PDF-fake")
renderer.assert_called_once()
def test_export_uses_the_same_filters_as_the_page(self):
# Same fixture as the "on-team" filter test for the page itself -- the PDF
# must reflect whatever's filtered, not the whole club.
other = Member.objects.create(first_name="Other", last_name="Person")
ClubMembership.objects.create(club=self.club, member=other, season=self.season, status=ClubMembership.StatusChoices.ACTIVE, fee_status=ClubMembership.FeeStatus.UNPAID)
with mock.patch("management.views.membership_list_pdf", return_value=b"%PDF-fake") as renderer:
self.client.get(reverse("management:membership_export_pdf") + "?q=Print", HTTP_HOST="ajax-united.rosterchief.app")
context = renderer.call_args[0][0]
names = {m.member.last_name for m in context["memberships"]}
self.assertEqual(names, {"Me"})
def test_a_missing_pdf_library_is_reported_rather_than_a_500(self):
# WeasyPrint needs native libs. Without them the button must explain itself.
with mock.patch("management.views.membership_list_pdf", side_effect=PDFExportError("PDF rendering needs the native pango/cairo libraries.")):
response = self.club_get("membership_export_pdf")
response = self.client.get(response.url, HTTP_HOST="ajax-united.rosterchief.app")
self.assertContains(response, "pango")
def test_non_admin_gets_403(self):
coach_user = User.objects.create_user(email="coach-export@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
team = Team.objects.create(club=self.club, name="U21", short_name="U21")
position = Position.objects.create(club=self.club, name="Coach10", short_name="C10", staff_position=True)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
self.client.force_login(coach_user)
response = self.club_get("membership_export_pdf")
self.assertEqual(response.status_code, 403)
class MembershipSendInvoicesTests(ManagementTestBase):
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
self.member = Member.objects.create(first_name="Jane", last_name="Doe", email="jane@example.com")
self.membership = ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE, fee_amount=Decimal("150.00"))
def test_sending_creates_and_emails_an_invoice(self):
self.club_post("membership_send_invoices", {"membership_ids": [str(self.membership.pk)], "due_in_days": "14"})
invoice = DuesInvoice.objects.get(membership=self.membership)
self.assertIsNotNone(invoice.sent_at)
self.assertEqual(invoice.sent_to_email, "jane@example.com")
self.assertEqual(invoice.amount, Decimal("150.00"))
self.assertEqual(invoice.due_date, timezone.now().date() + datetime.timedelta(days=14))
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].to, ["jane@example.com"])
def test_the_email_carries_an_html_alternative(self):
self.club_post("membership_send_invoices", {"membership_ids": [str(self.membership.pk)], "due_in_days": "14"})
[(html_body, mimetype)] = mail.outbox[0].alternatives
self.assertEqual(mimetype, "text/html")
self.assertIn(self.club.name, html_body)
def test_falls_back_to_a_guardians_email(self):
self.member.email = ""
self.member.save(update_fields=["email"])
family = Family.objects.create()
parent = Member.objects.create(first_name="Pat", last_name="Doe", email="pat@example.com")
FamilyMembership.objects.create(family=family, member=self.member, role=FamilyMembership.FamilyRole.CHILD)
FamilyMembership.objects.create(family=family, member=parent, role=FamilyMembership.FamilyRole.PARENT)
self.club_post("membership_send_invoices", {"membership_ids": [str(self.membership.pk)], "due_in_days": "14"})
invoice = DuesInvoice.objects.get(membership=self.membership)
self.assertEqual(invoice.sent_to_email, "pat@example.com")
self.assertTrue(invoice.sent_to_guardian)
def test_a_member_with_no_reachable_email_is_skipped(self):
self.member.email = ""
self.member.save(update_fields=["email"])
response = self.club_post("membership_send_invoices", {"membership_ids": [str(self.membership.pk)], "due_in_days": "14"})
self.assertFalse(DuesInvoice.objects.filter(membership=self.membership).exists())
self.assertEqual(len(mail.outbox), 0)
response = self.client.get(response.url, HTTP_HOST="ajax-united.rosterchief.app")
self.assertContains(response, "no email on file")
def test_resending_updates_the_same_invoice(self):
self.club_post("membership_send_invoices", {"membership_ids": [str(self.membership.pk)], "due_in_days": "14"})
first_number = DuesInvoice.objects.get(membership=self.membership).number
self.club_post("membership_send_invoices", {"membership_ids": [str(self.membership.pk)], "due_in_days": "30"})
self.assertEqual(DuesInvoice.objects.filter(membership=self.membership).count(), 1)
invoice = DuesInvoice.objects.get(membership=self.membership)
self.assertEqual(invoice.number, first_number)
self.assertEqual(invoice.due_date, timezone.now().date() + datetime.timedelta(days=30))
self.assertEqual(len(mail.outbox), 2)
def test_no_selection_shows_an_error(self):
response = self.club_post("membership_send_invoices", {"due_in_days": "14"})
self.assertFalse(DuesInvoice.objects.exists())
response = self.client.get(response.url, HTTP_HOST="ajax-united.rosterchief.app")
self.assertContains(response, "Select at least one member")
def test_non_admin_gets_403(self):
coach_user = User.objects.create_user(email="coach-invoice@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
team = Team.objects.create(club=self.club, name="U15", short_name="U15")
position = Position.objects.create(club=self.club, name="Coach11", short_name="C11", staff_position=True)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
self.client.force_login(coach_user)
response = self.club_post("membership_send_invoices", {"membership_ids": [str(self.membership.pk)], "due_in_days": "14"})
self.assertEqual(response.status_code, 403)
class MembershipSendInvoiceRemindersTests(ManagementTestBase):
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def make_invoice(self, *, due_date, fee_status=ClubMembership.FeeStatus.UNPAID, email="jane@example.com"):
member = Member.objects.create(first_name="Jane", last_name="Doe", email=email)
membership = ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE, fee_amount=Decimal("100.00"), fee_status=fee_status)
return DuesInvoice.objects.create(club=self.club, membership=membership, number="DUE-2026-00001", amount=Decimal("100.00"), due_date=due_date, sent_at=timezone.now(), sent_to_email=email)
def test_reminds_an_overdue_unpaid_invoice(self):
invoice = self.make_invoice(due_date=timezone.now().date() - datetime.timedelta(days=1))
self.club_post("membership_send_invoice_reminders", {})
invoice.refresh_from_db()
self.assertEqual(invoice.reminder_count, 1)
self.assertIsNotNone(invoice.last_reminder_sent_at)
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].to, ["jane@example.com"])
def test_does_not_remind_one_not_yet_due(self):
invoice = self.make_invoice(due_date=timezone.now().date() + datetime.timedelta(days=5))
self.club_post("membership_send_invoice_reminders", {})
invoice.refresh_from_db()
self.assertEqual(invoice.reminder_count, 0)
self.assertEqual(len(mail.outbox), 0)
def test_does_not_remind_a_paid_invoice(self):
invoice = self.make_invoice(due_date=timezone.now().date() - datetime.timedelta(days=1), fee_status=ClubMembership.FeeStatus.PAID)
self.club_post("membership_send_invoice_reminders", {})
invoice.refresh_from_db()
self.assertEqual(invoice.reminder_count, 0)
def test_nothing_to_remind_notifies_gracefully(self):
response = self.club_post("membership_send_invoice_reminders", {})
response = self.client.get(response.url, HTTP_HOST="ajax-united.rosterchief.app")
self.assertContains(response, "Nothing to remind")
def test_non_admin_gets_403(self):
coach_user = User.objects.create_user(email="coach-reminder@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
team = Team.objects.create(club=self.club, name="U16", short_name="U16")
position = Position.objects.create(club=self.club, name="Coach12", short_name="C12", staff_position=True)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
self.client.force_login(coach_user)
response = self.club_post("membership_send_invoice_reminders", {})
self.assertEqual(response.status_code, 403)
class DuesInvoiceDetailViewTests(ManagementTestBase):
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
self.member = Member.objects.create(first_name="Jane", last_name="Doe", email="jane@example.com")
self.membership = ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE, fee_amount=Decimal("150.00"))
self.invoice = DuesInvoice.objects.create(club=self.club, membership=self.membership, number="DUE-2026-00001", amount=Decimal("150.00"), due_date=timezone.now().date(), sent_at=timezone.now(), sent_to_email="jane@example.com")
def test_shows_the_invoice(self):
response = self.club_get("membership_invoice_detail", self.membership.pk)
self.assertContains(response, "DUE-2026-00001")
self.assertContains(response, "jane@example.com")
def test_404_when_the_membership_has_no_invoice(self):
other_member = Member.objects.create(first_name="No", last_name="Invoice")
other_membership = ClubMembership.objects.create(club=self.club, member=other_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
response = self.club_get("membership_invoice_detail", other_membership.pk)
self.assertEqual(response.status_code, 404)
def test_downloads_as_a_pdf(self):
with mock.patch("management.views.invoice_pdf", return_value=b"%PDF-fake") as renderer:
response = self.club_get("membership_invoice_pdf", self.membership.pk)
self.assertEqual(response["Content-Type"], "application/pdf")
self.assertEqual(response.content, b"%PDF-fake")
renderer.assert_called_once()
def test_a_missing_pdf_library_is_reported_rather_than_a_500(self):
with mock.patch("management.views.invoice_pdf", side_effect=DuesInvoicePDFError("PDF rendering needs the native pango/cairo libraries.")):
response = self.club_get("membership_invoice_pdf", self.membership.pk)
response = self.client.get(response.url, HTTP_HOST="ajax-united.rosterchief.app")
self.assertContains(response, "pango")
def test_non_admin_gets_403(self):
coach_user = User.objects.create_user(email="coach-invoice-detail@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
team = Team.objects.create(club=self.club, name="U17", short_name="U17")
position = Position.objects.create(club=self.club, name="Coach13", short_name="C13", staff_position=True)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
self.client.force_login(coach_user)
response = self.club_get("membership_invoice_detail", self.membership.pk)
self.assertEqual(response.status_code, 403)
class MemberListRowActionsTests(ManagementTestBase):
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.member = Member.objects.create(first_name="Row", last_name="Actions")
ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE)
def test_admin_sees_edit_and_delete_buttons(self):
self.client.force_login(self.admin_user)
response = self.club_get("member_list")
self.assertContains(response, reverse("management:member_delete", args=[self.member.pk]))
def test_non_admin_does_not_see_edit_and_delete_buttons(self):
# Roster self.member on the coach's team so this actually tests the
# admin-only button gating, not just "the coach can't see this row at all".
coach_user = User.objects.create_user(email="coach5@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
team = Team.objects.create(club=self.club, name="U16", short_name="U16")
staff_position = Position.objects.create(club=self.club, name="Coach5", short_name="C5", staff_position=True, management_position=True)
player_position = Position.objects.create(club=self.club, name="Player5", short_name="P5", staff_position=False)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=staff_position)
TeamMembership.objects.create(team=team, member=self.member, season=self.season, position=player_position)
self.client.force_login(coach_user)
response = self.club_get("member_list")
self.assertContains(response, "Row")
self.assertNotContains(response, reverse("management:member_delete", args=[self.member.pk]))
class HomeViewTests(ManagementTestBase):
"""The dashboard reuses controlpanel.services.statistics' club_attention/
club_charts/club_statistics -- already club-scoped, so directly usable for this
club's own staff. Financial pieces (fee chart, Shop stat group) are admin-only,
same line the nav already draws around the Shop section."""
def make_coach(self, email):
coach_user = User.objects.create_user(email=email, password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
team = Team.objects.create(club=self.club, name=f"Team-{email}", short_name="T")
position = Position.objects.create(club=self.club, name=f"Coach-{email}", short_name="C", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
return coach_user
def test_admin_sees_the_financial_sections(self):
self.client.force_login(self.admin_user)
response = self.club_get("home")
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'id="fees-chart"')
self.assertContains(response, 'id="signups-chart"')
self.assertContains(response, "Renewal rate")
self.assertContains(response, "Open carts")
self.assertContains(response, "md:grid-cols-4")
def test_non_admin_staff_does_not_see_the_financial_sections(self):
self.client.force_login(self.make_coach("coach6@example.com"))
response = self.club_get("home")
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, 'id="fees-chart"')
self.assertNotContains(response, 'id="signups-chart"')
self.assertNotContains(response, "Renewal rate")
self.assertNotContains(response, "Open carts")
self.assertContains(response, "md:grid-cols-3")
def test_the_season_pill_shows_the_season_years_not_the_short_name(self):
self.client.force_login(self.admin_user)
response = self.club_get("home")
self.assertContains(response, f"Season {self.season.start_date.year}-{self.season.end_date.year}")
self.assertNotContains(response, self.season.name)
def test_total_members_kpi_counts_current_season_members(self):
# self.admin_user already has one ACTIVE ClubMembership for self.season
# (ManagementTestBase.setUpTestData) -- a guardian-kind row must not count.
member = Member.objects.create(first_name="Extra", last_name="Player")
ClubMembership.objects.create(club=self.club, member=member, season=self.season, kind=ClubMembership.Kind.MEMBER, status=ClubMembership.StatusChoices.ACTIVE)
guardian = Member.objects.create(first_name="Guardian", last_name="Only")
ClubMembership.objects.create(club=self.club, member=guardian, season=self.season, kind=ClubMembership.Kind.GUARDIAN, status=ClubMembership.StatusChoices.ACTIVE)
self.client.force_login(self.admin_user)
response = self.club_get("home")
self.assertEqual(response.context["member_count"], 2)
def test_total_members_trend_compares_to_the_previous_season(self):
previous_season = Season.objects.create(club=self.club, start_date=self.season.start_date - datetime.timedelta(days=365), end_date=self.season.start_date - datetime.timedelta(days=1))
for i in range(3):
member = Member.objects.create(first_name=f"Last{i}", last_name="Season")
ClubMembership.objects.create(club=self.club, member=member, season=previous_season, kind=ClubMembership.Kind.MEMBER, status=ClubMembership.StatusChoices.ACTIVE)
# self.admin_user is the only current-season member -- 1 now vs 3 last season.
self.client.force_login(self.admin_user)
response = self.club_get("home")
self.assertEqual(response.context["member_count_change"], -2)
self.assertContains(response, "-2")
def test_no_previous_season_shows_no_comparison_available(self):
self.client.force_login(self.admin_user)
response = self.club_get("home")
self.assertIsNone(response.context["member_count_change"])
self.assertContains(response, "No previous season to compare")
def test_missing_documentation_kpi_hidden_without_any_requirements_configured(self):
self.client.force_login(self.admin_user)
response = self.club_get("home")
self.assertFalse(response.context["requirements_configured"])
self.assertNotContains(response, "Missing documentation")
def test_missing_documentation_kpi_counts_open_checklist_items_and_appears_in_needs_attention(self):
OnboardingRequirement.objects.create(club=self.club, name="Medical certificate")
member = Member.objects.create(first_name="Needs", last_name="Docs")
ClubMembership.objects.create(club=self.club, member=member, season=self.season, kind=ClubMembership.Kind.MEMBER, status=ClubMembership.StatusChoices.ACTIVE)
self.client.force_login(self.admin_user)
response = self.club_get("home")
# self.admin_user also has an open item for the same requirement -- 2 total.
self.assertTrue(response.context["requirements_configured"])
self.assertEqual(response.context["missing_documentation_count"], 2)
self.assertContains(response, "Missing documentation")
self.assertContains(response, "members have an open checklist item")
def test_missing_documentation_does_not_appear_once_everyone_is_complete(self):
requirement = OnboardingRequirement.objects.create(club=self.club, name="Medical certificate")
membership = ClubMembership.objects.get(club=self.club, member=self.admin_member, season=self.season)
MemberRequirementStatus.objects.create(membership=membership, requirement=requirement, is_complete=True)
self.client.force_login(self.admin_user)
response = self.club_get("home")
self.assertEqual(response.context["missing_documentation_count"], 0)
self.assertNotContains(response, "have an open checklist item")
def test_the_user_menu_links_to_password_mfa_and_logout(self):
# base.html's own chrome, exercised through any management page.
self.client.force_login(self.admin_user)
response = self.club_get("home")
self.assertContains(response, f'href="{reverse("account_change_password")}"')
self.assertContains(response, f'href="{reverse("mfa_index")}"')
self.assertContains(response, f'href="{reverse("account_logout")}"')
def test_published_news_is_shown_to_everyone(self):
item = News.objects.create(club=self.club, title="Season kickoff", body="Body.")
item.publish()
self.client.force_login(self.make_coach("coach-news-home@example.com"))
response = self.club_get("home")
self.assertContains(response, "Season kickoff")
def test_a_draft_or_scheduled_news_item_is_not_shown_on_the_home_page(self):
draft = News.objects.create(club=self.club, title="Still a draft", body="Body.")
scheduled = News.objects.create(club=self.club, title="Scheduled for later", body="Body.")
scheduled.publish(at=timezone.now() + datetime.timedelta(days=7))
self.client.force_login(self.admin_user)
response = self.club_get("home")
self.assertNotContains(response, draft.title)
self.assertNotContains(response, scheduled.title)
def test_upcoming_events_are_listed_in_order_and_future_only(self):
now = timezone.now()
past = Event.objects.create(club=self.club, kind=Event.EventKind.TRAINING, title="Past training", start=now - datetime.timedelta(days=1))
soon = Event.objects.create(club=self.club, kind=Event.EventKind.TRAINING, title="Sooner training", start=now + datetime.timedelta(days=1))
later = Event.objects.create(club=self.club, kind=Event.EventKind.GAME, title="Later game", start=now + datetime.timedelta(days=5))
self.client.force_login(self.admin_user)
response = self.club_get("home")
body = response.content.decode()
self.assertNotIn(past.title, body)
self.assertLess(body.index(soon.title), body.index(later.title))
def test_loads_fine_with_no_season_and_no_events(self):
# A fresh club, never given a season -- ClubMembership.season is PROTECT, so
# this is a new club rather than deleting self.season out from under setUp's
# own ClubMembership.
empty_club = Club.objects.create(name="Empty FC", slug="empty-fc")
admin_member = Member.objects.create(first_name="Empty", last_name="Admin")
ClubRole.objects.create(club=empty_club, member=admin_member, role=ClubRole.Roles.ADMIN)
admin_user = User.objects.create_user(email="emptyadmin@example.com", password="pw-secret-123")
admin_member.user = admin_user
admin_member.save()
enrol_mfa(admin_user)
self.client.force_login(admin_user)
with override_settings(ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "empty-fc.rosterchief.app", "testserver"]):
response = self.client.get(reverse("management:home"), HTTP_HOST="empty-fc.rosterchief.app")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "cannot take a signup")
self.assertContains(response, "Nothing scheduled.")
class MemberBulkImportTests(ManagementTestBase):
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def make_non_admin_staff(self):
coach_user = User.objects.create_user(email="coach-import@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
team = Team.objects.create(club=self.club, name="U14", short_name="U14")
position = Position.objects.create(club=self.club, name="CoachImport", short_name="CI", staff_position=True)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
return coach_user
def test_template_download_has_expected_headers_and_dropdowns(self):
response = self.club_get("member_import_template")
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], XLSX_CONTENT_TYPE)
workbook = openpyxl.load_workbook(BytesIO(response.content))
sheet = workbook.active
header = [cell.value for cell in sheet[1]]
self.assertEqual(header, TEMPLATE_COLUMNS)
self.assertTrue(sheet.data_validations.dataValidation)
def test_non_admin_can_download_template_but_not_upload(self):
coach_user = self.make_non_admin_staff()
self.client.force_login(coach_user)
template_response = self.club_get("member_import_template")
upload_response = self.club_post("member_import", {"file": make_import_workbook([["Jamie", "Kid", "", "jamie@example.com", "", "", "", "", ""]])})
self.assertEqual(template_response.status_code, 200)
self.assertEqual(upload_response.status_code, 403)
def test_anonymous_is_redirected_to_login_for_both(self):
self.client.logout()
self.assertEqual(self.club_get("member_import_template").status_code, 302)
self.assertEqual(self.club_get("member_import").status_code, 302)
def test_a_clean_row_previews_as_will_create(self):
upload = make_import_workbook([["Jamie", "Kid", "2010-01-01", "jamie.kid@example.com", "+32470111111", "", "", "", ""]])
response = self.club_post("member_import", {"file": upload})
self.assertEqual(response.context["valid_count"], 1)
self.assertEqual(response.context["skipped_count"], 0)
result = response.context["results"][0]
self.assertIsNotNone(result["member"])
self.assertEqual(result["membership_kwargs"]["status"], ClubMembership.StatusChoices.ACTIVE)
self.assertEqual(result["membership_kwargs"]["fee_status"], ClubMembership.FeeStatus.UNPAID)
def test_a_row_missing_a_required_name_is_skipped(self):
upload = make_import_workbook([["", "Noname", "", "", "", "", "", "", ""]])
response = self.club_post("member_import", {"file": upload})
self.assertEqual(response.context["valid_count"], 0)
result = response.context["results"][0]
self.assertIsNone(result["member"])
self.assertTrue(result["errors"])
def test_an_invalid_email_is_skipped(self):
upload = make_import_workbook([["Bad", "Email", "", "not-an-email", "", "", "", "", ""]])
response = self.club_post("member_import", {"file": upload})
result = response.context["results"][0]
self.assertIsNone(result["member"])
def test_an_invalid_status_value_is_skipped(self):
upload = make_import_workbook([["Bad", "Status", "", "bad.status@example.com", "", "", "", "not-a-status", ""]])
response = self.club_post("member_import", {"file": upload})
result = response.context["results"][0]
self.assertIsNone(result["member"])
self.assertTrue(any("status" in error.lower() for error in result["errors"]))
def test_a_duplicate_email_within_the_file_is_flagged_on_the_second_row(self):
upload = make_import_workbook(
[
["First", "Dup", "", "dup@example.com", "", "", "", "", ""],
["Second", "Dup", "", "dup@example.com", "", "", "", "", ""],
]
)
response = self.club_post("member_import", {"file": upload})
results = response.context["results"]
self.assertIsNotNone(results[0]["member"])
self.assertIsNone(results[1]["member"])
self.assertIn("Duplicate email in this file.", results[1]["errors"])
def test_an_email_already_in_the_club_is_skipped(self):
upload = make_import_workbook([["Ada", "Admin", "", self.admin_user.email, "", "", "", "", ""]])
response = self.club_post("member_import", {"file": upload})
result = response.context["results"][0]
self.assertIsNone(result["member"])
self.assertIn("Already a member of this club.", result["errors"])
def test_confirm_creates_only_the_valid_rows_with_defaults(self):
upload = make_import_workbook(
[
["Jamie", "Kid", "2010-01-01", "jamie.kid@example.com", "", "", "LIC-1", "", ""],
["", "Noname", "", "", "", "", "", "", ""],
]
)
self.club_post("member_import", {"file": upload})
response = self.club_post("member_import_confirm", {})
self.assertRedirects(response, reverse("management:member_list"))
member = Member.objects.get(email="jamie.kid@example.com")
membership = ClubMembership.objects.get(club=self.club, member=member)
self.assertEqual(membership.status, ClubMembership.StatusChoices.ACTIVE)
self.assertEqual(membership.fee_status, ClubMembership.FeeStatus.UNPAID)
self.assertEqual(membership.license, "LIC-1")
self.assertFalse(Member.objects.filter(last_name="Noname").exists())
def test_confirm_without_a_prior_upload_creates_nothing(self):
response = self.club_post("member_import_confirm", {})
self.assertRedirects(response, reverse("management:member_import"))
self.assertEqual(Member.objects.filter(last_name="Kid").count(), 0)
def test_confirm_is_admin_only(self):
coach_user = self.make_non_admin_staff()
upload = make_import_workbook([["Jamie", "Kid", "", "jamie.kid2@example.com", "", "", "", "", ""]])
self.club_post("member_import", {"file": upload})
self.client.force_login(coach_user)
response = self.club_post("member_import_confirm", {})
self.assertEqual(response.status_code, 403)
def test_a_family_group_links_a_parent_and_child_and_grants_the_parent_a_login(self):
upload = make_import_workbook(
[
["Taylor", "Doe", "", "taylor.doe@example.com", "", "", "", "", "", "Doe family", "parent"],
["Jamie", "Doe", "2014-03-02", "", "", "", "", "", "", "Doe family", "child"],
]
)
self.club_post("member_import", {"file": upload})
self.club_post("member_import_confirm", {})
parent = Member.objects.get(email="taylor.doe@example.com")
child = Member.objects.get(first_name="Jamie", last_name="Doe")
self.assertIsNotNone(parent.user_id)
self.assertTrue(User.objects.filter(email="taylor.doe@example.com").exists())
self.assertIsNone(child.user_id)
family = Family.objects.get(memberships__member=parent)
self.assertEqual(family, Family.objects.get(memberships__member=child))
self.assertEqual(FamilyMembership.objects.get(family=family, member=parent).role, FamilyMembership.FamilyRole.PARENT)
self.assertEqual(FamilyMembership.objects.get(family=family, member=child).role, FamilyMembership.FamilyRole.CHILD)
def test_membership_kind_guardian_creates_a_guardian_not_a_member(self):
# Columns are positional; membership_kind is the last one.
upload = make_import_workbook(
[
["Taylor", "Doe", "", "taylor.guardian@example.com", "", "", "", "", "", "Doe family", "parent", "guardian"],
["Jamie", "Doe", "2014-03-02", "", "", "", "", "", "", "Doe family", "child", "member"],
]
)
self.club_post("member_import", {"file": upload})
self.club_post("member_import_confirm", {})
parent = Member.objects.get(email="taylor.guardian@example.com")
child = Member.objects.get(first_name="Jamie", last_name="Doe")
self.assertEqual(ClubMembership.objects.get(club=self.club, member=parent).kind, ClubMembership.Kind.GUARDIAN)
self.assertEqual(ClubMembership.objects.get(club=self.club, member=child).kind, ClubMembership.Kind.MEMBER)
def test_a_blank_membership_kind_still_means_member(self):
# Every file written before the column existed carried this implicitly.
upload = make_import_workbook([["Solo", "Blankkind", "", "solo.blank@example.com", "", "", "", "", "", "", "", ""]])
self.club_post("member_import", {"file": upload})
self.club_post("member_import_confirm", {})
member = Member.objects.get(email="solo.blank@example.com")
self.assertEqual(ClubMembership.objects.get(club=self.club, member=member).kind, ClubMembership.Kind.MEMBER)
def test_a_lone_child_row_gets_a_family_of_its_own(self):
# The migration case: children arrive with no parents on file. A family of
# one is what makes "nobody responsible for this child" visible -- see
# members.services.claims.families_awaiting_a_parent.
upload = make_import_workbook([["Jamie", "Lonechild", "2014-03-02", "", "", "", "", "", "", "", "child", ""]])
self.club_post("member_import", {"file": upload})
self.club_post("member_import_confirm", {})
child = Member.objects.get(first_name="Jamie", last_name="Lonechild")
self.assertIn(child, children_awaiting_a_parent(self.club))
def test_a_lone_parent_row_is_still_an_error(self):
# Only `child` is meaningful without a family_group; a parent with nobody
# to be a parent *of* is a mistake in the file.
upload = make_import_workbook([["Odd", "Loneparent", "", "odd.lone@example.com", "", "", "", "", "", "", "parent", ""]])
response = self.club_post("member_import", {"file": upload})
result = response.context["results"][0]
self.assertIsNone(result["member"])
self.assertTrue(any("family_group" in error for error in result["errors"]))
def test_a_child_marked_as_a_guardian_is_an_error(self):
# A child is the member the guardian is attached *to*.
upload = make_import_workbook([["Jamie", "Doe", "2014-03-02", "", "", "", "", "", "", "Doe family", "child", "guardian"]])
response = self.club_post("member_import", {"file": upload})
result = response.context["results"][0]
self.assertIsNone(result["member"])
self.assertTrue(any("child is always a member" in error.lower() for error in result["errors"]))
def test_an_invalid_membership_kind_is_reported(self):
upload = make_import_workbook([["Odd", "Kind", "", "odd.kind@example.com", "", "", "", "", "", "", "", "sponsor"]])
response = self.club_post("member_import", {"file": upload})
result = response.context["results"][0]
self.assertIsNone(result["member"])
self.assertTrue(any("membership_kind" in error for error in result["errors"]))
def test_family_role_without_a_group_is_an_error(self):
upload = make_import_workbook([["Odd", "Row", "", "odd.row@example.com", "", "", "", "", "", "", "parent"]])
response = self.club_post("member_import", {"file": upload})
result = response.context["results"][0]
self.assertIsNone(result["member"])
self.assertTrue(any("family_group" in error.lower() for error in result["errors"]))
def test_family_group_without_a_role_is_an_error(self):
upload = make_import_workbook([["Odd", "Row", "", "odd.row2@example.com", "", "", "", "", "", "Odd family", ""]])
response = self.club_post("member_import", {"file": upload})
result = response.context["results"][0]
self.assertIsNone(result["member"])
self.assertTrue(any("family_role" in error.lower() for error in result["errors"]))
def test_a_standalone_row_is_not_linked_to_any_family(self):
upload = make_import_workbook([["Solo", "Standalone", "", "solo@example.com", "", "", "", "", "", "", ""]])
self.club_post("member_import", {"file": upload})
self.club_post("member_import_confirm", {})
member = Member.objects.get(email="solo@example.com")
self.assertFalse(FamilyMembership.objects.filter(member=member).exists())
class NewsManagementTests(ManagementTestBase):
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st")
# The three actors this class draws its lines between: a coach with a
# management position (may draft, may not publish), a plain staff member
# (may not touch news at all), and an EDITOR (may publish and may keep
# editing afterwards).
cls.coach_manager = User.objects.create_user(email="coach-news@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=cls.coach_manager, first_name="Cara", last_name="Coach")
coach_position = Position.objects.create(club=cls.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=cls.team, member=coach_member, season=cls.season, position=coach_position)
cls.plain_staff = User.objects.create_user(email="physio-news@example.com", password="pw-secret-123")
staff_member = Member.objects.create(user=cls.plain_staff, first_name="Pat", last_name="Physio")
staff_position = Position.objects.create(club=cls.club, name="Physio", short_name="PH", staff_position=True, management_position=False)
StaffAssignment.objects.create(team=cls.team, member=staff_member, season=cls.season, position=staff_position)
cls.editor = User.objects.create_user(email="editor-news@example.com", password="pw-secret-123")
editor_member = Member.objects.create(user=cls.editor, first_name="Eve", last_name="Editor")
ClubMembership.objects.create(club=cls.club, member=editor_member, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE)
ClubRole.objects.filter(club=cls.club, member=editor_member).update(role=ClubRole.Roles.EDITOR)
enrol_mfa(cls.editor) # ClubRole ADMIN/EDITOR requires a second factor; StaffAssignment-only doesn't.
def test_list_is_scoped_to_the_club(self):
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
News.objects.create(club=other_club, title="Rival news", body="Body.")
self.client.force_login(self.admin_user)
response = self.club_get("news_list")
self.assertNotContains(response, "Rival news")
def test_the_status_filter_chips_count_each_bucket(self):
News.objects.create(club=self.club, title="Draft one", body="Body.")
News.objects.create(club=self.club, title="Draft two", body="Body.")
News.objects.create(club=self.club, title="Live", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now())
News.objects.create(club=self.club, title="Upcoming", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now() + datetime.timedelta(days=3))
self.client.force_login(self.admin_user)
response = self.club_get("news_list")
self.assertEqual(response.context["counts"], {"all": 4, "draft": 2, "scheduled": 1, "published": 1})
def test_the_draft_filter_chip_narrows_the_list_to_drafts(self):
News.objects.create(club=self.club, title="A draft", body="Body.")
News.objects.create(club=self.club, title="Already live", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now())
self.client.force_login(self.admin_user)
response = self.club_get("news_list", params={"status": "draft"})
self.assertContains(response, "A draft")
self.assertNotContains(response, "Already live")
def test_selecting_an_item_previews_it_on_the_right(self):
News.objects.create(club=self.club, title="First post", body="First body.")
second = News.objects.create(club=self.club, title="Second post", body="Second body.")
self.client.force_login(self.admin_user)
response = self.club_get("news_list", params={"selected": str(second.pk)})
self.assertEqual(response.context["news_item"], second)
self.assertContains(response, "Second body.")
self.assertNotContains(response, "First body.")
def test_the_first_item_previews_by_default_when_none_is_selected(self):
# News.Meta.ordering is "-created", so the most recently created row
# (second) sorts first and is what should preview with no ?selected=.
News.objects.create(club=self.club, title="First post", body="First body.")
second = News.objects.create(club=self.club, title="Second post", body="Second body.")
self.client.force_login(self.admin_user)
response = self.club_get("news_list")
self.assertEqual(response.context["news_item"], second)
def test_a_coach_manager_can_create_a_draft(self):
self.client.force_login(self.coach_manager)
response = self.club_post("news_create", {"title": "Season Kickoff", "body": "Big news.", "visibility": News.Visibility.INTERNAL, "teams": [str(self.team.pk)]})
item = News.objects.get(club=self.club, title="Season Kickoff")
self.assertRedirects(response, reverse("management:news_detail", args=[item.pk]))
self.assertEqual(item.status, News.Status.DRAFT)
def test_an_english_translation_can_be_added_alongside_the_original(self):
self.client.force_login(self.coach_manager)
self.club_post(
"news_create",
{"title": "Seizoensstart", "title_en": "Season kickoff", "body": "We beginnen het seizoen.", "body_en": "We're starting the season.", "visibility": News.Visibility.INTERNAL, "teams": [str(self.team.pk)]},
)
item = News.objects.get(club=self.club, title="Seizoensstart")
self.assertEqual(item.title_en, "Season kickoff")
self.assertEqual(item.body_en, "We're starting the season.")
def test_the_english_translation_is_optional(self):
self.client.force_login(self.coach_manager)
response = self.club_post("news_create", {"title": "Seizoensstart", "body": "We beginnen het seizoen.", "visibility": News.Visibility.INTERNAL, "teams": [str(self.team.pk)]})
item = News.objects.get(club=self.club, title="Seizoensstart")
self.assertRedirects(response, reverse("management:news_detail", args=[item.pk]))
self.assertEqual(item.title_en, "")
def test_detail_page_shows_the_english_translation_when_set(self):
item = News.objects.create(club=self.club, title="Seizoensstart", body="We beginnen het seizoen.", title_en="Season kickoff", body_en="We're starting the season.")
self.client.force_login(self.admin_user)
response = self.club_get("news_detail", item.pk)
self.assertContains(response, "Season kickoff")
self.assertContains(response, "We&#x27;re starting the season.")
def test_detail_page_notes_a_missing_english_translation(self):
# The NL/EN toggle is always present now (not conditional on a
# translation existing) -- untranslated items instead get a fallback
# note, and the EN pane falls back to showing the Dutch content
# (News.effective_title_en/effective_body_en) rather than sitting empty.
item = News.objects.create(club=self.club, title="Seizoensstart", body="We beginnen het seizoen.")
self.client.force_login(self.admin_user)
response = self.club_get("news_detail", item.pk)
self.assertContains(response, "No English translation yet")
self.assertContains(response, 'data-lang-content="en"')
def test_plain_staff_cannot_create_news(self):
self.client.force_login(self.plain_staff)
response = self.club_post("news_create", {"title": "Not allowed", "body": "Body.", "visibility": News.Visibility.INTERNAL})
self.assertEqual(response.status_code, 403)
self.assertFalse(News.objects.filter(club=self.club, title="Not allowed").exists())
def test_coach_manager_cannot_publish(self):
item = News.objects.create(club=self.club, title="Draft item", body="Body.")
self.client.force_login(self.coach_manager)
response = self.club_post("news_publish", {"published_at": "2026-08-10T10:00"}, item.pk)
self.assertEqual(response.status_code, 403)
item.refresh_from_db()
self.assertEqual(item.status, News.Status.DRAFT)
def test_editor_can_publish(self):
item = News.objects.create(club=self.club, title="Draft item", body="Body.")
self.client.force_login(self.editor)
self.club_post("news_publish", {"published_at": "2026-08-10T10:00"}, item.pk)
item.refresh_from_db()
self.assertEqual(item.status, News.Status.PUBLISHED)
def test_publishing_with_a_future_date_leaves_it_scheduled(self):
item = News.objects.create(club=self.club, title="Draft item", body="Body.")
self.client.force_login(self.editor)
future = timezone.now() + datetime.timedelta(days=7)
self.club_post("news_publish", {"published_at": future.strftime("%Y-%m-%dT%H:%M")}, item.pk)
item.refresh_from_db()
self.assertTrue(item.is_scheduled)
def test_publishing_with_now_makes_it_live_immediately(self):
item = News.objects.create(club=self.club, title="Draft item", body="Body.")
self.client.force_login(self.editor)
self.club_post("news_publish", {"published_at": timezone.now().strftime("%Y-%m-%dT%H:%M")}, item.pk)
item.refresh_from_db()
self.assertFalse(item.is_scheduled)
def test_notify_members_checkbox_emails_the_audience(self):
member = Member.objects.create(first_name="Jamie", last_name="Doe", email="jamie@example.com")
ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
User.objects.create_user(email="jamie@example.com", password="pw-secret-123")
member.user = User.objects.get(email="jamie@example.com")
member.save(update_fields=["user"])
item = News.objects.create(club=self.club, title="Draft item", body="Body.")
self.client.force_login(self.editor)
self.club_post("news_publish", {"published_at": timezone.now().strftime("%Y-%m-%dT%H:%M"), "notify_members": "on"}, item.pk)
# Not asserting an exact outbox size: the base fixture's own staff
# members (self.editor etc.) may also be active MEMBER-kind club
# members in this same season, so they legitimately get one too.
sent_to = [address for message in mail.outbox for address in message.to]
self.assertIn("jamie@example.com", sent_to)
def test_leaving_notify_members_unchecked_sends_nothing(self):
member = Member.objects.create(first_name="Jamie", last_name="Doe", email="jamie@example.com")
ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
item = News.objects.create(club=self.club, title="Draft item", body="Body.")
self.client.force_login(self.editor)
self.club_post("news_publish", {"published_at": timezone.now().strftime("%Y-%m-%dT%H:%M")}, item.pk)
self.assertEqual(len(mail.outbox), 0)
def test_notify_members_checkbox_creates_an_in_app_notification_and_pushes_it(self):
# End-to-end: the publish-time toggle all the way through to the mobile
# app's own inbox and its push channel -- not just the email side
# test_notify_members_checkbox_emails_the_audience above already covers.
from mobile.models import PushSubscription
member_user = User.objects.create_user(email="jamie@example.com", password="pw-secret-123")
member = Member.objects.create(first_name="Jamie", last_name="Doe", email="jamie@example.com", user=member_user)
ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
PushSubscription.objects.create(club=self.club, member=member, endpoint="https://push.example.com/jamie", p256dh="key1", auth="key2")
item = News.objects.create(club=self.club, title="Signed: New Player", body="Body.")
self.client.force_login(self.editor)
with (
override_settings(VAPID_PRIVATE_KEY="test-private-key", VAPID_ADMIN_EMAIL="admin@example.com"),
mock.patch("mobile.services.push.webpush") as webpush_mock,
):
self.club_post("news_publish", {"published_at": timezone.now().strftime("%Y-%m-%dT%H:%M"), "notify_members": "on"}, item.pk)
notification = Notification.objects.get(member=member, title="Signed: New Player")
self.assertIsNotNone(notification.sent_at)
webpush_mock.assert_called_once()
self.assertEqual(json.loads(webpush_mock.call_args.kwargs["data"])["title"], "Signed: New Player")
# And it actually shows up in the member's own mobile inbox, not just the DB row.
self.client.logout()
self.client.force_login(member_user)
response = self.client.get(reverse("mobile:notifications"), HTTP_HOST="ajax-united.rosterchief.app")
self.assertContains(response, "Signed: New Player")
self.assertEqual(response.context["unread_notification_count"], 1)
def test_a_coach_manager_can_submit_a_draft_for_review(self):
item = News.objects.create(club=self.club, title="Draft item", body="Body.")
self.client.force_login(self.coach_manager)
self.club_post("news_submit_for_review", {}, item.pk)
item.refresh_from_db()
self.assertEqual(item.status, News.Status.PENDING_REVIEW)
def test_submitting_for_review_notifies_editors_in_app_only(self):
editor_member = Member.objects.get(user=self.editor)
item = News.objects.create(club=self.club, title="Draft item", body="Body.")
self.client.force_login(self.coach_manager)
self.club_post("news_submit_for_review", {}, item.pk)
self.assertTrue(Notification.objects.filter(member=editor_member, title__contains="Draft item").exists())
self.assertEqual(len(mail.outbox), 0)
def test_plain_staff_cannot_submit_for_review(self):
item = News.objects.create(club=self.club, title="Draft item", body="Body.")
self.client.force_login(self.plain_staff)
response = self.club_post("news_submit_for_review", {}, item.pk)
self.assertEqual(response.status_code, 403)
item.refresh_from_db()
self.assertEqual(item.status, News.Status.DRAFT)
def test_an_editor_still_publishes_directly_without_a_review_step(self):
# The button _news_preview.html shows for can_publish is Publish, never
# Send for review -- editors skip the queue entirely.
item = News.objects.create(club=self.club, title="Draft item", body="Body.")
self.client.force_login(self.editor)
response = self.club_get("news_detail", item.pk)
self.assertContains(response, reverse("management:news_publish", args=[item.pk]))
self.assertNotContains(response, reverse("management:news_submit_for_review", args=[item.pk]))
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 <input type="lazyselect"> (i.e. a
# broken text box), not a <select>.
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, "<select")
def test_plain_staff_cannot_view_the_location_list(self):
self.client.force_login(self.plain_staff)
self.assertEqual(self.club_get("location_list").status_code, 403)
def test_a_management_position_can_create_a_location(self):
self.client.force_login(self.coach_manager)
response = self.club_post("location_create", {"name": "New Field", "address": "2 St", "city": "Town", "zip_code": "1000", "country": "BE"})
self.assertRedirects(response, reverse("management:location_list"))
self.assertTrue(Location.objects.filter(club=self.club, name="New Field").exists())
def test_plain_staff_cannot_create_a_location(self):
self.client.force_login(self.plain_staff)
response = self.club_post("location_create", {"name": "New Field", "address": "2 St", "city": "Town", "zip_code": "1000", "country": "BE"})
self.assertEqual(response.status_code, 403)
self.assertFalse(Location.objects.filter(club=self.club, name="New Field").exists())
def test_a_management_position_can_edit_a_location(self):
location = Location.objects.create(club=self.club, name="Old name", address="1 St", city="Town", zip_code="1000", country="BE")
self.client.force_login(self.coach_manager)
self.club_post("location_update", {"name": "New name", "address": "1 St", "city": "Town", "zip_code": "1000", "country": "BE"}, location.pk)
location.refresh_from_db()
self.assertEqual(location.name, "New name")
def test_a_management_position_can_delete_a_location(self):
location = Location.objects.create(club=self.club, name="Doomed", address="1 St", city="Town", zip_code="1000", country="BE")
self.client.force_login(self.coach_manager)
response = self.club_post("location_delete", {}, location.pk)
self.assertRedirects(response, reverse("management:location_list"))
self.assertFalse(Location.objects.filter(pk=location.pk).exists())
def test_deleting_a_location_nulls_it_on_events_instead_of_erroring(self):
location = Location.objects.create(club=self.club, name="Doomed", address="1 St", city="Town", zip_code="1000", country="BE")
event = Event.objects.create(club=self.club, title="Match", start=timezone.now() + datetime.timedelta(days=1), location=location)
self.client.force_login(self.admin_user)
self.club_post("location_delete", {}, location.pk)
event.refresh_from_db()
self.assertIsNone(event.location)
def test_an_admin_has_full_rights_without_any_staff_assignment(self):
self.client.force_login(self.admin_user)
self.assertEqual(self.club_get("location_list").status_code, 200)
response = self.club_post("location_create", {"name": "Admin Field", "address": "3 St", "city": "Town", "zip_code": "1000", "country": "BE"})
self.assertRedirects(response, reverse("management:location_list"))
# --- Opponents -----------------------------------------------------------
def test_a_management_position_can_view_the_opponent_list(self):
Opponent.objects.create(club=self.club, name="Rivals FC")
self.client.force_login(self.coach_manager)
response = self.club_get("opponent_list")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Rivals FC")
def test_plain_staff_cannot_view_the_opponent_list(self):
self.client.force_login(self.plain_staff)
self.assertEqual(self.club_get("opponent_list").status_code, 403)
def test_a_management_position_can_create_an_opponent_with_a_logo(self):
self.client.force_login(self.coach_manager)
# Opponent.logo is a real ImageField (unlike NewsPhoto.image, set outside any
# ModelForm) -- Django's ImageField.clean() runs it through Pillow, so this
# needs to actually decode as an image, not just carry an image/png header.
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"
logo = SimpleUploadedFile("logo.png", one_pixel_png, content_type="image/png")
response = self.club_post("opponent_create", {"name": "Rivals FC", "logo": logo})
self.assertRedirects(response, reverse("management:opponent_list"))
opponent = Opponent.objects.get(club=self.club, name="Rivals FC")
self.assertTrue(opponent.logo)
def test_a_management_position_can_delete_an_opponent(self):
opponent = Opponent.objects.create(club=self.club, name="Doomed FC")
self.client.force_login(self.coach_manager)
response = self.club_post("opponent_delete", {}, opponent.pk)
self.assertRedirects(response, reverse("management:opponent_list"))
self.assertFalse(Opponent.objects.filter(pk=opponent.pk).exists())
def test_plain_staff_cannot_delete_an_opponent(self):
opponent = Opponent.objects.create(club=self.club, name="Safe FC")
self.client.force_login(self.plain_staff)
response = self.club_post("opponent_delete", {}, opponent.pk)
self.assertEqual(response.status_code, 403)
self.assertTrue(Opponent.objects.filter(pk=opponent.pk).exists())
class SponsorManagementTests(ManagementTestBase):
"""Full CRUD for Sponsor -- admin-only, unlike Location/Opponent which any
management position can maintain (see club.mixins.ClubAdminRequiredMixin
and management.views.Sponsor*View)."""
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st")
# Neither of these may touch sponsors -- a management position is enough
# for Location/Opponent but not here, and plain staff never qualifies.
cls.coach_manager = User.objects.create_user(email="coach-sponsor@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-sponsor@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)
def sponsor_data(self, **overrides):
data = {"name": "Acme Corp", "url": "https://acme.example.com", "start_date": "2026-01-01", "end_date": ""}
data.update(overrides)
return data
def test_an_admin_can_view_the_sponsor_list(self):
Sponsor.objects.create(club=self.club, name="Acme Corp", start_date=datetime.date(2026, 1, 1))
self.client.force_login(self.admin_user)
response = self.club_get("sponsor_list")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Acme Corp")
def test_a_management_position_cannot_view_the_sponsor_list(self):
self.client.force_login(self.coach_manager)
self.assertEqual(self.club_get("sponsor_list").status_code, 403)
def test_plain_staff_cannot_view_the_sponsor_list(self):
self.client.force_login(self.plain_staff)
self.assertEqual(self.club_get("sponsor_list").status_code, 403)
def test_an_admin_can_create_a_sponsor_with_a_logo(self):
self.client.force_login(self.admin_user)
response = self.club_post("sponsor_create", self.sponsor_data(logo=make_image_file()))
self.assertRedirects(response, reverse("management:sponsor_list"))
sponsor = Sponsor.objects.get(club=self.club, name="Acme Corp")
self.assertTrue(sponsor.logo)
def test_a_management_position_cannot_create_a_sponsor(self):
self.client.force_login(self.coach_manager)
response = self.club_post("sponsor_create", self.sponsor_data())
self.assertEqual(response.status_code, 403)
self.assertFalse(Sponsor.objects.filter(club=self.club, name="Acme Corp").exists())
def test_an_end_date_before_the_start_date_is_a_form_error_not_a_500(self):
self.client.force_login(self.admin_user)
response = self.club_post("sponsor_create", self.sponsor_data(start_date="2026-06-01", end_date="2026-01-01"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "before the start date")
self.assertFalse(Sponsor.objects.filter(club=self.club).exists())
def test_an_admin_can_edit_a_sponsor(self):
sponsor = Sponsor.objects.create(club=self.club, name="Acme Corp", start_date=datetime.date(2026, 1, 1))
self.client.force_login(self.admin_user)
response = self.club_post("sponsor_update", self.sponsor_data(name="Acme Corp Renamed"), sponsor.pk)
self.assertRedirects(response, reverse("management:sponsor_list"))
sponsor.refresh_from_db()
self.assertEqual(sponsor.name, "Acme Corp Renamed")
def test_plain_staff_cannot_edit_a_sponsor(self):
sponsor = Sponsor.objects.create(club=self.club, name="Acme Corp", start_date=datetime.date(2026, 1, 1))
self.client.force_login(self.plain_staff)
response = self.club_post("sponsor_update", self.sponsor_data(), sponsor.pk)
self.assertEqual(response.status_code, 403)
def test_an_admin_can_delete_a_sponsor(self):
sponsor = Sponsor.objects.create(club=self.club, name="Doomed Corp", start_date=datetime.date(2026, 1, 1))
self.client.force_login(self.admin_user)
response = self.club_post("sponsor_delete", {}, sponsor.pk)
self.assertRedirects(response, reverse("management:sponsor_list"))
self.assertFalse(Sponsor.objects.filter(pk=sponsor.pk).exists())
def test_plain_staff_cannot_delete_a_sponsor(self):
sponsor = Sponsor.objects.create(club=self.club, name="Safe Corp", start_date=datetime.date(2026, 1, 1))
self.client.force_login(self.plain_staff)
response = self.club_post("sponsor_delete", {}, sponsor.pk)
self.assertEqual(response.status_code, 403)
self.assertTrue(Sponsor.objects.filter(pk=sponsor.pk).exists())
def test_nav_only_shows_sponsors_for_an_admin(self):
# Sponsors lives under Finance's sub-nav now, which only expands on a
# Finance-section page -- see ActiveNavHighlightTests's sibling comments.
# Finance itself is admin-only, so a coach never reaches a page that would
# expand it in the first place -- club_get raises PermissionDenied for them.
self.client.force_login(self.admin_user)
self.assertContains(self.club_get("membership_list"), "Sponsors")
self.client.force_login(self.coach_manager)
self.assertNotContains(self.club_get("home"), "Sponsors")
class ClubSettingsPreviewTests(ManagementTestBase):
"""The D9-alike live preview on the Club identity page -- see
management/templates/management/club_settings.html. The Identity tab's
own mock (phone/website, JS colour-bound) is separate from the Email/PDF
tabs (real templates, server-rendered) -- see ClubSettingsDocumentTabTests
for those."""
def setUp(self):
self.client.force_login(self.admin_user)
def test_the_page_tabs_and_colour_swatches_render(self):
response = self.club_get("club_settings")
self.assertContains(response, "rounded-full bg-steel p-1")
self.assertContains(response, 'id="primary_color_swatch"')
self.assertContains(response, 'id="secondary_color_swatch"')
def test_the_mobile_app_and_website_previews_render(self):
response = self.club_get("club_settings")
self.assertContains(response, "preview-phone-lg")
self.assertContains(response, "preview-website")
self.assertContains(response, "Where the brand shows up")
def test_the_website_field_is_editable(self):
response = self.club_get("club_settings")
self.assertContains(response, 'name="website"')
def test_identity_email_and_pdf_are_the_three_page_tabs(self):
response = self.club_get("club_settings")
self.assertContains(response, 'data-page-tab="identity"')
self.assertContains(response, 'data-page-tab="email"')
self.assertContains(response, 'data-page-tab="pdf"')
def test_saving_still_updates_the_club(self):
response = self.club_post(
"club_settings",
{"name": "Ajax United", "legal_name": "", "contact_email": "", "website": "https://ajax-united.example", "primary_color": "#123456", "secondary_color": "#654321"},
)
self.assertRedirects(response, reverse("management:club_settings"))
self.club.refresh_from_db()
self.assertEqual(self.club.primary_color, "#123456")
self.assertEqual(self.club.secondary_color, "#654321")
self.assertEqual(self.club.website, "https://ajax-united.example")
def test_the_legal_address_can_be_set(self):
response = self.club_post(
"club_settings",
{
"name": "Ajax United",
"legal_name": "",
"legal_address": "Registered Office 5",
"legal_zip_code": "9000",
"legal_city": "Ghent",
"contact_email": "",
"website": "",
"primary_color": "",
"secondary_color": "",
},
)
self.assertRedirects(response, reverse("management:club_settings"))
self.club.refresh_from_db()
self.assertEqual(self.club.legal_address, "Registered Office 5")
self.assertEqual(self.club.legal_zip_code, "9000")
self.assertEqual(self.club.legal_city, "Ghent")
def test_the_event_background_can_be_uploaded(self):
response = self.club_post(
"club_settings",
{
"name": "Ajax United",
"legal_name": "",
"contact_email": "",
"website": "",
"primary_color": "",
"secondary_color": "",
"event_background": make_image_file("event-bg.png"),
},
)
self.assertRedirects(response, reverse("management:club_settings"))
self.club.refresh_from_db()
self.assertTrue(self.club.event_background)
def test_the_event_background_field_is_editable(self):
response = self.club_get("club_settings")
self.assertContains(response, 'name="event_background"')
class ClubSettingsDocumentTabTests(ManagementTestBase):
"""The Email and PDF tabs on the Club identity page -- every branded
email (management.email_previews) and generated PDF (management.pdf_previews)
this club can send, rendered against sample data. See
EmailPreviewRenderViewTests/PDFPreviewRenderViewTests for the actual
per-document render each card's iframe loads."""
def setUp(self):
self.client.force_login(self.admin_user)
def test_renders_every_registered_email_and_pdf_preview(self):
response = self.club_get("club_settings")
self.assertEqual(response.status_code, 200)
for preview in EMAIL_PREVIEWS:
self.assertContains(response, preview.label)
for preview in PDF_PREVIEWS:
self.assertContains(response, preview.label)
def test_each_card_links_to_its_own_render_view(self):
response = self.club_get("club_settings")
for preview in EMAIL_PREVIEWS:
self.assertContains(response, reverse("management:email_preview_render", args=[preview.key]))
for preview in PDF_PREVIEWS:
self.assertContains(response, reverse("management:pdf_preview_render", args=[preview.key]))
def test_the_subject_and_plain_text_body_render_for_emails(self):
response = self.club_get("club_settings")
self.assertContains(response, "Subject:")
self.assertContains(response, "DUE-2026-00042")
def test_pdf_cards_have_no_plain_text_toggle(self):
# PDFs have no plain-text body to switch to -- unlike an email card,
# a PDF card is just the one iframe. Exactly one toggle button per
# email preview, none contributed by the PDF ones.
response = self.club_get("club_settings")
self.assertEqual(response.content.decode().count('data-view-btn="text"'), len(EMAIL_PREVIEWS))
def test_claim_approved_was_dropped(self):
# Not directly used right now -- see management/email_previews.py.
response = self.club_get("club_settings")
self.assertNotContains(response, "Parent claim approved")
def test_no_real_data_is_needed(self):
# The whole point: previews render with zero DuesInvoice/Event rows
# in the database.
self.assertFalse(DuesInvoice.objects.exists())
self.assertFalse(Event.objects.filter(club=self.club).exists())
response = self.club_get("club_settings")
self.assertEqual(response.status_code, 200)
def test_non_admin_gets_403(self):
coach_user = User.objects.create_user(email="coach-doc-preview@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="U18", short_name="U18")
position = Position.objects.create(club=self.club, name="Coach14", short_name="C14", 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("club_settings")
self.assertEqual(response.status_code, 403)
class EmailPreviewRenderViewTests(ManagementTestBase):
"""The actual per-email HTML document each card's iframe loads via `src` --
see EmailPreviewRenderView's own docstring for why this is a same-origin
sub-request rather than a srcdoc="..." attribute."""
def setUp(self):
self.client.force_login(self.admin_user)
def test_renders_the_full_html_document(self):
response = self.client.get(reverse("management:email_preview_render", args=["dues_invoice"]), HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "text/html; charset=utf-8")
self.assertContains(response, "<!DOCTYPE html>")
self.assertContains(response, "DUE-2026-00042")
def test_uses_the_clubs_own_branding(self):
self.club.name = "Ajax United"
self.club.secondary_color = "#123456"
self.club.save(update_fields=["name", "secondary_color"])
response = self.client.get(reverse("management:email_preview_render", args=["dues_invoice"]), HTTP_HOST="ajax-united.rosterchief.app")
self.assertContains(response, "Ajax United")
self.assertContains(response, "#123456")
def test_the_password_reset_preview_renders(self):
response = self.client.get(reverse("management:email_preview_render", args=["password_reset"]), HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Reset your password")
def test_allows_same_origin_framing(self):
# The site-wide default (SecurityMiddleware, no X_FRAME_OPTIONS override
# in settings.py) is DENY -- this response overrides that specifically,
# since the Email tab's own iframe needs to load it.
response = self.client.get(reverse("management:email_preview_render", args=["dues_invoice"]), HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response["X-Frame-Options"], "SAMEORIGIN")
def test_an_unknown_key_404s(self):
response = self.client.get(reverse("management:email_preview_render", args=["not-a-real-key"]), HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.status_code, 404)
def test_non_admin_gets_403(self):
coach_user = User.objects.create_user(email="coach-email-render@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="U19", short_name="U19")
position = Position.objects.create(club=self.club, name="Coach15", short_name="C15", staff_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:email_preview_render", args=["dues_invoice"]), HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.status_code, 403)
class PDFPreviewRenderViewTests(ManagementTestBase):
"""The actual per-PDF HTML document each PDF card's iframe loads via
`src` -- the same underlying HTML WeasyPrint would turn into a PDF, shown
directly rather than round-tripping through WeasyPrint for a preview."""
def setUp(self):
self.client.force_login(self.admin_user)
def test_renders_the_dues_invoice_pdf_html(self):
response = self.client.get(reverse("management:pdf_preview_render", args=["dues_invoice"]), HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<!DOCTYPE html>")
self.assertContains(response, "DUE-2026-00042")
def test_renders_the_referee_form_pdf_html(self):
response = self.client.get(reverse("management:pdf_preview_render", args=["referee_form"]), HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Referee payment form")
self.assertContains(response, "Jamie Doe")
def test_allows_same_origin_framing(self):
response = self.client.get(reverse("management:pdf_preview_render", args=["dues_invoice"]), HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response["X-Frame-Options"], "SAMEORIGIN")
def test_an_unknown_key_404s(self):
response = self.client.get(reverse("management:pdf_preview_render", args=["not-a-real-key"]), HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.status_code, 404)
def test_non_admin_gets_403(self):
coach_user = User.objects.create_user(email="coach-pdf-render@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="U20", short_name="U20")
position = Position.objects.create(club=self.club, name="Coach16", short_name="C16", staff_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:pdf_preview_render", args=["dues_invoice"]), HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.status_code, 403)
class BillingEndingBannerTests(ManagementTestBase):
"""The club dashboard's "billing is about to stop" warning -- see
management.views.HomeView and management/templates/management/home.html.
Admin-only, only within the plan's own renewal lead of the period ending, and only when
nothing is owed -- an unpaid club gets the louder billing notice instead."""
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.plan = Plan.objects.create(name="Standard")
PlanPrice.objects.create(plan=cls.plan, active_from=cls.season.start_date - datetime.timedelta(days=1200), amount=Decimal("500.00"))
def settle(self):
"""The "period ends soon" notice only shows when nothing is owed."""
record_payment(self.club.dues.first(), Decimal("500.00"))
def test_admin_sees_the_banner_when_the_period_ends_soon(self):
subscribe(self.club, self.plan, start=timezone.localdate() - datetime.timedelta(days=350), auto_renew=False)
self.settle()
self.client.force_login(self.admin_user)
response = self.club_get("home")
self.assertContains(response, "billing is about to stop")
def test_admin_does_not_see_the_banner_when_the_period_is_not_ending_soon(self):
subscribe(self.club, self.plan, start=timezone.localdate())
self.settle()
self.client.force_login(self.admin_user)
response = self.club_get("home")
self.assertNotContains(response, "billing is about to stop")
def test_a_non_admin_manager_never_sees_the_banner(self):
subscribe(self.club, self.plan, start=timezone.localdate() - datetime.timedelta(days=350), auto_renew=False)
team = Team.objects.create(club=self.club, name="First Team", short_name="1st")
position = Position.objects.create(club=self.club, name="Coach", short_name="C", staff_position=True, management_position=True)
coach_user = User.objects.create_user(email="coach-banner@example.com", 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=self.season, position=position)
self.settle()
self.client.force_login(coach_user)
response = self.club_get("home")
self.assertNotContains(response, "billing is about to stop")
def test_a_club_with_no_subscription_shows_no_banner(self):
self.client.force_login(self.admin_user)
response = self.club_get("home")
self.assertNotContains(response, "billing is about to stop")
self.assertNotContains(response, "will renew automatically")
def test_an_auto_renewing_club_gets_a_reassuring_banner_instead(self):
subscribe(self.club, self.plan, start=timezone.localdate() - datetime.timedelta(days=350), auto_renew=True)
self.settle()
self.client.force_login(self.admin_user)
response = self.club_get("home")
self.assertContains(response, "will renew automatically")
self.assertNotContains(response, "billing is about to stop")
class RecurrenceUiTests(TestCase):
"""management.recurrence_ui's friendly builder <-> 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_notifies_the_invited_roster(self):
position = Position.objects.create(club=self.club, name="Forward", short_name="F")
player = Member.objects.create(first_name="Poul", last_name="Player")
TeamMembership.objects.create(team=self.own_team, member=player, season=self.season, position=position)
self.client.force_login(self.own_team_coach)
self.club_post("event_create", self.event_data())
event = Event.objects.get(title="Training")
notification = Notification.objects.get(member=player, content_type__model="event", object_id=str(event.pk))
self.assertEqual(notification.title, "Training")
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_calendar_page_highlights_the_events_sidebar_item(self):
# Regression: EventListView's own "calendar_nav" context (prev/next/today
# for the Week/Month view) used to be keyed "nav", shadowing
# management.context_processors.active_nav_section's identically-named
# "nav" -- the value _nav_items.html reads to mark the Events sub-item
# active. That silently broke the highlight on this page alone.
self.client.force_login(self.admin_user)
response = self.club_get("event_list")
self.assertContains(response, '<a class="nav-item active" href="/manage/events/">')
self.assertContains(response, '<a class="nav-subitem active" href="/manage/events/">')
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, '<tr data-status="present">')
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["document_address"], 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()), "document_address": 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()), "document_address": 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": [], "document_address": 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": [], "document_address": 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": [], "document_address": 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]))
def test_shows_a_pending_self_service_invite(self):
# make_game()'s own event.teams.add(...) already triggers
# events.services.referees.sync_referee_invites via events/signals.py --
# self.referee (eligible for self.team) gets invited automatically.
game = self.make_game()
self.client.force_login(self.admin_user)
response = self.club_get("referee_management")
self.assertTrue(RefereeSignup.objects.filter(event=game, member=self.referee, status=RefereeSignup.Status.INVITED).exists())
self.assertContains(response, "Invited, no response yet")
self.assertContains(response, str(self.referee))
def test_no_pending_invite_shown_once_the_game_is_full(self):
game = self.make_game(max_referees=1)
EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member)
other_referee = Member.objects.create(first_name="Other", last_name="Ref")
RefereeSignup.objects.create(event=game, member=other_referee, status=RefereeSignup.Status.INVITED)
self.client.force_login(self.admin_user)
response = self.club_get("referee_management")
self.assertNotContains(response, "Invited, no response yet")
def test_self_signed_up_referee_is_flagged_distinctly_from_an_admin_assignment(self):
game = self.make_game()
EventReferee.objects.create(event=game, member=self.referee, assigned_by=None)
self.client.force_login(self.admin_user)
response = self.club_get("referee_management")
self.assertContains(response, "Self sign-up")
def test_workload_stats_count_games_refereed_this_season(self):
game_one = self.make_game()
game_two = self.make_game(title="Second game", start=timezone.now() + datetime.timedelta(days=2))
EventReferee.objects.create(event=game_one, member=self.referee, assigned_by=self.admin_member, fee=Decimal("20.00"))
EventReferee.objects.create(event=game_two, member=self.referee, assigned_by=self.admin_member, fee=Decimal("15.00"))
self.client.force_login(self.admin_user)
response = self.club_get("referee_management")
self.assertEqual(response.context["kpi_active_referees"], 1)
self.assertEqual(response.context["kpi_total_assignments"], 2)
stats = response.context["referee_stats"]
self.assertEqual(stats[0]["games"], 2)
self.assertEqual(stats[0]["total_fees"], Decimal("35.00"))
self.assertContains(response, "Games refereed per referee")
def test_workload_stats_exclude_external_referees(self):
game = self.make_game()
add_external_referee(game, "Guest Ref", assigned_by=self.admin_member)
self.client.force_login(self.admin_user)
response = self.club_get("referee_management")
self.assertEqual(response.context["kpi_active_referees"], 0)
def test_workload_stats_empty_state(self):
self.client.force_login(self.admin_user)
response = self.club_get("referee_management")
self.assertEqual(response.context["referee_stats"], [])
self.assertContains(response, "No referee assignments recorded yet this season.")
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")
class EvaluationsComingSoonViewTests(ManagementTestBase):
"""Placeholder nav entry/page for player evaluations (design: ARCHITECTURE.md
§5.8) -- see management.views.EvaluationsComingSoonView."""
def make_plain_staff(self, email="physio-eval@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_member_admin_can_reach_it(self):
self.client.force_login(self.admin_user)
response = self.club_get("evaluations")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Player evaluations")
def test_plain_staff_gets_403(self):
self.client.force_login(self.make_plain_staff())
response = self.club_get("evaluations")
self.assertEqual(response.status_code, 403)
def test_nav_shows_the_placeholder_unconditionally_no_flag_needed(self):
self.client.force_login(self.admin_user)
response = self.club_get("member_list")
self.assertContains(response, "Evaluations")
RBIHF_SAMPLE_HTML = """<html><body>
<div class="block"><div class="block-header"><h2>Sportoase Antwerp Phantoms</h2></div></div>
<div class="block"><div class="block-header"><h2 id="games-upcoming">Upcoming games</h2></div>
<div class="block-content"><table>
<tr><th class="game-nr">#</th><th class="date">Date</th><th class="hour">Hour</th><th>Location</th><th>Home</th><th>Visit</th></tr>
<tr>
<td class="game-nr"><a href="/game/5002" title="Game 5002">5002</a></td>
<td class="date">2026-09-12</td>
<td class="hour">12:15</td>
<td>Deurne</td>
<td><a href="/league/team/4460" title="Sportoase Antwerp Phantoms">Sportoase Antwerp Phantoms</a></td>
<td><a href="/league/team/4464" title="Amsterdam Tigers">Amsterdam Tigers</a></td>
</tr>
</table></div></div>
</body></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 NotificationBellTests(ManagementTestBase):
"""The topbar bell (every page) and the dashboard's fuller list -- see
management.context_processors.notification_bell. Unlike SidebarCounterTests'
admin-only badges above, any signed-in staff member with a Member row can
have notifications."""
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def test_none_when_nothing_is_pending_but_zero_is_shown_once_therere_notifications(self):
response = self.club_get("home")
self.assertEqual(response.context["unread_notification_count"], 0)
self.assertEqual(list(response.context["recent_notifications"]), [])
def test_unread_count_and_recent_list_reflect_real_notifications(self):
Notification.objects.create(club=self.club, member=self.admin_member, title="Big win", body="We won 3-0.")
response = self.club_get("home")
self.assertEqual(response.context["unread_notification_count"], 1)
self.assertContains(response, "Big win")
def test_a_read_notification_does_not_count_as_unread(self):
Notification.objects.create(club=self.club, member=self.admin_member, title="Old news", body="Body.", read_at=timezone.now())
response = self.club_get("home")
self.assertEqual(response.context["unread_notification_count"], 0)
def test_only_this_members_own_notifications_show(self):
other_member = Member.objects.create(first_name="Other", last_name="Staff")
Notification.objects.create(club=self.club, member=other_member, title="Not for you", body="Body.")
response = self.club_get("home")
self.assertEqual(response.context["unread_notification_count"], 0)
self.assertNotContains(response, "Not for you")
def test_the_bell_badge_renders_on_every_page_not_just_the_dashboard(self):
Notification.objects.create(club=self.club, member=self.admin_member, title="Big win", body="We won 3-0.")
response = self.club_get("member_list")
self.assertContains(response, 'id="notification-menu"')
def test_anonymous_gets_no_bell(self):
self.client.logout()
response = self.client.get(reverse("account_login"), HTTP_HOST="ajax-united.rosterchief.app")
self.assertNotContains(response, 'id="notification-menu"')
class NotificationMarkAllReadViewTests(ManagementTestBase):
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def test_marks_every_unread_notification_read(self):
first = Notification.objects.create(club=self.club, member=self.admin_member, title="One", body="Body.")
second = Notification.objects.create(club=self.club, member=self.admin_member, title="Two", body="Body.")
self.club_post("notification_mark_all_read", {})
first.refresh_from_db()
second.refresh_from_db()
self.assertIsNotNone(first.read_at)
self.assertIsNotNone(second.read_at)
def test_does_not_touch_another_members_notification(self):
other_member = Member.objects.create(first_name="Other", last_name="Staff")
other_notification = Notification.objects.create(club=self.club, member=other_member, title="Not yours", body="Body.")
self.club_post("notification_mark_all_read", {})
other_notification.refresh_from_db()
self.assertIsNone(other_notification.read_at)
def test_redirects_to_next_when_given(self):
response = self.club_post("notification_mark_all_read", {"next": reverse("management:member_list")})
self.assertRedirects(response, reverse("management:member_list"))
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&amp;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"})
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"})
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_plain_coach_cannot_mark_one_complete(self):
# Only an admin/MEMBER_ADMIN may touch a member's checklist
# (MemberAdminRequiredMixin) -- a coach can still *see* it on a member's
# profile (ClubStaffRequiredMixin's read access there), but not mark
# anything done or reopen it. A *real* staff assignment on the member's
# own team is what would have made this coach able to reach the page at
# all pre-checklist-permission, so the fixture still sets that up to
# isolate this from a simpler "can't reach the page" 403.
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.assertEqual(response.status_code, 403)
def test_a_member_admin_can_mark_one_complete(self):
member_admin_user = User.objects.create_user(email="memberadmin-checklist@example.com", password="pw-secret-123")
member_admin_member = Member.objects.create(user=member_admin_user, first_name="Mia", last_name="Admin")
ClubMembership.objects.create(club=self.club, member=member_admin_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
# The active membership above already granted a plain MEMBER role via
# club/signals.py's post_save sync -- update that row rather than
# creating a second one (ClubRole is unique per club/member).
ClubRole.objects.filter(club=self.club, member=member_admin_member).update(role=ClubRole.Roles.MEMBER_ADMIN)
enrol_mfa(member_admin_user)
self.client.force_login(member_admin_user)
response = self.club_post("member_requirement_complete", {"note": ""}, self.member.pk, self.requirement.pk)
# fetch_redirect_response=False: this member_admin has no team/family tie
# to self.member, so members_visible_to would 404 the redirect target --
# a separate, pre-existing visibility question this test isn't about.
self.assertRedirects(response, reverse("management:member_detail", args=[self.member.pk]), fetch_redirect_response=False)
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)