Files
RosterChief/management/tests.py
Bernard Siebens 062da00bb9 Add the management app: club-facing UI + real fee-payment tracking
Gives clubs a self-service /manage/ area for members, families, teams,
roles, and season memberships, alongside real fee-payment tracking
(FeePayment, record_payment/mark_as_paid/remaining_balance) so a
membership's paid status reflects actual money received instead of a
single manually-set flag.
2026-08-03 17:01:15 +02:00

1645 lines
86 KiB
Python

import datetime
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.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from club.models import Club, ClubMembership, ClubRole, FeePayment, Season
from events.models import Event
from management.bulk_import import TEMPLATE_COLUMNS
from management.pdf import PDFExportError, render_pdf
from members.models import Family, FamilyMembership, Member
from shop.models import Order
from teams.models import Position, StaffAssignment, Team, TeamMembership
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):
def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
self.season = make_season(self.club)
self.admin_user = User.objects.create_user(email="admin@example.com", password="pw-secret-123")
self.admin_member = Member.objects.create(user=self.admin_user, first_name="Ada", last_name="Admin")
ClubMembership.objects.create(club=self.club, member=self.admin_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
ClubRole.objects.filter(club=self.club, member=self.admin_member).update(role=ClubRole.Roles.ADMIN)
enrol_mfa(self.admin_user)
def club_get(self, name, *args):
return self.client.get(reverse(f"management:{name}", args=args), HTTP_HOST="ajax-united.rosterchief.app")
def club_post(self, name, data, *args):
return self.client.post(reverse(f"management:{name}", args=args), data, HTTP_HOST="ajax-united.rosterchief.app")
class AccessTests(ManagementTestBase):
def test_anonymous_is_sent_to_login(self):
response = self.club_get("home")
self.assertEqual(response.status_code, 302)
self.assertIn(reverse("account_login"), response.url)
def test_a_club_member_with_no_role_or_staff_assignment_gets_403(self):
plain_user = User.objects.create_user(email="plain@example.com", password="pw-secret-123")
self.client.force_login(plain_user)
self.assertEqual(self.club_get("home").status_code, 403)
def test_a_plain_active_club_member_gets_403(self):
# An active ClubMembership auto-grants a MEMBER ClubRole (club/signals.py) --
# every signed-up player has one. That alone must not be enough to get in, or
# players would reach the staff UI they're explicitly excluded from.
player_user = User.objects.create_user(email="player@example.com", password="pw-secret-123")
player_member = Member.objects.create(user=player_user, first_name="Paul", last_name="Player")
ClubMembership.objects.create(club=self.club, member=player_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
self.client.force_login(player_user)
self.assertEqual(self.club_get("home").status_code, 403)
def test_a_club_admin_can_reach_it(self):
self.client.force_login(self.admin_user)
self.assertEqual(self.club_get("home").status_code, 200)
def test_it_does_not_exist_on_the_base_domain(self):
# The management UI manages one club; the mirror image of controlpanel
# refusing to exist on a club subdomain.
self.client.force_login(self.admin_user)
response = self.client.get(reverse("management:home"), HTTP_HOST="rosterchief.app")
self.assertEqual(response.status_code, 404)
def test_staff_with_only_a_staff_assignment_can_reach_staff_pages(self):
# No ClubRole at all -- authority comes purely from a current-season
# StaffAssignment, per club.services.access.
coach_user = User.objects.create_user(email="coach@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
team = Team.objects.create(club=self.club, name="U12", short_name="U12")
position = Position.objects.create(club=self.club, name="Coach", short_name="C", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
self.client.force_login(coach_user)
self.assertEqual(self.club_get("member_list").status_code, 200)
def test_staff_without_admin_role_cannot_reach_admin_only_pages(self):
coach_user = User.objects.create_user(email="coach2@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cody", last_name="Coach")
team = Team.objects.create(club=self.club, name="U13", short_name="U13")
position = Position.objects.create(club=self.club, name="Coach2", short_name="C2", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
self.client.force_login(coach_user)
self.assertEqual(self.club_get("position_list").status_code, 403)
self.assertEqual(self.club_post("member_create", {"first_name": "X", "last_name": "Y"}).status_code, 403)
class NavLinkTests(ManagementTestBase):
"""The global navbar's "Management" link, next to Django admin -- see
management.context_processors.management_link and templates/_base.html."""
def test_a_club_admin_sees_the_link_on_the_club_subdomain(self):
self.client.force_login(self.admin_user)
self.assertContains(self.club_get("home"), reverse("management:home"))
def test_a_plain_active_club_member_does_not_see_the_link(self):
player_user = User.objects.create_user(email="player2@example.com", password="pw-secret-123")
player_member = Member.objects.create(user=player_user, first_name="Pia", last_name="Player")
ClubMembership.objects.create(club=self.club, member=player_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
self.client.login(email="player2@example.com", password="pw-secret-123")
# 403 on /manage/ itself, but the link must not appear on pages this user *can*
# reach either -- assert against a page outside the gate: the account view.
response = self.client.get(reverse("mfa_index"), HTTP_HOST="ajax-united.rosterchief.app")
self.assertNotContains(response, reverse("management:home"))
def test_the_link_is_absent_on_the_base_domain_even_for_a_club_admin(self):
# has_management_access requires a resolved club; the control panel/base domain
# has none, so the link -- which points at a single club's management app --
# correctly never appears there regardless of who's signed in.
self.client.force_login(self.admin_user)
response = self.client.get(reverse("mfa_index"), HTTP_HOST="rosterchief.app")
self.assertNotContains(response, reverse("management:home"))
class ActiveNavHighlightTests(ManagementTestBase):
"""The sidebar/mobile nav highlights whichever section the current page
belongs to -- see management.context_processors.active_nav_section."""
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def test_the_members_list_page_highlights_members(self):
response = self.club_get("member_list")
self.assertContains(response, f'class="menu-active" href="{reverse("management:member_list")}"')
self.assertNotContains(response, f'class="menu-active" href="{reverse("management:home")}"')
def test_a_member_detail_sub_page_still_highlights_members(self):
# member_detail has no nav entry of its own -- it belongs to the Members
# section, same as member_list, member_update, family_detail, etc.
member = Member.objects.create(first_name="Sub", last_name="Page")
ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
response = self.club_get("member_detail", member.pk)
self.assertContains(response, f'class="menu-active" href="{reverse("management:member_list")}"')
def test_the_dashboard_highlights_dashboard_only(self):
response = self.club_get("home")
self.assertContains(response, f'class="menu-active" href="{reverse("management:home")}"')
self.assertNotContains(response, f'class="menu-active" href="{reverse("management:member_list")}"')
class MemberManagementTests(ManagementTestBase):
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def test_member_list_is_scoped_to_the_club(self):
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
other_season = make_season(other_club)
other_member = Member.objects.create(first_name="Other", last_name="Person")
ClubMembership.objects.create(club=other_club, member=other_member, season=other_season, status=ClubMembership.StatusChoices.ACTIVE)
response = self.club_get("member_list")
self.assertNotContains(response, "Other Person")
def test_creating_a_member_also_signs_them_up_for_the_current_season(self):
response = self.club_post("member_create", {"first_name": "New", "last_name": "Player", "email": "new@example.com"})
member = Member.objects.get(first_name="New", last_name="Player")
self.assertRedirects(response, reverse("management:member_detail", args=[member.pk]))
self.assertTrue(ClubMembership.objects.filter(club=self.club, member=member, season=self.season).exists())
def test_updating_a_member(self):
member = Member.objects.create(first_name="Old", last_name="Name")
ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
# This member has a current-season membership, so its section renders too --
# one combined submit, so its (required) fields must come along.
self.club_post(
"member_update",
{"first_name": "New", "last_name": "Name", "status": ClubMembership.StatusChoices.ACTIVE, "fee_status": ClubMembership.FeeStatus.UNPAID},
member.pk,
)
member.refresh_from_db()
self.assertEqual(member.first_name, "New")
class TeamManagementTests(ManagementTestBase):
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def test_team_list_is_scoped_to_the_club(self):
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
Team.objects.create(club=other_club, name="Rival Team", short_name="RT")
response = self.club_get("team_list")
self.assertNotContains(response, "Rival Team")
def test_creating_a_team(self):
response = self.club_post("team_create", {"name": "U15", "short_name": "U15"})
team = Team.objects.get(club=self.club, name="U15")
self.assertRedirects(response, reverse("management:team_detail", args=[team.pk]))
class ClubRoleManagementTests(ManagementTestBase):
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
self.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=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
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())
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 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."""
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
self.family = Family.objects.create()
self.child = Member.objects.create(first_name="Cody", last_name="Kid")
FamilyMembership.objects.create(family=self.family, member=self.child, role=FamilyMembership.FamilyRole.CHILD)
ClubMembership.objects.create(club=self.club, member=self.child, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
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 MemberFamilyAttachDetachTests(ManagementTestBase):
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
self.standalone = Member.objects.create(first_name="Stan", last_name="Alone")
ClubMembership.objects.create(club=self.club, member=self.standalone, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
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)
class MemberDeleteTests(ManagementTestBase):
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
self.member = Member.objects.create(first_name="Doomed", last_name="Member")
ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
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):
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
self.member = Member.objects.create(first_name="Fee", last_name="Payer")
self.membership = ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
def test_editing_a_member_also_updates_their_current_membership(self):
response = self.club_post(
"member_update",
{
"first_name": "Fee",
"last_name": "Payer",
"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", "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."""
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
self.family = Family.objects.create()
self.parent = Member.objects.create(first_name="Pat", last_name="Guardian")
self.child = Member.objects.create(first_name="Cody", last_name="Kid")
FamilyMembership.objects.create(family=self.family, member=self.parent, role=FamilyMembership.FamilyRole.PARENT)
FamilyMembership.objects.create(family=self.family, member=self.child, role=FamilyMembership.FamilyRole.CHILD)
ClubMembership.objects.create(club=self.club, member=self.parent, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
ClubMembership.objects.create(club=self.club, member=self.child, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
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):
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
self.family = Family.objects.create()
self.member = Member.objects.create(first_name="Cody", last_name="Kid")
self.membership = FamilyMembership.objects.create(family=self.family, member=self.member, role=FamilyMembership.FamilyRole.CHILD)
ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
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):
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
# 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=self.club, member=self.admin_member).delete()
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):
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
self.member = Member.objects.create(first_name="Owed", last_name="Fee")
self.membership = ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.UNPAID)
def test_marking_paid_sets_active_and_paid(self):
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.ACTIVE)
self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PAID)
self.assertEqual(self.membership.activated_at, timezone.localdate())
def test_marking_paid_grants_the_member_role(self):
# The whole reason this loops and calls .save() instead of .update() --
# club/signals.py grants MEMBER via a post_save signal.
self.assertFalse(ClubRole.objects.filter(club=self.club, member=self.member, role=ClubRole.Roles.MEMBER).exists())
self.club_post("membership_mark_paid", {"membership_ids": [str(self.membership.pk)]})
self.assertTrue(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):
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
self.member = Member.objects.create(first_name="Owed", last_name="Fee")
self.membership = ClubMembership.objects.create(
club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.UNPAID, fee_amount=Decimal("150.00")
)
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_it(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.ACTIVE)
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):
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
self.member = Member.objects.create(first_name="Owed", last_name="Fee")
self.membership = ClubMembership.objects.create(
club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.UNPAID, fee_amount=Decimal("150.00")
)
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.ACTIVE)
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):
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
self.member = Member.objects.create(first_name="Print", last_name="Me")
ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE, fee_status=ClubMembership.FeeStatus.UNPAID)
def test_downloads_as_a_pdf(self):
with mock.patch("management.views.membership_list_pdf", return_value=b"%PDF-fake") as renderer:
response = self.club_get("membership_export_pdf")
self.assertEqual(response["Content-Type"], "application/pdf")
self.assertIn(".pdf", response["Content-Disposition"])
self.assertEqual(response.content, b"%PDF-fake")
renderer.assert_called_once()
def test_export_uses_the_same_filters_as_the_page(self):
# Same fixture as the "on-team" filter test for the page itself -- the PDF
# must reflect whatever's filtered, not the whole club.
other = Member.objects.create(first_name="Other", last_name="Person")
ClubMembership.objects.create(club=self.club, member=other, season=self.season, status=ClubMembership.StatusChoices.ACTIVE, fee_status=ClubMembership.FeeStatus.UNPAID)
with mock.patch("management.views.membership_list_pdf", return_value=b"%PDF-fake") as renderer:
self.client.get(reverse("management:membership_export_pdf") + "?q=Print", HTTP_HOST="ajax-united.rosterchief.app")
context = renderer.call_args[0][0]
names = {m.member.last_name for m in context["memberships"]}
self.assertEqual(names, {"Me"})
def test_a_missing_pdf_library_is_reported_rather_than_a_500(self):
# WeasyPrint needs native libs. Without them the button must explain itself.
with mock.patch("management.views.membership_list_pdf", side_effect=PDFExportError("PDF rendering needs the native pango/cairo libraries.")):
response = self.club_get("membership_export_pdf")
response = self.client.get(response.url, HTTP_HOST="ajax-united.rosterchief.app")
self.assertContains(response, "pango")
def test_non_admin_gets_403(self):
coach_user = User.objects.create_user(email="coach-export@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
team = Team.objects.create(club=self.club, name="U21", short_name="U21")
position = Position.objects.create(club=self.club, name="Coach10", short_name="C10", staff_position=True)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
self.client.force_login(coach_user)
response = self.club_get("membership_export_pdf")
self.assertEqual(response.status_code, 403)
class MemberListRowActionsTests(ManagementTestBase):
def setUp(self):
super().setUp()
self.member = Member.objects.create(first_name="Row", last_name="Actions")
ClubMembership.objects.create(club=self.club, member=self.member, season=self.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, "Open carts")
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, "Open carts")
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.MATCH, title="Later match", 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)