A member excluded from an event's Attendance sync by an open onboarding requirement (events.services.attendance.effective_members) previously just never saw that event anywhere -- no row, no explanation. Two new read-side functions mirror that exclusion instead of hiding it: club.services. onboarding.open_requirements_blocking (per-member, "why") and events. services.attendance.blocked_upcoming_events_for_member (which of their upcoming events are affected). The Calendar now shows those events as a distinct muted "Blocked" row naming the outstanding requirement, and the event detail page shows a "Can't sign up yet" card for the same reason -- no RSVP buttons, no lineup/ referee actions, just the explanation. Write-side blocking (who actually gets an Attendance row, who a coach can select) is untouched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2388 lines
107 KiB
Python
2388 lines
107 KiB
Python
import datetime
|
|
import uuid
|
|
from contextlib import contextmanager
|
|
from decimal import Decimal
|
|
from io import StringIO
|
|
from unittest import mock
|
|
|
|
from allauth.mfa.models import Authenticator
|
|
from dateutil.relativedelta import relativedelta
|
|
from django.contrib import admin as django_admin
|
|
from django.contrib.auth import get_user_model
|
|
from django.contrib.auth.models import AnonymousUser
|
|
from django.core.exceptions import ValidationError
|
|
from django.core.management import call_command
|
|
from django.db import IntegrityError
|
|
from django.db.models import ProtectedError
|
|
from django.test import RequestFactory, TestCase, override_settings
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
|
|
from events.models import Event, Location
|
|
from members.models import Family, FamilyMembership, Member
|
|
from teams.models import Position, StaffAssignment, Team, TeamMembership
|
|
from teams.services import eligible_roster_members
|
|
|
|
from .models import Club, ClubMembership, ClubRole, DuesInvoice, FeePayment, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor, club_logo_path
|
|
from .services.access import (
|
|
COACH_MANAGER,
|
|
can_edit_event,
|
|
can_manage_members,
|
|
can_manage_shop,
|
|
has_club_role,
|
|
has_management_access,
|
|
is_club_admin,
|
|
is_member_admin,
|
|
is_platform_superuser,
|
|
members_visible_to,
|
|
roles_in_club,
|
|
teams_managed_by,
|
|
teams_staffed_by,
|
|
)
|
|
from .services.fees import mark_as_paid, open_dues_rows, record_payment, remaining_balance
|
|
from .services.invoicing import create_or_resend_invoice, invoice_pdf, invoices_due_for_reminder, recipient_for, resolve_document_address
|
|
from .services.onboarding import (
|
|
annotate_onboarding_status,
|
|
approve_all_clean,
|
|
approve_one,
|
|
blocked_member_ids_for_event,
|
|
blocking_event_kinds,
|
|
checklist_for,
|
|
mark_bypassed,
|
|
mark_complete,
|
|
mark_incomplete,
|
|
open_requirements_blocking,
|
|
)
|
|
from .services.seasons import _initial_season_start, _season_end, generate_seasons, resync_seasons
|
|
from .tenancy import (
|
|
ClubTenantMiddleware,
|
|
get_current_club,
|
|
require_current_club,
|
|
reset_current_club,
|
|
set_current_club,
|
|
)
|
|
|
|
|
|
@contextmanager
|
|
def with_club(club):
|
|
"""Bind ``club`` as the active tenant for the duration of the block."""
|
|
token = set_current_club(club)
|
|
try:
|
|
yield club
|
|
finally:
|
|
reset_current_club(token)
|
|
|
|
|
|
class ClubModelTests(TestCase):
|
|
def test_str_returns_name(self):
|
|
club = Club.objects.create(name="City Swim Club")
|
|
|
|
self.assertEqual(str(club), "City Swim Club")
|
|
|
|
def test_pk_is_uuid(self):
|
|
club = Club.objects.create(name="City Swim Club")
|
|
|
|
self.assertIsInstance(club.pk, uuid.UUID)
|
|
|
|
def test_clubs_are_ordered_by_name(self):
|
|
Club.objects.create(name="Zulu Club")
|
|
Club.objects.create(name="Alpha Club")
|
|
Club.objects.create(name="Middle Club")
|
|
|
|
self.assertEqual(
|
|
list(Club.objects.values_list("name", flat=True)),
|
|
["Alpha Club", "Middle Club", "Zulu Club"],
|
|
)
|
|
|
|
def test_verbose_names(self):
|
|
self.assertEqual(Club._meta.verbose_name, "club")
|
|
self.assertEqual(Club._meta.verbose_name_plural, "clubs")
|
|
|
|
|
|
def make_season(club, start_year=2026):
|
|
return Season.objects.create(
|
|
club=club,
|
|
start_date=datetime.date(start_year, 8, 1),
|
|
end_date=datetime.date(start_year + 1, 5, 31),
|
|
)
|
|
|
|
|
|
class ClubMembershipModelTests(TestCase):
|
|
# setUpTestData, not setUp: these three are read-only scaffolding, built once per class
|
|
# instead of once per test. Django hands each test its own deep copy, and the database
|
|
# is rolled back after every one, so the tests that archive or delete them stay isolated.
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="City Swim Club")
|
|
cls.season = make_season(cls.club)
|
|
cls.member = Member.objects.create(
|
|
first_name="Jane",
|
|
last_name="Doe",
|
|
email="jane@example.com",
|
|
)
|
|
|
|
def test_str_returns_club_and_member(self):
|
|
membership = ClubMembership.objects.create(club=self.club, season=self.season, member=self.member, license="LIC-001")
|
|
|
|
self.assertEqual(str(membership), "City Swim Club - Jane Doe")
|
|
|
|
def test_defaults_are_pending_and_unpaid(self):
|
|
membership = ClubMembership.objects.create(club=self.club, season=self.season, member=self.member)
|
|
|
|
self.assertEqual(membership.status, ClubMembership.StatusChoices.PENDING)
|
|
self.assertEqual(membership.fee_status, ClubMembership.FeeStatus.UNPAID)
|
|
self.assertEqual(membership.license, "")
|
|
self.assertIsNone(membership.signed_up_at)
|
|
|
|
def test_pk_is_uuid(self):
|
|
membership = ClubMembership.objects.create(club=self.club, season=self.season, member=self.member)
|
|
|
|
self.assertIsInstance(membership.pk, uuid.UUID)
|
|
|
|
def test_club_is_filled_from_active_tenant(self):
|
|
with with_club(self.club):
|
|
membership = ClubMembership.objects.create(season=self.season, member=self.member)
|
|
|
|
self.assertEqual(membership.club, self.club)
|
|
|
|
def test_member_is_unique_per_club_and_season(self):
|
|
ClubMembership.objects.create(club=self.club, season=self.season, member=self.member)
|
|
|
|
with self.assertRaises(IntegrityError):
|
|
ClubMembership.objects.create(club=self.club, season=self.season, member=self.member)
|
|
|
|
def test_same_member_can_join_consecutive_seasons(self):
|
|
next_season = make_season(self.club, start_year=2027)
|
|
|
|
first = ClubMembership.objects.create(club=self.club, season=self.season, member=self.member)
|
|
second = ClubMembership.objects.create(club=self.club, season=next_season, member=self.member)
|
|
|
|
self.assertEqual(self.member.member_of.count(), 2)
|
|
self.assertNotEqual(first.season, second.season)
|
|
|
|
def test_same_member_can_join_different_clubs(self):
|
|
other_club = Club.objects.create(name="Other Swim Club")
|
|
other_season = make_season(other_club)
|
|
|
|
ClubMembership.objects.create(club=self.club, season=self.season, member=self.member)
|
|
ClubMembership.objects.create(club=other_club, season=other_season, member=self.member)
|
|
|
|
self.assertEqual(self.member.member_of.count(), 2)
|
|
|
|
def test_deleting_club_is_blocked_while_a_season_has_memberships(self):
|
|
# Club -> Season is CASCADE, but ClubMembership -> Season is PROTECT, so
|
|
# the club can't be deleted while one of its seasons is still referenced.
|
|
ClubMembership.objects.create(club=self.club, season=self.season, member=self.member)
|
|
|
|
with self.assertRaises(ProtectedError):
|
|
self.club.delete()
|
|
|
|
self.assertTrue(ClubMembership.objects.exists())
|
|
|
|
def test_deleting_empty_club_cascades_to_its_seasons(self):
|
|
self.club.delete()
|
|
|
|
self.assertFalse(Club.objects.filter(pk=self.club.pk).exists())
|
|
self.assertFalse(Season.objects.filter(pk=self.season.pk).exists())
|
|
|
|
def test_deleting_member_deletes_membership_but_keeps_club(self):
|
|
ClubMembership.objects.create(club=self.club, season=self.season, member=self.member)
|
|
|
|
self.member.delete()
|
|
|
|
self.assertFalse(ClubMembership.objects.exists())
|
|
self.assertTrue(Club.objects.filter(pk=self.club.pk).exists())
|
|
|
|
def test_season_is_protected_while_referenced(self):
|
|
ClubMembership.objects.create(club=self.club, season=self.season, member=self.member)
|
|
|
|
with self.assertRaises(ProtectedError):
|
|
self.season.delete()
|
|
|
|
def test_memberships_are_ordered_by_club_then_member_name(self):
|
|
alpha_club = Club.objects.create(name="Alpha Club")
|
|
zulu_club = Club.objects.create(name="Zulu Club")
|
|
alpha_season = make_season(alpha_club)
|
|
zulu_season = make_season(zulu_club)
|
|
|
|
jane = Member.objects.create(first_name="Jane", last_name="Doe")
|
|
alice = Member.objects.create(first_name="Alice", last_name="Smith")
|
|
bob = Member.objects.create(first_name="Bob", last_name="Smith")
|
|
|
|
ClubMembership.objects.create(club=zulu_club, season=zulu_season, member=bob)
|
|
ClubMembership.objects.create(club=alpha_club, season=alpha_season, member=bob)
|
|
ClubMembership.objects.create(club=alpha_club, season=alpha_season, member=alice)
|
|
ClubMembership.objects.create(club=alpha_club, season=alpha_season, member=jane)
|
|
|
|
self.assertEqual(
|
|
[(membership.club.name, membership.member.last_name, membership.member.first_name) for membership in ClubMembership.objects.all()],
|
|
[
|
|
("Alpha Club", "Doe", "Jane"),
|
|
("Alpha Club", "Smith", "Alice"),
|
|
("Alpha Club", "Smith", "Bob"),
|
|
("Zulu Club", "Smith", "Bob"),
|
|
],
|
|
)
|
|
|
|
def test_reverse_relations(self):
|
|
membership = ClubMembership.objects.create(club=self.club, season=self.season, member=self.member)
|
|
|
|
self.assertEqual(list(self.club.clubmemberships.all()), [membership])
|
|
self.assertEqual(list(self.member.member_of.all()), [membership])
|
|
self.assertEqual(list(self.season.memberships.all()), [membership])
|
|
|
|
def test_verbose_names(self):
|
|
self.assertEqual(ClubMembership._meta.verbose_name, "club membership")
|
|
self.assertEqual(ClubMembership._meta.verbose_name_plural, "club memberships")
|
|
|
|
|
|
class ClubSlugTests(TestCase):
|
|
def test_slug_is_derived_from_name(self):
|
|
club = Club.objects.create(name="City Swim Club")
|
|
|
|
self.assertEqual(club.slug, "city-swim-club")
|
|
|
|
def test_explicit_slug_is_kept(self):
|
|
club = Club.objects.create(name="City Swim Club", slug="ajax-united")
|
|
|
|
self.assertEqual(club.slug, "ajax-united")
|
|
|
|
def test_derived_slugs_are_made_unique(self):
|
|
first = Club.objects.create(name="City Swim Club")
|
|
second = Club.objects.create(name="City Swim Club")
|
|
|
|
self.assertEqual(first.slug, "city-swim-club")
|
|
self.assertEqual(second.slug, "city-swim-club-2")
|
|
|
|
def test_slug_is_unique(self):
|
|
Club.objects.create(name="First", slug="shared")
|
|
|
|
with self.assertRaises(IntegrityError):
|
|
Club.objects.create(name="Second", slug="shared")
|
|
|
|
|
|
@override_settings(
|
|
ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
|
|
ALLOWED_HOSTS=[".rosterchief.app", ".example.com", ".example.org"],
|
|
)
|
|
class ClubTenantMiddlewareTests(TestCase):
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
|
|
def setUp(self):
|
|
self.factory = RequestFactory()
|
|
# Bound to this instance's _capture, so it cannot be shared across tests.
|
|
self.captured = {}
|
|
self.middleware = ClubTenantMiddleware(self._capture)
|
|
|
|
def _capture(self, request):
|
|
# Runs inside the middleware, while the context var is set.
|
|
self.captured["context_club"] = get_current_club()
|
|
return "response"
|
|
|
|
def _run(self, host):
|
|
request = self.factory.get("/", HTTP_HOST=host)
|
|
response = self.middleware(request)
|
|
return request, response
|
|
|
|
def test_subdomain_resolves_to_club(self):
|
|
request, response = self._run("ajax-united.rosterchief.app")
|
|
|
|
self.assertEqual(response, "response")
|
|
self.assertEqual(request.club, self.club)
|
|
self.assertEqual(self.captured["context_club"], self.club)
|
|
|
|
def test_subdomain_resolution_ignores_port(self):
|
|
request, _ = self._run("ajax-united.rosterchief.app:8000")
|
|
|
|
self.assertEqual(request.club, self.club)
|
|
|
|
def test_unknown_subdomain_sets_none(self):
|
|
request, _ = self._run("unknown-club.rosterchief.app")
|
|
|
|
self.assertIsNone(request.club)
|
|
|
|
def test_bare_base_domain_has_no_club(self):
|
|
request, _ = self._run("rosterchief.app")
|
|
|
|
self.assertIsNone(request.club)
|
|
|
|
def test_www_is_treated_as_no_club(self):
|
|
request, _ = self._run("www.rosterchief.app")
|
|
|
|
self.assertIsNone(request.club)
|
|
|
|
def test_foreign_domain_has_no_club(self):
|
|
request, _ = self._run("ajax-united.example.org")
|
|
|
|
self.assertIsNone(request.club)
|
|
|
|
def test_context_var_is_reset_after_request(self):
|
|
self._run("ajax-united.rosterchief.app")
|
|
|
|
self.assertIsNone(get_current_club())
|
|
|
|
@override_settings(ROSTERCHIEF_BASE_DOMAIN="")
|
|
def test_generic_host_resolution_without_base_domain(self):
|
|
request, _ = self._run("ajax-united.example.com")
|
|
|
|
self.assertEqual(request.club, self.club)
|
|
|
|
@override_settings(ROSTERCHIEF_BASE_DOMAIN="")
|
|
def test_two_label_host_has_no_club_without_base_domain(self):
|
|
request, _ = self._run("example.com")
|
|
|
|
self.assertIsNone(request.club)
|
|
|
|
|
|
class _FakeRequest:
|
|
def __init__(self, host):
|
|
self._host = host
|
|
|
|
def get_host(self):
|
|
return self._host
|
|
|
|
|
|
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app")
|
|
class GetSubdomainTests(TestCase):
|
|
def subdomain(self, host):
|
|
return ClubTenantMiddleware.get_subdomain(_FakeRequest(host))
|
|
|
|
def test_empty_host_returns_none(self):
|
|
self.assertIsNone(self.subdomain(""))
|
|
|
|
def test_trailing_dot_is_stripped(self):
|
|
self.assertEqual(self.subdomain("ajax-united.rosterchief.app."), "ajax-united")
|
|
|
|
def test_nested_subdomain_uses_leftmost_label(self):
|
|
self.assertEqual(self.subdomain("a.b.rosterchief.app"), "a")
|
|
|
|
|
|
class TenantContextTests(TestCase):
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
|
|
def test_require_current_club_returns_active_club(self):
|
|
with with_club(self.club):
|
|
self.assertEqual(require_current_club(), self.club)
|
|
|
|
def test_require_current_club_raises_without_context(self):
|
|
with self.assertRaises(RuntimeError):
|
|
require_current_club()
|
|
|
|
def test_club_manager_current_returns_active_club(self):
|
|
with with_club(self.club):
|
|
self.assertEqual(Club.objects.current(), self.club)
|
|
|
|
def test_club_manager_current_is_none_without_context(self):
|
|
self.assertIsNone(Club.objects.current())
|
|
|
|
|
|
class TenantScopedModelTests(TestCase):
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
cls.other = Club.objects.create(name="Rival FC", slug="rival-fc")
|
|
cls.dates = {
|
|
"start_date": datetime.date(2026, 8, 1),
|
|
"end_date": datetime.date(2027, 5, 31),
|
|
}
|
|
|
|
def test_save_keeps_explicit_club(self):
|
|
season = Season.objects.create(club=self.club, **self.dates)
|
|
|
|
self.assertEqual(season.club, self.club)
|
|
|
|
def test_save_fills_club_from_context(self):
|
|
with with_club(self.club):
|
|
season = Season.objects.create(**self.dates)
|
|
|
|
self.assertEqual(season.club, self.club)
|
|
|
|
def test_save_without_club_or_context_raises(self):
|
|
with self.assertRaises(RuntimeError):
|
|
Season.objects.create(**self.dates)
|
|
|
|
def test_for_club_filters_by_club(self):
|
|
mine = Season.objects.create(club=self.club, **self.dates)
|
|
Season.objects.create(club=self.other, **self.dates)
|
|
|
|
self.assertEqual(list(Season.objects.for_club(self.club)), [mine])
|
|
|
|
def test_current_club_filters_by_active_club(self):
|
|
mine = Season.objects.create(club=self.club, **self.dates)
|
|
Season.objects.create(club=self.other, **self.dates)
|
|
|
|
with with_club(self.club):
|
|
self.assertEqual(list(Season.objects.current_club()), [mine])
|
|
|
|
def test_name_is_two_digit_year_range(self):
|
|
season = Season.objects.create(club=self.club, **self.dates)
|
|
|
|
self.assertEqual(season.name, "26-27")
|
|
|
|
def test_name_zero_pads_years(self):
|
|
season = Season.objects.create(
|
|
club=self.club,
|
|
start_date=datetime.date(2008, 8, 1),
|
|
end_date=datetime.date(2009, 5, 31),
|
|
)
|
|
|
|
self.assertEqual(season.name, "08-09")
|
|
|
|
def test_str_is_the_name(self):
|
|
season = Season.objects.create(club=self.club, **self.dates)
|
|
|
|
self.assertEqual(str(season), "26-27")
|
|
|
|
|
|
class SeasonGetCurrentTests(TestCase):
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
cls.other = Club.objects.create(name="Rival FC", slug="rival-fc")
|
|
cls.season = Season.objects.create(
|
|
club=cls.club,
|
|
start_date=datetime.date(2026, 8, 1),
|
|
end_date=datetime.date(2027, 5, 31),
|
|
)
|
|
|
|
def test_returns_season_covering_the_given_date(self):
|
|
with with_club(self.club):
|
|
found = Season.get_current(datetime.date(2026, 12, 25))
|
|
|
|
self.assertEqual(found, self.season)
|
|
|
|
def test_includes_boundary_dates(self):
|
|
with with_club(self.club):
|
|
self.assertEqual(Season.get_current(datetime.date(2026, 8, 1)), self.season)
|
|
self.assertEqual(Season.get_current(datetime.date(2027, 5, 31)), self.season)
|
|
|
|
def test_returns_none_when_no_season_covers_the_date(self):
|
|
with with_club(self.club):
|
|
self.assertIsNone(Season.get_current(datetime.date(2027, 7, 1)))
|
|
|
|
def test_is_scoped_to_the_active_club(self):
|
|
# The other club's season covers the same date but must not leak.
|
|
Season.objects.create(
|
|
club=self.other,
|
|
start_date=datetime.date(2026, 8, 1),
|
|
end_date=datetime.date(2027, 5, 31),
|
|
)
|
|
|
|
with with_club(self.other):
|
|
found = Season.get_current(datetime.date(2026, 12, 25))
|
|
|
|
self.assertEqual(found.club, self.other)
|
|
|
|
def test_defaults_to_today(self):
|
|
# self.other, not self.club -- setUp's self.season (2026-08-01 to 2027-05-31)
|
|
# would otherwise also cover "today" once real dates reach that window,
|
|
# colliding with the one created here.
|
|
today = timezone.now().date()
|
|
current = Season.objects.create(
|
|
club=self.other,
|
|
start_date=today - datetime.timedelta(days=10),
|
|
end_date=today + datetime.timedelta(days=10),
|
|
)
|
|
|
|
with with_club(self.other):
|
|
self.assertEqual(Season.get_current(), current)
|
|
|
|
def test_requires_an_active_club(self):
|
|
with self.assertRaises(RuntimeError):
|
|
Season.get_current(datetime.date(2026, 12, 25))
|
|
|
|
|
|
class SeasonNextAfterTests(TestCase):
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
cls.current = Season.objects.create(club=cls.club, start_date=datetime.date(2026, 8, 1), end_date=datetime.date(2027, 5, 31))
|
|
cls.next_season = Season.objects.create(club=cls.club, start_date=datetime.date(2027, 8, 1), end_date=datetime.date(2028, 5, 31))
|
|
|
|
def test_returns_the_soonest_season_starting_after_the_date(self):
|
|
self.assertEqual(Season.next_after(self.club, datetime.date(2026, 12, 25)), self.next_season)
|
|
|
|
def test_returns_none_when_there_is_no_later_season(self):
|
|
self.assertIsNone(Season.next_after(self.club, datetime.date(2027, 12, 25)))
|
|
|
|
def test_is_scoped_to_the_given_club(self):
|
|
other = Club.objects.create(name="Rival FC", slug="rival-fc")
|
|
Season.objects.create(club=other, start_date=datetime.date(2027, 8, 1), end_date=datetime.date(2028, 5, 31))
|
|
|
|
self.assertEqual(Season.next_after(other, datetime.date(2026, 12, 25)).club, other)
|
|
|
|
|
|
class SeasonBeforeTests(TestCase):
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
cls.previous = Season.objects.create(club=cls.club, start_date=datetime.date(2025, 8, 1), end_date=datetime.date(2026, 5, 31))
|
|
cls.current = Season.objects.create(club=cls.club, start_date=datetime.date(2026, 8, 1), end_date=datetime.date(2027, 5, 31))
|
|
|
|
def test_returns_the_most_recent_season_starting_before_this_one(self):
|
|
self.assertEqual(Season.before(self.club, self.current), self.previous)
|
|
|
|
def test_returns_none_when_there_is_no_earlier_season(self):
|
|
self.assertIsNone(Season.before(self.club, self.previous))
|
|
|
|
def test_is_scoped_to_the_given_club(self):
|
|
other = Club.objects.create(name="Rival FC", slug="rival-fc")
|
|
other_current = Season.objects.create(club=other, start_date=datetime.date(2026, 8, 1), end_date=datetime.date(2027, 5, 31))
|
|
|
|
self.assertIsNone(Season.before(other, other_current))
|
|
|
|
|
|
class SponsorModelTests(TestCase):
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
|
|
def test_str_returns_name(self):
|
|
sponsor = Sponsor.objects.create(club=self.club, name="Acme Corp", start_date=datetime.date(2026, 1, 1))
|
|
|
|
self.assertEqual(str(sponsor), "Acme Corp")
|
|
|
|
def test_a_blank_end_date_is_valid(self):
|
|
sponsor = Sponsor(club=self.club, name="Acme Corp", start_date=datetime.date(2026, 1, 1))
|
|
sponsor.full_clean()
|
|
|
|
def test_an_end_date_on_the_same_day_as_start_is_valid(self):
|
|
sponsor = Sponsor(club=self.club, name="Acme Corp", start_date=datetime.date(2026, 1, 1), end_date=datetime.date(2026, 1, 1))
|
|
sponsor.full_clean()
|
|
|
|
def test_an_end_date_before_start_is_rejected(self):
|
|
sponsor = Sponsor(club=self.club, name="Acme Corp", start_date=datetime.date(2026, 6, 1), end_date=datetime.date(2026, 1, 1))
|
|
|
|
with self.assertRaises(ValidationError) as ctx:
|
|
sponsor.full_clean()
|
|
self.assertIn("end_date", ctx.exception.error_dict)
|
|
|
|
def test_sponsors_are_ordered_by_name(self):
|
|
Sponsor.objects.create(club=self.club, name="Zulu Corp", start_date=datetime.date(2026, 1, 1))
|
|
Sponsor.objects.create(club=self.club, name="Acme Corp", start_date=datetime.date(2026, 1, 1))
|
|
|
|
self.assertEqual(list(Sponsor.objects.values_list("name", flat=True)), ["Acme Corp", "Zulu Corp"])
|
|
|
|
|
|
class AdminRegistrationSmokeTests(TestCase):
|
|
"""Every registered model across all apps must have a working admin: load
|
|
each changelist and add page to catch bad list_display / search_fields /
|
|
fieldsets / autocomplete targets in any app's admin config."""
|
|
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.admin = get_user_model().objects.create_superuser(email="root@club.test", password="pw-secret-123")
|
|
# Staff must hold a second factor (RequireMFAMiddleware), else they are
|
|
# redirected to enrolment instead of reaching the admin.
|
|
Authenticator.objects.create(user=cls.admin, type=Authenticator.Type.TOTP, data={"secret": "JBSWY3DPEHPK3PXP"})
|
|
|
|
def setUp(self):
|
|
# The test client is per-test, so the session it carries has to be too.
|
|
self.client.force_login(self.admin)
|
|
|
|
def test_every_model_is_registered_in_admin(self):
|
|
from django.apps import apps
|
|
|
|
registered = set(django_admin.site._registry)
|
|
# Concrete, non-auto-created models in these apps should all be registered.
|
|
project_apps = {"authentication", "club", "members", "teams", "events", "formbuilder", "shop"}
|
|
for model in apps.get_models():
|
|
if model._meta.app_label not in project_apps or model._meta.auto_created:
|
|
continue
|
|
with self.subTest(model=model.__name__):
|
|
self.assertIn(model, registered, f"{model.__name__} is not registered in the admin")
|
|
|
|
def test_all_changelists_load(self):
|
|
for model in django_admin.site._registry:
|
|
url = reverse(f"admin:{model._meta.app_label}_{model._meta.model_name}_changelist")
|
|
with self.subTest(model=model.__name__):
|
|
self.assertEqual(self.client.get(url).status_code, 200)
|
|
|
|
def test_all_add_pages_load(self):
|
|
for model in django_admin.site._registry:
|
|
url = reverse(f"admin:{model._meta.app_label}_{model._meta.model_name}_add")
|
|
with self.subTest(model=model.__name__):
|
|
self.assertEqual(self.client.get(url).status_code, 200)
|
|
|
|
|
|
class ClubArchivingTests(TestCase):
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
|
|
def test_a_new_club_is_active(self):
|
|
self.assertFalse(self.club.is_archived)
|
|
self.assertIn(self.club, Club.objects.active())
|
|
self.assertNotIn(self.club, Club.objects.archived())
|
|
|
|
def test_archive_and_restore(self):
|
|
self.club.archive()
|
|
|
|
self.assertTrue(self.club.is_archived)
|
|
self.assertIn(self.club, Club.objects.archived())
|
|
self.assertNotIn(self.club, Club.objects.active())
|
|
|
|
self.club.restore()
|
|
|
|
self.assertFalse(self.club.is_archived)
|
|
self.assertIn(self.club, Club.objects.active())
|
|
|
|
def test_archiving_twice_keeps_the_original_timestamp(self):
|
|
self.club.archive()
|
|
first = self.club.archived_at
|
|
|
|
self.club.archive()
|
|
|
|
self.assertEqual(self.club.archived_at, first)
|
|
|
|
def test_restoring_an_active_club_is_a_no_op(self):
|
|
self.club.restore()
|
|
|
|
self.assertFalse(self.club.is_archived)
|
|
|
|
def test_archiving_destroys_nothing(self):
|
|
season = make_season(self.club)
|
|
member = Member.objects.create(first_name="Jane", last_name="Doe")
|
|
ClubMembership.objects.create(club=self.club, member=member, season=season)
|
|
|
|
self.club.archive()
|
|
|
|
self.assertTrue(ClubMembership.objects.filter(club=self.club).exists())
|
|
self.assertTrue(Season.objects.filter(club=self.club).exists())
|
|
|
|
|
|
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=[".rosterchief.app"])
|
|
class ArchivedClubTenancyTests(TestCase):
|
|
"""An archived club's subdomain must stop resolving — that is what makes
|
|
archiving a real deactivation rather than a cosmetic flag."""
|
|
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
|
|
def setUp(self):
|
|
self.middleware = ClubTenantMiddleware(lambda request: "response")
|
|
|
|
def resolve(self):
|
|
request = RequestFactory().get("/", HTTP_HOST="ajax-united.rosterchief.app")
|
|
self.middleware(request)
|
|
return request.club
|
|
|
|
def test_active_club_resolves(self):
|
|
self.assertEqual(self.resolve(), self.club)
|
|
|
|
def test_archived_club_stops_resolving(self):
|
|
self.club.archive()
|
|
|
|
self.assertIsNone(self.resolve())
|
|
|
|
def test_restored_club_resolves_again(self):
|
|
self.club.archive()
|
|
self.club.restore()
|
|
|
|
self.assertEqual(self.resolve(), self.club)
|
|
|
|
|
|
class ClubRoleTests(TestCase):
|
|
def test_str(self):
|
|
club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
member = Member.objects.create(first_name="Jane", last_name="Doe")
|
|
role = ClubRole.objects.create(club=club, member=member)
|
|
|
|
self.assertEqual(str(role), f"{club} - {member}")
|
|
|
|
|
|
class ClubMembershipCleanTests(TestCase):
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
cls.other = Club.objects.create(name="Rival FC", slug="rival-fc")
|
|
today = timezone.localdate()
|
|
cls.season = Season.objects.create(club=cls.club, start_date=today, end_date=today + datetime.timedelta(days=300))
|
|
cls.other_season = Season.objects.create(club=cls.other, start_date=today, end_date=today + datetime.timedelta(days=300))
|
|
cls.member = Member.objects.create(first_name="Jane", last_name="Doe")
|
|
|
|
def test_rejects_cross_club_season(self):
|
|
membership = ClubMembership(club=self.club, member=self.member, season=self.other_season)
|
|
with self.assertRaises(ValidationError) as ctx:
|
|
membership.full_clean()
|
|
self.assertIn("season", ctx.exception.error_dict)
|
|
|
|
def test_accepts_same_club_season(self):
|
|
ClubMembership(club=self.club, member=self.member, season=self.season).full_clean()
|
|
|
|
|
|
class GuardianMembershipTests(TestCase):
|
|
"""A parent attached to the club only through their child -- see
|
|
ClubMembership.Kind. They hold the login and can be reached, but they are not
|
|
a member: no fee, absent from every member list and count, and not eligible
|
|
for a roster or staff spot."""
|
|
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
today = timezone.localdate()
|
|
cls.season = Season.objects.create(club=cls.club, start_date=today, end_date=today + datetime.timedelta(days=300))
|
|
cls.child = Member.objects.create(first_name="Jamie", last_name="Doe")
|
|
cls.parent = Member.objects.create(first_name="Taylor", last_name="Doe")
|
|
ClubMembership.objects.create(club=cls.club, member=cls.child, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE)
|
|
cls.guardianship = ClubMembership.objects.create(club=cls.club, member=cls.parent, season=cls.season, kind=ClubMembership.Kind.GUARDIAN, status=ClubMembership.StatusChoices.ACTIVE)
|
|
|
|
cls.admin_user = get_user_model().objects.create_user(email="guardian-admin@example.com", password="pw")
|
|
admin_member = Member.objects.create(user=cls.admin_user, first_name="Ada", last_name="Admin")
|
|
ClubRole.objects.create(club=cls.club, member=admin_member, role=ClubRole.Roles.ADMIN)
|
|
|
|
def test_a_guardian_is_not_a_visible_member(self):
|
|
visible = members_visible_to(self.admin_user, self.club)
|
|
|
|
self.assertIn(self.child, visible)
|
|
self.assertNotIn(self.parent, visible)
|
|
|
|
def test_a_guardian_is_visible_when_explicitly_asked_for(self):
|
|
# The group pickers and the person's own detail page ask for this: they're
|
|
# about a person, not about the member list.
|
|
visible = members_visible_to(self.admin_user, self.club, include_guardians=True)
|
|
|
|
self.assertIn(self.parent, visible)
|
|
|
|
def test_the_derived_member_role_does_not_leak_a_guardian_back_in(self):
|
|
# An active membership of any kind grants a MEMBER ClubRole (club/signals.py),
|
|
# so matching on "has any role in this club" would undo the exclusion.
|
|
self.assertTrue(ClubRole.objects.filter(club=self.club, member=self.parent, role=ClubRole.Roles.MEMBER).exists())
|
|
self.assertNotIn(self.parent, members_visible_to(self.admin_user, self.club))
|
|
|
|
def test_a_parent_who_also_plays_stays_a_member(self):
|
|
# Being a parent and being a member are independent; the family graph
|
|
# records the first, `kind` the second.
|
|
self.guardianship.kind = ClubMembership.Kind.MEMBER
|
|
self.guardianship.save(update_fields=["kind"])
|
|
|
|
self.assertIn(self.parent, members_visible_to(self.admin_user, self.club))
|
|
|
|
def test_a_guardian_who_is_also_on_a_roster_stays_visible(self):
|
|
# The guardian row alone would hide them; playing for a team must not be
|
|
# undone by their also being someone's parent.
|
|
team = Team.objects.create(club=self.club, name="First Team", short_name="1st")
|
|
position = Position.objects.create(club=self.club, name="Forward", short_name="FW")
|
|
TeamMembership.objects.create(team=team, member=self.parent, season=self.season, position=position)
|
|
|
|
self.assertIn(self.parent, members_visible_to(self.admin_user, self.club))
|
|
|
|
def test_a_guardian_is_not_eligible_for_a_roster_or_staff_spot(self):
|
|
self.assertIn(self.child, eligible_roster_members(self.club))
|
|
self.assertNotIn(self.parent, eligible_roster_members(self.club))
|
|
|
|
def test_a_guardian_cannot_owe_a_fee(self):
|
|
self.guardianship.fee_amount = Decimal("250.00")
|
|
|
|
with self.assertRaises(ValidationError) as ctx:
|
|
self.guardianship.full_clean()
|
|
|
|
self.assertIn("fee_amount", ctx.exception.error_dict)
|
|
|
|
def test_a_new_season_carries_guardians_forward(self):
|
|
# Their tie to the club isn't seasonal -- without this a parent silently
|
|
# drops off at the season boundary while their child stays enrolled.
|
|
generate_seasons(self.club, until=self.season.end_date + datetime.timedelta(days=400))
|
|
|
|
next_season = Season.objects.filter(club=self.club, start_date__gt=self.season.start_date).order_by("start_date").first()
|
|
self.assertIsNotNone(next_season)
|
|
self.assertTrue(ClubMembership.objects.filter(club=self.club, member=self.parent, season=next_season, kind=ClubMembership.Kind.GUARDIAN).exists())
|
|
|
|
def test_a_guardian_removed_from_the_latest_season_is_not_resurrected(self):
|
|
# Copied from the season immediately before, not from "any season ever",
|
|
# so a deliberate removal stays removed.
|
|
self.guardianship.delete()
|
|
|
|
generate_seasons(self.club, until=self.season.end_date + datetime.timedelta(days=400))
|
|
|
|
next_season = Season.objects.filter(club=self.club, start_date__gt=self.season.start_date).order_by("start_date").first()
|
|
self.assertFalse(ClubMembership.objects.filter(club=self.club, member=self.parent, season=next_season).exists())
|
|
|
|
|
|
class AccessServiceTests(TestCase):
|
|
# The clubs, season, teams and positions are pure scaffolding here -- every test
|
|
# builds its *own* people and assignments on top of them -- so they are created once
|
|
# per class rather than 28 times over.
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
cls.other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
|
|
today = timezone.localdate()
|
|
cls.season = Season.objects.create(club=cls.club, start_date=today, end_date=today + datetime.timedelta(days=300))
|
|
cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st")
|
|
cls.second_team = Team.objects.create(club=cls.club, name="Second Team", short_name="2nd")
|
|
cls.forward = Position.objects.create(club=cls.club, name="Forward", short_name="FW")
|
|
# Management staff (coach / team manager) vs. non-management staff (e.g. physio).
|
|
cls.coach_position = Position.objects.create(club=cls.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True)
|
|
cls.physio_position = Position.objects.create(club=cls.club, name="Physio", short_name="PH", staff_position=True, management_position=False)
|
|
|
|
def make_user_member(self, email):
|
|
user = get_user_model().objects.create_user(email=email, password="pw")
|
|
member = Member.objects.create(user=user, first_name=email.split("@")[0].title(), last_name="Doe")
|
|
return user, member
|
|
|
|
def grant(self, member, role):
|
|
return ClubRole.objects.create(club=self.club, member=member, role=role)
|
|
|
|
def make_coach(self, member, team=None):
|
|
return StaffAssignment.objects.create(team=team or self.team, member=member, season=self.season, position=self.coach_position)
|
|
|
|
def make_support_staff(self, member, team=None):
|
|
"""Staff on the team, but in a non-management position."""
|
|
return StaffAssignment.objects.create(team=team or self.team, member=member, season=self.season, position=self.physio_position)
|
|
|
|
def make_event(self, **kwargs):
|
|
kwargs.setdefault("club", self.club)
|
|
kwargs.setdefault("title", "Match")
|
|
kwargs.setdefault("start", timezone.now() + datetime.timedelta(days=1))
|
|
return Event.objects.create(**kwargs)
|
|
|
|
# --- has_club_role / roles_in_club ---
|
|
def test_has_club_role_is_scoped_to_role_and_club(self):
|
|
user, member = self.make_user_member("admin@example.com")
|
|
self.grant(member, ClubRole.Roles.ADMIN)
|
|
|
|
self.assertTrue(has_club_role(user, self.club, ClubRole.Roles.ADMIN))
|
|
self.assertFalse(has_club_role(user, self.club, ClubRole.Roles.EDITOR))
|
|
self.assertFalse(has_club_role(user, self.other_club, ClubRole.Roles.ADMIN))
|
|
|
|
def test_roles_in_club_includes_derived_coach_manager(self):
|
|
user, member = self.make_user_member("editor@example.com")
|
|
self.grant(member, ClubRole.Roles.EDITOR)
|
|
self.make_coach(member)
|
|
|
|
self.assertEqual(roles_in_club(user, self.club), {ClubRole.Roles.EDITOR, COACH_MANAGER})
|
|
|
|
def test_non_management_staff_gets_no_derived_role(self):
|
|
user, member = self.make_user_member("physio@example.com")
|
|
self.make_support_staff(member)
|
|
|
|
self.assertEqual(roles_in_club(user, self.club), set())
|
|
|
|
def test_roles_in_club_is_empty_for_outsider(self):
|
|
user, _ = self.make_user_member("nobody@example.com")
|
|
|
|
self.assertEqual(roles_in_club(user, self.club), set())
|
|
|
|
# --- teams_managed_by ---
|
|
def test_admin_manages_every_team(self):
|
|
user, member = self.make_user_member("admin@example.com")
|
|
self.grant(member, ClubRole.Roles.ADMIN)
|
|
|
|
self.assertEqual(set(teams_managed_by(user, self.club)), {self.team, self.second_team})
|
|
|
|
def test_coach_manages_only_their_team(self):
|
|
user, member = self.make_user_member("coach@example.com")
|
|
self.make_coach(member)
|
|
|
|
self.assertEqual(list(teams_managed_by(user, self.club)), [self.team])
|
|
|
|
def test_plain_member_manages_no_teams(self):
|
|
user, _ = self.make_user_member("plain@example.com")
|
|
|
|
self.assertEqual(list(teams_managed_by(user, self.club)), [])
|
|
|
|
def test_non_management_staff_manages_no_teams(self):
|
|
user, member = self.make_user_member("physio@example.com")
|
|
self.make_support_staff(member)
|
|
|
|
self.assertEqual(list(teams_managed_by(user, self.club)), [])
|
|
|
|
def test_non_management_staff_does_not_inherit_a_managers_team(self):
|
|
# The team has BOTH a manager and a non-management staffer. The staffer
|
|
# must not pick up the team just because *someone else* manages it.
|
|
_, manager = self.make_user_member("coach@example.com")
|
|
self.make_coach(manager)
|
|
physio_user, physio = self.make_user_member("physio@example.com")
|
|
self.make_support_staff(physio)
|
|
|
|
self.assertEqual(list(teams_managed_by(physio_user, self.club)), [])
|
|
|
|
def make_past_season(self):
|
|
return 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=1),
|
|
)
|
|
|
|
def test_a_former_seasons_coach_no_longer_manages_the_team(self):
|
|
# StaffAssignment is per-season: authority expires with it.
|
|
user, member = self.make_user_member("coach@example.com")
|
|
StaffAssignment.objects.create(team=self.team, member=member, season=self.make_past_season(), position=self.coach_position)
|
|
|
|
self.assertEqual(list(teams_managed_by(user, self.club)), [])
|
|
self.assertEqual(list(teams_staffed_by(user, self.club)), [])
|
|
self.assertFalse(roles_in_club(user, self.club))
|
|
|
|
def test_a_former_seasons_coach_cannot_edit_a_current_event(self):
|
|
user, member = self.make_user_member("coach@example.com")
|
|
StaffAssignment.objects.create(team=self.team, member=member, season=self.make_past_season(), position=self.coach_position)
|
|
event = self.make_event()
|
|
event.teams.add(self.team)
|
|
|
|
self.assertFalse(can_edit_event(user, event))
|
|
|
|
# --- members_visible_to ---
|
|
def test_admin_sees_all_club_members(self):
|
|
user, member = self.make_user_member("admin@example.com")
|
|
self.grant(member, ClubRole.Roles.ADMIN)
|
|
other = Member.objects.create(first_name="Other", last_name="Member")
|
|
ClubMembership.objects.create(club=self.club, member=member, season=self.season)
|
|
ClubMembership.objects.create(club=self.club, member=other, season=self.season)
|
|
|
|
self.assertEqual(set(members_visible_to(user, self.club)), {member, other})
|
|
|
|
def test_coach_sees_self_and_managed_roster(self):
|
|
user, member = self.make_user_member("coach@example.com")
|
|
self.make_coach(member)
|
|
player = Member.objects.create(first_name="Player", last_name="One")
|
|
TeamMembership.objects.create(team=self.team, member=player, season=self.season, position=self.forward)
|
|
unrelated = Member.objects.create(first_name="Un", last_name="Related")
|
|
|
|
visible = set(members_visible_to(user, self.club))
|
|
|
|
self.assertEqual(visible, {member, player})
|
|
self.assertNotIn(unrelated, visible)
|
|
|
|
def test_parent_sees_self_and_children(self):
|
|
user, parent = self.make_user_member("parent@example.com")
|
|
child = Member.objects.create(first_name="Kid", last_name="Doe")
|
|
family = Family.objects.create(name="Doe")
|
|
FamilyMembership.objects.create(family=family, member=parent, role=FamilyMembership.FamilyRole.PARENT)
|
|
FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD)
|
|
|
|
self.assertEqual(set(members_visible_to(user, self.club)), {parent, child})
|
|
|
|
def test_user_without_a_member_sees_nobody(self):
|
|
user = get_user_model().objects.create_user(email="ghost@example.com", password="pw")
|
|
|
|
self.assertEqual(list(members_visible_to(user, self.club)), [])
|
|
|
|
def test_non_management_staff_sees_the_roster_but_holds_no_authority(self):
|
|
# A physio can see the team they work with, but manages nothing.
|
|
user, member = self.make_user_member("physio@example.com")
|
|
self.make_support_staff(member)
|
|
player = Member.objects.create(first_name="Player", last_name="One")
|
|
TeamMembership.objects.create(team=self.team, member=player, season=self.season, position=self.forward)
|
|
|
|
self.assertEqual(set(members_visible_to(user, self.club)), {member, player})
|
|
self.assertEqual(list(teams_managed_by(user, self.club)), [])
|
|
self.assertEqual(list(teams_staffed_by(user, self.club)), [self.team])
|
|
|
|
def test_manager_also_sees_the_teams_other_staff(self):
|
|
user, manager = self.make_user_member("coach@example.com")
|
|
self.make_coach(manager)
|
|
_, physio = self.make_user_member("physio@example.com")
|
|
self.make_support_staff(physio)
|
|
|
|
self.assertIn(physio, set(members_visible_to(user, self.club)))
|
|
|
|
def test_admin_sees_members_without_a_club_membership(self):
|
|
user, admin = self.make_user_member("admin@example.com")
|
|
self.grant(admin, ClubRole.Roles.ADMIN)
|
|
_, coach = self.make_user_member("coach@example.com")
|
|
self.make_coach(coach) # staff, but no ClubMembership
|
|
|
|
visible = set(members_visible_to(user, self.club))
|
|
|
|
self.assertIn(coach, visible)
|
|
self.assertIn(admin, visible) # the admin sees themselves via their ClubRole
|
|
|
|
def test_roster_visibility_is_scoped_to_the_current_season(self):
|
|
user, member = self.make_user_member("coach@example.com")
|
|
self.make_coach(member)
|
|
old_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=1),
|
|
)
|
|
former_player = Member.objects.create(first_name="Former", last_name="Player")
|
|
TeamMembership.objects.create(team=self.team, member=former_player, season=old_season, position=self.forward)
|
|
|
|
self.assertNotIn(former_player, set(members_visible_to(user, self.club)))
|
|
|
|
# --- can_edit_event ---
|
|
def test_admin_can_edit_event(self):
|
|
user, member = self.make_user_member("admin@example.com")
|
|
self.grant(member, ClubRole.Roles.ADMIN)
|
|
|
|
self.assertTrue(can_edit_event(user, self.make_event()))
|
|
|
|
def test_editor_can_edit_event(self):
|
|
user, member = self.make_user_member("editor@example.com")
|
|
self.grant(member, ClubRole.Roles.EDITOR)
|
|
|
|
self.assertTrue(can_edit_event(user, self.make_event()))
|
|
|
|
def test_owner_can_edit_their_event(self):
|
|
user, member = self.make_user_member("owner@example.com")
|
|
|
|
self.assertTrue(can_edit_event(user, self.make_event(created_by=member)))
|
|
|
|
def test_coach_can_edit_their_teams_event(self):
|
|
user, member = self.make_user_member("coach@example.com")
|
|
self.make_coach(member)
|
|
event = self.make_event()
|
|
event.teams.add(self.team)
|
|
|
|
self.assertTrue(can_edit_event(user, event))
|
|
|
|
def test_coach_cannot_edit_another_teams_event(self):
|
|
user, member = self.make_user_member("coach@example.com")
|
|
self.make_coach(member)
|
|
event = self.make_event()
|
|
event.teams.add(self.second_team)
|
|
|
|
self.assertFalse(can_edit_event(user, event))
|
|
|
|
def test_plain_member_cannot_edit_event(self):
|
|
user, _ = self.make_user_member("plain@example.com")
|
|
|
|
self.assertFalse(can_edit_event(user, self.make_event()))
|
|
|
|
def test_non_management_staff_cannot_edit_their_teams_event(self):
|
|
user, member = self.make_user_member("physio@example.com")
|
|
self.make_support_staff(member)
|
|
event = self.make_event()
|
|
event.teams.add(self.team)
|
|
|
|
self.assertFalse(can_edit_event(user, event))
|
|
|
|
def test_non_management_staff_on_a_managed_team_cannot_edit_its_event(self):
|
|
# Same escalation shape as teams_managed_by: a manager exists on the team,
|
|
# but the physio must not inherit edit rights from them.
|
|
_, manager = self.make_user_member("coach@example.com")
|
|
self.make_coach(manager)
|
|
physio_user, physio = self.make_user_member("physio@example.com")
|
|
self.make_support_staff(physio)
|
|
event = self.make_event()
|
|
event.teams.add(self.team)
|
|
|
|
self.assertFalse(can_edit_event(physio_user, event))
|
|
|
|
# --- can_manage_shop ---
|
|
def test_only_admin_can_manage_shop(self):
|
|
admin_user, admin_member = self.make_user_member("admin@example.com")
|
|
self.grant(admin_member, ClubRole.Roles.ADMIN)
|
|
editor_user, editor_member = self.make_user_member("editor@example.com")
|
|
self.grant(editor_member, ClubRole.Roles.EDITOR)
|
|
|
|
self.assertTrue(can_manage_shop(admin_user, self.club))
|
|
self.assertFalse(can_manage_shop(editor_user, self.club))
|
|
|
|
# --- platform superuser bypass ---
|
|
def test_superuser_is_club_admin_everywhere_with_no_clubrole_at_all(self):
|
|
user, _ = self.make_user_member("root@example.com")
|
|
user.is_superuser = True
|
|
user.save()
|
|
|
|
self.assertTrue(is_club_admin(user, self.club))
|
|
self.assertTrue(is_club_admin(user, self.other_club))
|
|
self.assertTrue(has_management_access(user, self.club))
|
|
self.assertTrue(is_platform_superuser(user))
|
|
|
|
def test_a_plain_staff_flag_alone_is_not_the_superuser_bypass(self):
|
|
user, _ = self.make_user_member("staffonly@example.com")
|
|
user.is_staff = True
|
|
user.save()
|
|
|
|
self.assertFalse(is_club_admin(user, self.club))
|
|
self.assertFalse(is_platform_superuser(user))
|
|
|
|
def test_an_anonymous_user_is_never_the_superuser_bypass(self):
|
|
self.assertFalse(is_platform_superuser(AnonymousUser()))
|
|
|
|
# --- MEMBER_ADMIN / can_manage_members ---
|
|
def test_member_admin_role_grants_can_manage_members_but_not_is_club_admin(self):
|
|
user, member = self.make_user_member("memberadmin@example.com")
|
|
self.grant(member, ClubRole.Roles.MEMBER_ADMIN)
|
|
|
|
self.assertTrue(is_member_admin(user, self.club))
|
|
self.assertTrue(can_manage_members(user, self.club))
|
|
self.assertFalse(is_club_admin(user, self.club))
|
|
|
|
def test_real_admin_also_satisfies_can_manage_members(self):
|
|
user, member = self.make_user_member("admin@example.com")
|
|
self.grant(member, ClubRole.Roles.ADMIN)
|
|
|
|
self.assertTrue(can_manage_members(user, self.club))
|
|
|
|
def test_editor_alone_does_not_satisfy_can_manage_members(self):
|
|
user, member = self.make_user_member("editor@example.com")
|
|
self.grant(member, ClubRole.Roles.EDITOR)
|
|
|
|
self.assertFalse(can_manage_members(user, self.club))
|
|
|
|
def test_member_admin_counts_as_management_access(self):
|
|
user, member = self.make_user_member("memberadmin@example.com")
|
|
self.grant(member, ClubRole.Roles.MEMBER_ADMIN)
|
|
|
|
self.assertTrue(has_management_access(user, self.club))
|
|
|
|
def test_member_admin_in_one_club_has_no_bearing_on_another(self):
|
|
user, member = self.make_user_member("memberadmin@example.com")
|
|
ClubRole.objects.create(club=self.club, member=member, role=ClubRole.Roles.MEMBER_ADMIN)
|
|
|
|
self.assertFalse(can_manage_members(user, self.other_club))
|
|
|
|
|
|
class ClubRoleStatusSyncTests(TestCase):
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
today = timezone.localdate()
|
|
cls.season = Season.objects.create(club=cls.club, start_date=today, end_date=today + datetime.timedelta(days=300))
|
|
cls.member = Member.objects.create(first_name="Jane", last_name="Doe")
|
|
|
|
def roles(self):
|
|
return ClubRole.objects.filter(club=self.club, member=self.member)
|
|
|
|
def make_membership(self, status=ClubMembership.StatusChoices.ACTIVE):
|
|
return ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=status)
|
|
|
|
def test_active_membership_grants_member_role(self):
|
|
self.make_membership()
|
|
|
|
self.assertEqual(self.roles().get().role, ClubRole.Roles.MEMBER)
|
|
|
|
def test_pending_membership_grants_no_role(self):
|
|
self.make_membership(status=ClubMembership.StatusChoices.PENDING)
|
|
|
|
self.assertFalse(self.roles().exists())
|
|
|
|
def test_deactivating_membership_withdraws_member_role(self):
|
|
membership = self.make_membership()
|
|
self.assertTrue(self.roles().exists())
|
|
|
|
membership.status = ClubMembership.StatusChoices.LAPSED
|
|
membership.save()
|
|
|
|
self.assertFalse(self.roles().exists())
|
|
|
|
def test_deleting_membership_withdraws_member_role(self):
|
|
membership = self.make_membership()
|
|
|
|
membership.delete()
|
|
|
|
self.assertFalse(self.roles().exists())
|
|
|
|
def test_elevated_role_is_never_downgraded_or_removed(self):
|
|
ClubRole.objects.create(club=self.club, member=self.member, role=ClubRole.Roles.ADMIN)
|
|
|
|
membership = self.make_membership()
|
|
self.assertEqual(self.roles().get().role, ClubRole.Roles.ADMIN)
|
|
|
|
membership.status = ClubMembership.StatusChoices.CANCELLED
|
|
membership.save()
|
|
|
|
self.assertEqual(self.roles().get().role, ClubRole.Roles.ADMIN)
|
|
|
|
def test_editor_role_survives_a_lapsed_membership(self):
|
|
ClubRole.objects.create(club=self.club, member=self.member, role=ClubRole.Roles.EDITOR)
|
|
membership = self.make_membership()
|
|
|
|
membership.status = ClubMembership.StatusChoices.LAPSED
|
|
membership.save()
|
|
|
|
self.assertEqual(self.roles().get().role, ClubRole.Roles.EDITOR)
|
|
|
|
def test_elevated_role_survives_membership_deletion(self):
|
|
ClubRole.objects.create(club=self.club, member=self.member, role=ClubRole.Roles.ADMIN)
|
|
membership = self.make_membership()
|
|
|
|
membership.delete()
|
|
|
|
self.assertEqual(self.roles().get().role, ClubRole.Roles.ADMIN)
|
|
|
|
def test_elevated_role_survives_a_season_rollover(self):
|
|
# Last season's membership lapses and the new season's is still pending:
|
|
# the admin must not lose their role in the gap.
|
|
ClubRole.objects.create(club=self.club, member=self.member, role=ClubRole.Roles.ADMIN)
|
|
last_season = self.make_membership()
|
|
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),
|
|
)
|
|
|
|
last_season.status = ClubMembership.StatusChoices.LAPSED
|
|
last_season.save()
|
|
ClubMembership.objects.create(club=self.club, member=self.member, season=next_season, status=ClubMembership.StatusChoices.PENDING)
|
|
|
|
self.assertEqual(self.roles().get().role, ClubRole.Roles.ADMIN)
|
|
|
|
def test_elevated_access_and_login_survive_a_lapsed_membership(self):
|
|
# The whole point: a lapsed membership must not lock an admin out.
|
|
user = get_user_model().objects.create_user(email="admin@example.com", password="pw")
|
|
admin = Member.objects.create(user=user, first_name="Ada", last_name="Min")
|
|
ClubRole.objects.create(club=self.club, member=admin, role=ClubRole.Roles.ADMIN)
|
|
membership = ClubMembership.objects.create(club=self.club, member=admin, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
|
|
|
|
membership.status = ClubMembership.StatusChoices.CANCELLED
|
|
membership.save()
|
|
|
|
self.assertTrue(user.is_active) # can still log in
|
|
self.assertIn(ClubRole.Roles.ADMIN, roles_in_club(user, self.club))
|
|
self.assertTrue(has_club_role(user, self.club, ClubRole.Roles.ADMIN))
|
|
self.assertTrue(can_manage_shop(user, self.club))
|
|
|
|
|
|
@override_settings(
|
|
ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
|
|
ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"],
|
|
)
|
|
class BrandingTests(TestCase):
|
|
"""The auth screens are shared; only the skin they inherit differs per tenant."""
|
|
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
|
|
def login_page(self, host):
|
|
return self.client.get(reverse("account_login"), HTTP_HOST=host)
|
|
|
|
def test_the_base_domain_gets_the_platform_skin(self):
|
|
response = self.login_page("rosterchief.app")
|
|
|
|
self.assertTemplateUsed(response, "controlpanel/_auth_base.html")
|
|
self.assertTemplateNotUsed(response, "_club_base.html")
|
|
self.assertContains(response, "RosterChief")
|
|
self.assertIsNone(response.context["club"])
|
|
|
|
def test_a_club_subdomain_gets_the_club_skin(self):
|
|
response = self.login_page("ajax-united.rosterchief.app")
|
|
|
|
self.assertTemplateUsed(response, "_club_base.html")
|
|
self.assertTemplateNotUsed(response, "controlpanel/_auth_base.html")
|
|
self.assertContains(response, "Ajax United")
|
|
self.assertEqual(response.context["club"], self.club)
|
|
|
|
def test_an_archived_club_falls_back_to_the_platform_skin(self):
|
|
# The subdomain stops resolving, so there is no club to brand with.
|
|
self.club.archive()
|
|
|
|
self.assertTemplateUsed(self.login_page("ajax-united.rosterchief.app"), "controlpanel/_auth_base.html")
|
|
|
|
def test_a_club_without_a_logo_shows_its_initials_not_our_mark(self):
|
|
response = self.login_page("ajax-united.rosterchief.app")
|
|
|
|
self.assertContains(response, "AU")
|
|
self.assertNotContains(response, "rosterchief-dark.svg")
|
|
|
|
def test_a_club_logo_is_rendered_when_set(self):
|
|
self.club.logo = "clubs/ajax-united/crest.png"
|
|
self.club.save()
|
|
|
|
self.assertContains(self.login_page("ajax-united.rosterchief.app"), "clubs/ajax-united/crest.png")
|
|
|
|
def test_a_club_logo_gets_a_primary_coloured_ring(self):
|
|
self.club.logo = "clubs/ajax-united/crest.png"
|
|
self.club.save()
|
|
|
|
self.assertContains(self.login_page("ajax-united.rosterchief.app"), "ring-primary")
|
|
|
|
def test_a_club_colour_overrides_the_theme(self):
|
|
self.club.primary_color = "#1e40af"
|
|
self.club.save()
|
|
|
|
self.assertContains(self.login_page("ajax-united.rosterchief.app"), "--color-primary: #1e40af")
|
|
|
|
def test_no_colour_means_no_override(self):
|
|
self.assertNotContains(self.login_page("ajax-united.rosterchief.app"), "--color-primary")
|
|
|
|
def test_a_club_secondary_colour_overrides_the_theme(self):
|
|
self.club.secondary_color = "#be185d"
|
|
self.club.save()
|
|
|
|
self.assertContains(self.login_page("ajax-united.rosterchief.app"), "--color-secondary: #be185d")
|
|
|
|
def test_no_secondary_colour_means_no_override(self):
|
|
self.assertNotContains(self.login_page("ajax-united.rosterchief.app"), "--color-secondary")
|
|
|
|
|
|
@override_settings(
|
|
ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
|
|
ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"],
|
|
)
|
|
class ManagementBrandingTests(TestCase):
|
|
"""allauth's password-change/MFA/logout screens live under /accounts/, outside
|
|
/manage/, so branding() (this module) can't tell they were reached from the
|
|
management app's own user menu by path alone -- it also checks the session flag
|
|
ClubStaffRequiredMixin.dispatch sets (club/mixins.py). These are the tests for
|
|
that flag, as distinct from BrandingTests above (which only covers the plain
|
|
per-tenant split, never touching /manage/ at all)."""
|
|
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
cls.season = Season.objects.create(club=cls.club, start_date=timezone.localdate() - datetime.timedelta(days=30), end_date=timezone.localdate() + datetime.timedelta(days=300))
|
|
|
|
cls.staff_user = get_user_model().objects.create_user(email="staff@example.com", password="pw-secret-123")
|
|
member = Member.objects.create(user=cls.staff_user, first_name="Ada", last_name="Admin")
|
|
ClubMembership.objects.create(club=cls.club, member=member, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE)
|
|
ClubRole.objects.filter(club=cls.club, member=member).update(role=ClubRole.Roles.ADMIN)
|
|
Authenticator.objects.create(user=cls.staff_user, type=Authenticator.Type.TOTP, data={"secret": "JBSWY3DPEHPK3PXP"})
|
|
|
|
def test_the_change_password_screen_stays_club_branded_without_a_visit_to_manage(self):
|
|
self.client.force_login(self.staff_user)
|
|
|
|
response = self.client.get(reverse("account_change_password"), HTTP_HOST="ajax-united.rosterchief.app")
|
|
|
|
self.assertTemplateUsed(response, "_club_base.html")
|
|
self.assertTemplateNotUsed(response, "management/_auth_base.html")
|
|
|
|
def test_the_change_password_screen_gets_the_management_skin_after_visiting_manage(self):
|
|
self.client.force_login(self.staff_user)
|
|
self.client.get(reverse("management:home"), HTTP_HOST="ajax-united.rosterchief.app")
|
|
|
|
response = self.client.get(reverse("account_change_password"), HTTP_HOST="ajax-united.rosterchief.app")
|
|
|
|
self.assertTemplateUsed(response, "management/_auth_base.html")
|
|
self.assertContains(response, "Ajax United")
|
|
|
|
def test_the_mfa_index_screen_gets_the_management_skin_after_visiting_manage(self):
|
|
self.client.force_login(self.staff_user)
|
|
self.client.get(reverse("management:home"), HTTP_HOST="ajax-united.rosterchief.app")
|
|
|
|
response = self.client.get(reverse("mfa_index"), HTTP_HOST="ajax-united.rosterchief.app")
|
|
|
|
self.assertTemplateUsed(response, "management/_auth_base.html")
|
|
|
|
|
|
@override_settings(
|
|
ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
|
|
ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"],
|
|
)
|
|
class Custom403PageTests(TestCase):
|
|
"""Django's default 403 handler picks up templates/403.html automatically --
|
|
branded per tenant (base_template, same as maintenance.html) so a permission
|
|
error still looks like the app, not a bare Django error page. A club subdomain
|
|
itself splits further: a /manage/ URL gets the management app's own skin
|
|
(management/_auth_base.html) rather than the club's public one, matching every
|
|
other allauth-adjacent screen reached from inside the management app -- see
|
|
club/context_processors.py's MANAGEMENT_BASE_TEMPLATE."""
|
|
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
|
|
def test_a_manage_url_403_gets_the_management_skin(self):
|
|
member = get_user_model().objects.create_user(email="member-403@example.com", password="pw-secret-123")
|
|
self.client.force_login(member)
|
|
|
|
response = self.client.get(reverse("management:position_list"), HTTP_HOST="ajax-united.rosterchief.app")
|
|
|
|
self.assertEqual(response.status_code, 403)
|
|
self.assertContains(response, "Access denied", status_code=403)
|
|
self.assertContains(response, "Ajax United", status_code=403)
|
|
self.assertTemplateUsed(response, "management/_auth_base.html")
|
|
|
|
def test_the_base_domain_403_gets_the_platform_skin(self):
|
|
self.client.force_login(get_user_model().objects.create_user(email="platform-403@example.com", password="pw-secret-123"))
|
|
|
|
response = self.client.get(reverse("controlpanel:dashboard"))
|
|
|
|
self.assertEqual(response.status_code, 403)
|
|
self.assertContains(response, "Access denied", status_code=403)
|
|
self.assertTemplateUsed(response, "controlpanel/_auth_base.html")
|
|
self.assertContains(response, "RosterChief", status_code=403)
|
|
|
|
|
|
class ClubBrandingModelTests(TestCase):
|
|
def test_initials_use_the_first_two_words(self):
|
|
self.assertEqual(Club(name="Ajax United Football Club").initials, "AU")
|
|
self.assertEqual(Club(name="Ajax").initials, "A")
|
|
|
|
def test_text_on_a_pale_colour_is_black_and_on_a_dark_one_white(self):
|
|
# A club picking pale yellow must not get white-on-yellow buttons.
|
|
self.assertEqual(Club(primary_color="#fef08a").primary_content_color, "#000000")
|
|
self.assertEqual(Club(primary_color="#1e40af").primary_content_color, "#ffffff")
|
|
|
|
def test_no_colour_means_no_contrast_colour(self):
|
|
self.assertEqual(Club(primary_color="").primary_content_color, "")
|
|
|
|
def test_contrast_color_filter_matches_the_same_algorithm(self):
|
|
# HTML emails apply this to a literal fallback background (e.g.
|
|
# club.secondary_color|default:"#ec4899") rather than a club's own
|
|
# colour, so it can't be exercised through primary_content_color --
|
|
# but it must still agree with it for a colour a club actually set.
|
|
from club.templatetags.club_email import contrast_color
|
|
|
|
self.assertEqual(contrast_color("#ec4899"), Club(secondary_color="#ec4899").secondary_content_color)
|
|
self.assertEqual(contrast_color("#fef08a"), "#000000")
|
|
self.assertEqual(contrast_color("#1e40af"), "#ffffff")
|
|
|
|
def test_a_colour_must_be_a_hex_value(self):
|
|
club = Club(name="Ajax United", primary_color="blue")
|
|
|
|
with self.assertRaises(ValidationError):
|
|
club.full_clean()
|
|
|
|
def test_logos_are_stored_per_club(self):
|
|
self.assertEqual(club_logo_path(Club(slug="ajax-united"), "crest.png"), "clubs/ajax-united/crest.png")
|
|
|
|
|
|
@override_settings(
|
|
ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
|
|
ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"],
|
|
)
|
|
class RootViewTests(TestCase):
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
cls.user = get_user_model().objects.create_user(email="member@example.com", password="pw-secret-123")
|
|
|
|
def test_the_base_domain_hands_off_to_the_control_panel(self):
|
|
response = self.client.get("/", HTTP_HOST="rosterchief.app")
|
|
|
|
self.assertRedirects(response, reverse("controlpanel:dashboard"), fetch_redirect_response=False)
|
|
|
|
def test_a_club_subdomain_lands_on_the_club_home(self):
|
|
self.client.force_login(self.user)
|
|
|
|
response = self.client.get("/", HTTP_HOST="ajax-united.rosterchief.app")
|
|
|
|
self.assertTemplateUsed(response, "club/home.html")
|
|
self.assertContains(response, "Ajax United")
|
|
|
|
def test_the_club_home_requires_a_login(self):
|
|
response = self.client.get("/", HTTP_HOST="ajax-united.rosterchief.app")
|
|
|
|
self.assertRedirects(response, f"{reverse('account_login')}?next=/", fetch_redirect_response=False)
|
|
|
|
|
|
class FeeServiceTests(TestCase):
|
|
"""club.services.fees -- record_payment/mark_as_paid/remaining_balance, the
|
|
service layer behind the Memberships page's per-row payment actions."""
|
|
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
cls.season = make_season(cls.club)
|
|
cls.member = Member.objects.create(first_name="Jane", last_name="Doe")
|
|
cls.membership = ClubMembership.objects.create(
|
|
club=cls.club, member=cls.member, season=cls.season, status=ClubMembership.StatusChoices.PENDING, fee_amount=Decimal("150.00")
|
|
)
|
|
|
|
def roles(self):
|
|
return ClubRole.objects.filter(club=self.club, member=self.member)
|
|
|
|
def test_remaining_balance_starts_at_the_full_fee(self):
|
|
self.assertEqual(remaining_balance(self.membership), Decimal("150.00"))
|
|
|
|
def test_remaining_balance_is_never_negative(self):
|
|
record_payment(self.membership, amount=Decimal("200.00"))
|
|
|
|
self.assertEqual(remaining_balance(self.membership), Decimal("0.00"))
|
|
|
|
def test_a_partial_payment_creates_a_record_and_updates_the_running_total(self):
|
|
payment = record_payment(self.membership, amount=Decimal("50.00"), method=FeePayment.Method.CASH, reference="R1", note="first installment")
|
|
|
|
self.assertEqual(payment.membership, self.membership)
|
|
self.assertEqual(payment.amount, Decimal("50.00"))
|
|
self.membership.refresh_from_db()
|
|
self.assertEqual(self.membership.amount_paid, Decimal("50.00"))
|
|
self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PARTIALLY_PAID)
|
|
# Not yet settled -- status doesn't change on a partial payment.
|
|
self.assertEqual(self.membership.status, ClubMembership.StatusChoices.PENDING)
|
|
|
|
def test_multiple_partial_payments_accumulate(self):
|
|
record_payment(self.membership, amount=Decimal("50.00"))
|
|
record_payment(self.membership, amount=Decimal("60.00"))
|
|
|
|
self.membership.refresh_from_db()
|
|
self.assertEqual(self.membership.amount_paid, Decimal("110.00"))
|
|
self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PARTIALLY_PAID)
|
|
self.assertEqual(FeePayment.objects.filter(membership=self.membership).count(), 2)
|
|
|
|
def test_reaching_the_full_amount_settles_the_fee_but_leaves_status_pending(self):
|
|
# Paying in full only ever settles fee_status now -- activation is
|
|
# exclusively club.services.onboarding.approve_one/approve_all_clean's call
|
|
# (see OnboardingRequirement's docstring), so a membership can be fully paid
|
|
# and still sit PENDING until an admin actually approves it.
|
|
record_payment(self.membership, amount=Decimal("100.00"))
|
|
record_payment(self.membership, amount=Decimal("50.00"))
|
|
|
|
self.membership.refresh_from_db()
|
|
self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PAID)
|
|
self.assertEqual(self.membership.status, ClubMembership.StatusChoices.PENDING)
|
|
self.assertIsNone(self.membership.activated_at)
|
|
self.assertFalse(self.roles().filter(role=ClubRole.Roles.MEMBER).exists())
|
|
|
|
def test_settling_in_full_never_touches_activated_at(self):
|
|
earlier = datetime.date(2026, 1, 1)
|
|
self.membership.activated_at = earlier
|
|
self.membership.save()
|
|
|
|
record_payment(self.membership, amount=Decimal("150.00"))
|
|
|
|
self.membership.refresh_from_db()
|
|
self.assertEqual(self.membership.activated_at, earlier)
|
|
|
|
def test_a_waived_membership_is_untouched_by_a_payment(self):
|
|
self.membership.fee_status = ClubMembership.FeeStatus.WAIVED
|
|
self.membership.save()
|
|
|
|
record_payment(self.membership, amount=Decimal("50.00"))
|
|
|
|
self.membership.refresh_from_db()
|
|
self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.WAIVED)
|
|
|
|
def test_mark_as_paid_records_the_exact_remaining_balance(self):
|
|
record_payment(self.membership, amount=Decimal("100.00"))
|
|
|
|
mark_as_paid(self.membership)
|
|
|
|
self.membership.refresh_from_db()
|
|
self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PAID)
|
|
payment = FeePayment.objects.get(membership=self.membership, amount=Decimal("50.00"))
|
|
self.assertEqual(payment.note, "Marked as paid")
|
|
|
|
def test_mark_as_paid_with_no_fee_amount_set_skips_creating_a_zero_payment(self):
|
|
# FeePayment.amount has a MinValueValidator(0.01) -- a $0 "payment" isn't a
|
|
# real transaction, so this must flip the flags directly instead.
|
|
unpriced = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="No", last_name="Price"), season=self.season, status=ClubMembership.StatusChoices.PENDING)
|
|
|
|
mark_as_paid(unpriced)
|
|
|
|
unpriced.refresh_from_db()
|
|
self.assertEqual(unpriced.fee_status, ClubMembership.FeeStatus.PAID)
|
|
self.assertEqual(unpriced.status, ClubMembership.StatusChoices.PENDING)
|
|
self.assertFalse(FeePayment.objects.filter(membership=unpriced).exists())
|
|
|
|
def test_recorded_by_is_stored_on_the_payment(self):
|
|
user = get_user_model().objects.create_user(email="admin-fees@example.com", password="pw-secret-123")
|
|
|
|
payment = record_payment(self.membership, amount=Decimal("50.00"), recorded_by=user)
|
|
|
|
self.assertEqual(payment.recorded_by, user)
|
|
|
|
|
|
class OpenDuesRowsTests(TestCase):
|
|
"""club.services.fees.open_dues_rows -- the shared source behind mobile's
|
|
Home dues card and its Payments & dues screen (mobile/views.py's HomeView
|
|
and PaymentsView)."""
|
|
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
cls.season = make_season(cls.club)
|
|
cls.member = Member.objects.create(first_name="Jane", last_name="Doe")
|
|
|
|
def test_returns_nothing_without_a_season(self):
|
|
ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, fee_amount=Decimal("150.00"))
|
|
|
|
self.assertEqual(open_dues_rows(self.club, [self.member], None), [])
|
|
|
|
def test_returns_nothing_without_any_people(self):
|
|
self.assertEqual(open_dues_rows(self.club, [], self.season), [])
|
|
|
|
def test_a_membership_with_a_remaining_balance_is_included(self):
|
|
membership = ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, fee_amount=Decimal("150.00"))
|
|
|
|
rows = open_dues_rows(self.club, [self.member], self.season)
|
|
|
|
self.assertEqual(len(rows), 1)
|
|
self.assertEqual(rows[0]["membership"], membership)
|
|
self.assertEqual(rows[0]["balance"], Decimal("150.00"))
|
|
self.assertIsNone(rows[0]["invoice"])
|
|
|
|
def test_a_fully_paid_membership_is_excluded(self):
|
|
membership = ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, fee_amount=Decimal("150.00"))
|
|
record_payment(membership, amount=Decimal("150.00"))
|
|
|
|
self.assertEqual(open_dues_rows(self.club, [self.member], self.season), [])
|
|
|
|
def test_a_waived_membership_is_excluded_even_with_an_unpaid_balance(self):
|
|
ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, fee_amount=Decimal("150.00"), fee_status=ClubMembership.FeeStatus.WAIVED)
|
|
|
|
self.assertEqual(open_dues_rows(self.club, [self.member], self.season), [])
|
|
|
|
def test_a_membership_with_no_fee_priced_is_excluded(self):
|
|
ClubMembership.objects.create(club=self.club, member=self.member, season=self.season)
|
|
|
|
self.assertEqual(open_dues_rows(self.club, [self.member], self.season), [])
|
|
|
|
def test_the_linked_invoice_is_included_when_one_exists(self):
|
|
membership = ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, fee_amount=Decimal("150.00"))
|
|
invoice = DuesInvoice.objects.create(club=self.club, membership=membership, amount=Decimal("150.00"), due_date=timezone.now().date(), sent_at=timezone.now())
|
|
|
|
rows = open_dues_rows(self.club, [self.member], self.season)
|
|
|
|
self.assertEqual(rows[0]["invoice"], invoice)
|
|
|
|
def test_only_includes_the_given_people(self):
|
|
other_member = Member.objects.create(first_name="Tom", last_name="Roe")
|
|
ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, fee_amount=Decimal("150.00"))
|
|
ClubMembership.objects.create(club=self.club, member=other_member, season=self.season, fee_amount=Decimal("150.00"))
|
|
|
|
rows = open_dues_rows(self.club, [self.member], self.season)
|
|
|
|
self.assertEqual([row["membership"].member for row in rows], [self.member])
|
|
|
|
|
|
class RecipientForTests(TestCase):
|
|
"""club.services.invoicing.recipient_for -- the member's own email, else the
|
|
first parent/guardian who has one, else nobody reachable at all."""
|
|
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
cls.season = make_season(cls.club)
|
|
|
|
def test_the_members_own_email_wins(self):
|
|
member = Member.objects.create(first_name="Jane", last_name="Doe", email="jane@example.com")
|
|
|
|
email, used_guardian = recipient_for(member)
|
|
|
|
self.assertEqual(email, "jane@example.com")
|
|
self.assertFalse(used_guardian)
|
|
|
|
def test_falls_back_to_a_guardians_email_when_the_member_has_none(self):
|
|
family = Family.objects.create()
|
|
member = Member.objects.create(first_name="Jane", last_name="Doe")
|
|
parent = Member.objects.create(first_name="Pat", last_name="Doe", email="pat@example.com")
|
|
FamilyMembership.objects.create(family=family, member=member, role=FamilyMembership.FamilyRole.CHILD)
|
|
FamilyMembership.objects.create(family=family, member=parent, role=FamilyMembership.FamilyRole.PARENT)
|
|
|
|
email, used_guardian = recipient_for(member)
|
|
|
|
self.assertEqual(email, "pat@example.com")
|
|
self.assertTrue(used_guardian)
|
|
|
|
def test_empty_when_nobody_is_reachable(self):
|
|
family = Family.objects.create()
|
|
member = Member.objects.create(first_name="Jane", last_name="Doe")
|
|
parent = Member.objects.create(first_name="Pat", last_name="Doe")
|
|
FamilyMembership.objects.create(family=family, member=member, role=FamilyMembership.FamilyRole.CHILD)
|
|
FamilyMembership.objects.create(family=family, member=parent, role=FamilyMembership.FamilyRole.PARENT)
|
|
|
|
email, used_guardian = recipient_for(member)
|
|
|
|
self.assertEqual(email, "")
|
|
self.assertFalse(used_guardian)
|
|
|
|
|
|
class CreateOrResendInvoiceTests(TestCase):
|
|
"""club.services.invoicing.create_or_resend_invoice -- one invoice per
|
|
membership, numbered once, re-snapshotted on every send."""
|
|
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
cls.season = make_season(cls.club)
|
|
cls.member = Member.objects.create(first_name="Jane", last_name="Doe", email="jane@example.com")
|
|
cls.membership = ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season, status=ClubMembership.StatusChoices.PENDING, fee_amount=Decimal("150.00"))
|
|
|
|
def test_amount_is_the_remaining_balance_not_the_full_fee(self):
|
|
record_payment(self.membership, amount=Decimal("50.00"))
|
|
|
|
invoice = create_or_resend_invoice(self.membership, due_in_days=14, recipient_email="jane@example.com", sent_to_guardian=False)
|
|
|
|
self.assertEqual(invoice.amount, Decimal("100.00"))
|
|
|
|
def test_due_date_is_today_plus_due_in_days(self):
|
|
invoice = create_or_resend_invoice(self.membership, due_in_days=10, recipient_email="jane@example.com", sent_to_guardian=False)
|
|
|
|
self.assertEqual(invoice.due_date, timezone.now().date() + datetime.timedelta(days=10))
|
|
|
|
def test_a_number_is_allocated_once(self):
|
|
invoice = create_or_resend_invoice(self.membership, due_in_days=14, recipient_email="jane@example.com", sent_to_guardian=False)
|
|
first_number = invoice.number
|
|
|
|
resent = create_or_resend_invoice(self.membership, due_in_days=30, recipient_email="jane@example.com", sent_to_guardian=False)
|
|
|
|
self.assertEqual(resent.pk, invoice.pk)
|
|
self.assertEqual(resent.number, first_number)
|
|
self.assertEqual(resent.due_date, timezone.now().date() + datetime.timedelta(days=30))
|
|
|
|
def test_a_membership_can_only_ever_have_one_invoice_row(self):
|
|
create_or_resend_invoice(self.membership, due_in_days=14, recipient_email="jane@example.com", sent_to_guardian=False)
|
|
create_or_resend_invoice(self.membership, due_in_days=14, recipient_email="jane@example.com", sent_to_guardian=False)
|
|
|
|
self.assertEqual(DuesInvoice.objects.filter(membership=self.membership).count(), 1)
|
|
|
|
|
|
class InvoicePdfTests(TestCase):
|
|
"""club.services.invoicing.invoice_pdf -- same header convention as
|
|
management/event_referee_form_pdf.html: legal name, and the club's home
|
|
location, never any other one."""
|
|
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united", legal_name="Ajax United VZW")
|
|
cls.season = make_season(cls.club)
|
|
cls.member = Member.objects.create(first_name="Jane", last_name="Doe", email="jane@example.com")
|
|
cls.membership = ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season, status=ClubMembership.StatusChoices.PENDING, fee_amount=Decimal("150.00"))
|
|
cls.invoice = create_or_resend_invoice(cls.membership, due_in_days=14, recipient_email="jane@example.com", sent_to_guardian=False)
|
|
|
|
def render(self):
|
|
with mock.patch("club.services.invoicing.render_pdf", side_effect=lambda html: html) as renderer:
|
|
invoice_pdf(self.invoice)
|
|
return renderer.call_args[0][0]
|
|
|
|
def test_the_header_uses_the_legal_name(self):
|
|
html = self.render()
|
|
|
|
self.assertIn("Ajax United VZW", html)
|
|
|
|
def test_the_header_uses_the_clubs_home_location(self):
|
|
Location.objects.create(club=self.club, name="Sports Hall", address="Sportlaan 1", zip_code="1000", city="Brussels", is_home=True)
|
|
Location.objects.create(club=self.club, name="Away ground", address="Elsewhere 2", zip_code="2000", city="Antwerp", is_home=False)
|
|
|
|
html = self.render()
|
|
|
|
self.assertIn("Sportlaan 1", html)
|
|
self.assertNotIn("Elsewhere 2", html)
|
|
|
|
def test_renders_fine_with_no_home_location_set(self):
|
|
self.assertEqual(Location.objects.filter(club=self.club, is_home=True).count(), 0)
|
|
|
|
html = self.render()
|
|
|
|
self.assertIn(self.invoice.number, html)
|
|
|
|
def test_the_legal_address_takes_precedence_over_the_home_location(self):
|
|
Location.objects.create(club=self.club, name="Sports Hall", address="Sportlaan 1", zip_code="1000", city="Brussels", is_home=True)
|
|
self.club.legal_address = "Registered Office 5"
|
|
self.club.legal_zip_code = "9000"
|
|
self.club.legal_city = "Ghent"
|
|
self.club.save(update_fields=["legal_address", "legal_zip_code", "legal_city"])
|
|
|
|
html = self.render()
|
|
|
|
self.assertIn("Registered Office 5", html)
|
|
self.assertNotIn("Sportlaan 1", html)
|
|
|
|
|
|
class ResolveDocumentAddressTests(TestCase):
|
|
"""club.services.invoicing.resolve_document_address -- the club's own
|
|
legal_address when set, else its home Location, else None."""
|
|
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
|
|
def test_returns_none_with_neither_set(self):
|
|
self.assertIsNone(resolve_document_address(self.club))
|
|
|
|
def test_falls_back_to_the_home_location(self):
|
|
home = Location.objects.create(club=self.club, name="Sports Hall", address="Sportlaan 1", zip_code="1000", city="Brussels", is_home=True)
|
|
|
|
self.assertEqual(resolve_document_address(self.club), home)
|
|
|
|
def test_legal_address_wins_over_the_home_location(self):
|
|
Location.objects.create(club=self.club, name="Sports Hall", address="Sportlaan 1", zip_code="1000", city="Brussels", is_home=True)
|
|
self.club.legal_address = "Registered Office 5"
|
|
self.club.legal_zip_code = "9000"
|
|
self.club.legal_city = "Ghent"
|
|
self.club.save(update_fields=["legal_address", "legal_zip_code", "legal_city"])
|
|
|
|
address = resolve_document_address(self.club)
|
|
|
|
self.assertEqual(address.address, "Registered Office 5")
|
|
self.assertEqual(address.zip_code, "9000")
|
|
self.assertEqual(address.city, "Ghent")
|
|
|
|
|
|
class InvoicesDueForReminderTests(TestCase):
|
|
"""club.services.invoicing.invoices_due_for_reminder -- sent, unpaid, past
|
|
their own due date; never paid, waived, or not yet due."""
|
|
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
cls.season = make_season(cls.club)
|
|
|
|
def make_invoice(self, *, fee_status, due_date, sent_at=None):
|
|
member = Member.objects.create(first_name="Member", last_name=fee_status)
|
|
membership = ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_amount=Decimal("100.00"), fee_status=fee_status)
|
|
return DuesInvoice.objects.create(club=self.club, membership=membership, amount=Decimal("100.00"), due_date=due_date, sent_at=sent_at or timezone.now())
|
|
|
|
def test_includes_an_overdue_unpaid_invoice(self):
|
|
overdue = self.make_invoice(fee_status=ClubMembership.FeeStatus.UNPAID, due_date=timezone.now().date() - datetime.timedelta(days=1))
|
|
|
|
self.assertIn(overdue, invoices_due_for_reminder(self.club))
|
|
|
|
def test_excludes_a_paid_invoice(self):
|
|
paid = self.make_invoice(fee_status=ClubMembership.FeeStatus.PAID, due_date=timezone.now().date() - datetime.timedelta(days=1))
|
|
|
|
self.assertNotIn(paid, invoices_due_for_reminder(self.club))
|
|
|
|
def test_excludes_a_waived_invoice(self):
|
|
waived = self.make_invoice(fee_status=ClubMembership.FeeStatus.WAIVED, due_date=timezone.now().date() - datetime.timedelta(days=1))
|
|
|
|
self.assertNotIn(waived, invoices_due_for_reminder(self.club))
|
|
|
|
def test_excludes_one_not_yet_due(self):
|
|
not_due = self.make_invoice(fee_status=ClubMembership.FeeStatus.UNPAID, due_date=timezone.now().date() + datetime.timedelta(days=5))
|
|
|
|
self.assertNotIn(not_due, invoices_due_for_reminder(self.club))
|
|
|
|
|
|
class SeasonStartEndTests(TestCase):
|
|
"""club.services.seasons._initial_season_start / _season_end -- the
|
|
per-club rules generate_seasons chains off, now that a club's own
|
|
season_start/season_duration_months drive them instead of a fixed Aug-May
|
|
window."""
|
|
|
|
# Every test here reassigns a field on its own copy of the club without saving it;
|
|
# setUpTestData's per-test deep copy is what keeps that from leaking sideways.
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
|
|
def test_a_date_past_this_years_anchor_uses_this_year(self):
|
|
self.club.season_start = datetime.date(2000, 8, 1)
|
|
|
|
start = _initial_season_start(self.club, datetime.date(2026, 8, 15))
|
|
|
|
self.assertEqual(start, datetime.date(2026, 8, 1))
|
|
|
|
def test_a_date_before_this_years_anchor_uses_last_year(self):
|
|
self.club.season_start = datetime.date(2000, 8, 1)
|
|
|
|
start = _initial_season_start(self.club, datetime.date(2027, 2, 1))
|
|
|
|
self.assertEqual(start, datetime.date(2026, 8, 1))
|
|
|
|
def test_the_anchor_date_itself_uses_this_year(self):
|
|
self.club.season_start = datetime.date(2000, 8, 1)
|
|
|
|
start = _initial_season_start(self.club, datetime.date(2026, 8, 1))
|
|
|
|
self.assertEqual(start, datetime.date(2026, 8, 1))
|
|
|
|
def test_season_end_is_the_day_before_the_start_plus_the_duration(self):
|
|
self.club.season_duration_months = 12
|
|
|
|
end = _season_end(datetime.date(2026, 8, 1), self.club)
|
|
|
|
self.assertEqual(end, datetime.date(2027, 7, 31))
|
|
|
|
def test_a_shorter_duration_produces_a_shorter_season(self):
|
|
self.club.season_duration_months = 6
|
|
|
|
end = _season_end(datetime.date(2026, 8, 1), self.club)
|
|
|
|
self.assertEqual(end, datetime.date(2027, 1, 31))
|
|
|
|
|
|
class GenerateSeasonsTests(TestCase):
|
|
"""club.services.seasons.generate_seasons -- the service behind the
|
|
generate_seasons management command."""
|
|
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
|
|
def test_generates_a_season_covering_today(self):
|
|
today = timezone.localdate()
|
|
|
|
generate_seasons(self.club, today)
|
|
|
|
self.assertTrue(Season.objects.filter(club=self.club, start_date__lte=today, end_date__gte=today).exists())
|
|
|
|
def test_generates_every_window_through_the_horizon(self):
|
|
today = timezone.localdate()
|
|
until = today + relativedelta(years=2)
|
|
|
|
created = generate_seasons(self.club, until)
|
|
|
|
self.assertGreaterEqual(len(created), 2)
|
|
for season in created:
|
|
self.assertLessEqual(season.start_date, until)
|
|
|
|
def test_is_idempotent_on_a_second_run(self):
|
|
until = timezone.localdate() + relativedelta(years=2)
|
|
generate_seasons(self.club, until)
|
|
count_after_first = Season.objects.filter(club=self.club).count()
|
|
|
|
second_run = generate_seasons(self.club, until)
|
|
|
|
self.assertEqual(second_run, [])
|
|
self.assertEqual(Season.objects.filter(club=self.club).count(), count_after_first)
|
|
|
|
def test_a_new_season_starts_the_day_after_the_last_one_ends(self):
|
|
today = timezone.localdate()
|
|
generate_seasons(self.club, today)
|
|
latest = Season.objects.filter(club=self.club).order_by("-end_date").first()
|
|
|
|
generate_seasons(self.club, latest.end_date + relativedelta(months=self.club.season_duration_months))
|
|
|
|
next_season = Season.objects.filter(club=self.club, start_date=latest.end_date + datetime.timedelta(days=1)).first()
|
|
self.assertIsNotNone(next_season)
|
|
|
|
def test_changing_the_duration_only_affects_the_next_generated_season(self):
|
|
today = timezone.localdate()
|
|
generate_seasons(self.club, today)
|
|
first = Season.objects.filter(club=self.club).order_by("-end_date").first()
|
|
|
|
self.club.season_duration_months = 6
|
|
self.club.save()
|
|
generate_seasons(self.club, first.end_date + relativedelta(months=6))
|
|
|
|
first_end_before = first.end_date
|
|
first.refresh_from_db()
|
|
self.assertEqual(first.end_date, first_end_before) # existing season untouched
|
|
second = Season.objects.get(club=self.club, start_date=first.end_date + datetime.timedelta(days=1))
|
|
self.assertEqual(second.end_date, second.start_date + relativedelta(months=6) - datetime.timedelta(days=1))
|
|
|
|
def test_does_not_disturb_a_pre_existing_irregular_season(self):
|
|
odd = Season.objects.create(club=self.club, start_date=datetime.date(2020, 3, 1), end_date=datetime.date(2020, 9, 1))
|
|
|
|
generate_seasons(self.club, timezone.localdate())
|
|
|
|
odd.refresh_from_db()
|
|
self.assertEqual(odd.start_date, datetime.date(2020, 3, 1))
|
|
self.assertEqual(odd.end_date, datetime.date(2020, 9, 1))
|
|
|
|
|
|
class ResyncSeasonsTests(TestCase):
|
|
"""club.services.seasons.resync_seasons -- cleaning up seasons that don't
|
|
match a club's current settings (e.g. left over from a since-changed
|
|
season_start/season_duration_months)."""
|
|
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
cls.until = timezone.localdate() + relativedelta(years=2)
|
|
|
|
def test_a_wrong_and_unreferenced_season_is_reported_as_removable(self):
|
|
wrong = Season.objects.create(club=self.club, start_date=datetime.date(2020, 3, 1), end_date=datetime.date(2020, 9, 1))
|
|
|
|
removed, kept = resync_seasons(self.club, self.until)
|
|
|
|
self.assertIn(wrong, removed)
|
|
self.assertEqual(kept, [])
|
|
|
|
def test_commit_actually_deletes_a_wrong_unreferenced_season(self):
|
|
wrong = Season.objects.create(club=self.club, start_date=datetime.date(2020, 3, 1), end_date=datetime.date(2020, 9, 1))
|
|
|
|
resync_seasons(self.club, self.until, commit=True)
|
|
|
|
self.assertFalse(Season.objects.filter(pk=wrong.pk).exists())
|
|
|
|
def test_without_commit_nothing_is_actually_deleted(self):
|
|
wrong = Season.objects.create(club=self.club, start_date=datetime.date(2020, 3, 1), end_date=datetime.date(2020, 9, 1))
|
|
|
|
resync_seasons(self.club, self.until, commit=False)
|
|
|
|
self.assertTrue(Season.objects.filter(pk=wrong.pk).exists())
|
|
|
|
def test_a_wrong_but_referenced_season_is_kept_not_removed(self):
|
|
wrong = Season.objects.create(club=self.club, start_date=datetime.date(2020, 3, 1), end_date=datetime.date(2020, 9, 1))
|
|
member = Member.objects.create(first_name="Jane", last_name="Doe")
|
|
ClubMembership.objects.create(club=self.club, member=member, season=wrong)
|
|
|
|
removed, kept = resync_seasons(self.club, self.until, commit=True)
|
|
|
|
self.assertEqual(removed, [])
|
|
self.assertIn(wrong, kept)
|
|
self.assertTrue(Season.objects.filter(pk=wrong.pk).exists())
|
|
|
|
def test_a_season_matching_current_settings_is_left_alone(self):
|
|
generate_seasons(self.club, timezone.localdate())
|
|
|
|
removed, kept = resync_seasons(self.club, self.until)
|
|
|
|
self.assertEqual(removed, [])
|
|
self.assertEqual(kept, [])
|
|
|
|
def test_a_season_referenced_only_via_staff_assignment_is_kept_not_removed(self):
|
|
# Season is PROTECTed by more than just ClubMembership -- a season kept
|
|
# alive only through a StaffAssignment must not be silently deleted either.
|
|
wrong = Season.objects.create(club=self.club, start_date=datetime.date(2020, 3, 1), end_date=datetime.date(2020, 9, 1))
|
|
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)
|
|
member = Member.objects.create(first_name="Jane", last_name="Doe")
|
|
StaffAssignment.objects.create(team=team, member=member, season=wrong, position=position)
|
|
|
|
removed, kept = resync_seasons(self.club, self.until, commit=True)
|
|
|
|
self.assertEqual(removed, [])
|
|
self.assertIn(wrong, kept)
|
|
self.assertTrue(Season.objects.filter(pk=wrong.pk).exists())
|
|
|
|
|
|
class GenerateSeasonsCommandTests(TestCase):
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
|
|
def test_default_years_is_two(self):
|
|
call_command("generate_seasons", stdout=StringIO())
|
|
|
|
today = timezone.localdate()
|
|
until = today + relativedelta(years=2)
|
|
seasons = Season.objects.filter(club=self.club).order_by("start_date")
|
|
self.assertTrue(seasons.exists())
|
|
for season in seasons:
|
|
self.assertLessEqual(season.start_date, until)
|
|
self.assertTrue(seasons.filter(start_date__lte=today, end_date__gte=today).exists())
|
|
|
|
def test_years_argument_controls_the_horizon(self):
|
|
call_command("generate_seasons", "--years", "1", stdout=StringIO())
|
|
|
|
until = timezone.localdate() + relativedelta(years=1)
|
|
for season in Season.objects.filter(club=self.club):
|
|
self.assertLessEqual(season.start_date, until)
|
|
|
|
def test_archived_clubs_are_skipped(self):
|
|
self.club.archive()
|
|
|
|
call_command("generate_seasons", stdout=StringIO())
|
|
|
|
self.assertFalse(Season.objects.filter(club=self.club).exists())
|
|
|
|
def test_second_run_creates_nothing_new(self):
|
|
call_command("generate_seasons", stdout=StringIO())
|
|
count_after_first = Season.objects.filter(club=self.club).count()
|
|
|
|
out = StringIO()
|
|
call_command("generate_seasons", stdout=out)
|
|
|
|
self.assertEqual(Season.objects.filter(club=self.club).count(), count_after_first)
|
|
self.assertIn("Generated 0 season", out.getvalue())
|
|
|
|
def test_resync_without_commit_reports_but_does_not_delete(self):
|
|
wrong = Season.objects.create(club=self.club, start_date=datetime.date(2020, 3, 1), end_date=datetime.date(2020, 9, 1))
|
|
|
|
out = StringIO()
|
|
call_command("generate_seasons", "--resync", stdout=out)
|
|
|
|
self.assertTrue(Season.objects.filter(pk=wrong.pk).exists())
|
|
self.assertIn("Would remove", out.getvalue())
|
|
|
|
def test_resync_with_commit_deletes_the_wrong_season(self):
|
|
wrong = Season.objects.create(club=self.club, start_date=datetime.date(2020, 3, 1), end_date=datetime.date(2020, 9, 1))
|
|
|
|
call_command("generate_seasons", "--resync", "--commit", stdout=StringIO())
|
|
|
|
self.assertFalse(Season.objects.filter(pk=wrong.pk).exists())
|
|
|
|
|
|
|
|
class OnboardingRequirementTests(TestCase):
|
|
"""club.services.onboarding -- deliberately orthogonal to status/fee_status (see
|
|
OnboardingRequirement's docstring): a fully paid, active membership can still
|
|
have open requirements, and neither field moves when one is marked complete."""
|
|
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
cls.season = make_season(cls.club)
|
|
cls.member = Member.objects.create(first_name="Jane", last_name="Doe")
|
|
cls.membership = ClubMembership.objects.create(
|
|
club=cls.club, member=cls.member, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE, fee_status=ClubMembership.FeeStatus.PAID
|
|
)
|
|
cls.staff = get_user_model().objects.create_user(email="staff@example.com", password="pw-secret-123")
|
|
cls.photo = OnboardingRequirement.objects.create(club=cls.club, name="Photo")
|
|
cls.medical = OnboardingRequirement.objects.create(club=cls.club, name="Medical certificate", requires_document=True)
|
|
|
|
def test_a_membership_with_no_status_rows_has_every_requirement_open(self):
|
|
self.assertEqual(self.membership.open_requirement_count, 2)
|
|
self.assertFalse(self.membership.onboarding_complete)
|
|
|
|
def test_marking_one_complete_leaves_the_other_open(self):
|
|
mark_complete(self.membership, self.photo, user=self.staff)
|
|
|
|
self.assertEqual(self.membership.open_requirement_count, 1)
|
|
self.assertFalse(self.membership.onboarding_complete)
|
|
|
|
def test_completing_every_requirement_clears_the_membership(self):
|
|
mark_complete(self.membership, self.photo, user=self.staff)
|
|
mark_complete(self.membership, self.medical, user=self.staff)
|
|
|
|
self.assertTrue(self.membership.onboarding_complete)
|
|
|
|
def test_marking_complete_never_touches_status_or_fee_status(self):
|
|
# The whole point: a document upload must never re-derive membership state --
|
|
# club.services.fees owns status/fee_status exclusively.
|
|
unpaid = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="Tom", last_name="Roe"), season=self.season, status=ClubMembership.StatusChoices.PENDING)
|
|
|
|
mark_complete(unpaid, self.photo, user=self.staff)
|
|
mark_complete(unpaid, self.medical, user=self.staff)
|
|
unpaid.refresh_from_db()
|
|
|
|
self.assertTrue(unpaid.onboarding_complete)
|
|
self.assertEqual(unpaid.status, ClubMembership.StatusChoices.PENDING)
|
|
self.assertEqual(unpaid.fee_status, ClubMembership.FeeStatus.UNPAID)
|
|
|
|
def test_mark_complete_records_who_and_when(self):
|
|
status = mark_complete(self.membership, self.medical, user=self.staff, note="emailed 12 Aug")
|
|
|
|
self.assertTrue(status.is_complete)
|
|
self.assertEqual(status.completed_by, self.staff)
|
|
self.assertIsNotNone(status.completed_at)
|
|
self.assertEqual(status.note, "emailed 12 Aug")
|
|
|
|
def test_mark_complete_is_idempotent_per_requirement(self):
|
|
mark_complete(self.membership, self.photo, user=self.staff)
|
|
mark_complete(self.membership, self.photo, user=self.staff)
|
|
|
|
self.assertEqual(MemberRequirementStatus.objects.filter(membership=self.membership, requirement=self.photo).count(), 1)
|
|
|
|
def test_mark_incomplete_undoes_it_without_deleting_the_row(self):
|
|
mark_complete(self.membership, self.photo, user=self.staff, note="handed in at practice")
|
|
status = mark_incomplete(self.membership, self.photo)
|
|
|
|
self.assertFalse(status.is_complete)
|
|
self.assertIsNone(status.completed_at)
|
|
self.assertIsNone(status.completed_by)
|
|
# The note (and any document) survive the toggle -- it's evidence something
|
|
# was received once, even if it needs redoing.
|
|
self.assertEqual(status.note, "handed in at practice")
|
|
|
|
def test_an_inactive_requirement_does_not_block_onboarding(self):
|
|
self.medical.is_active = False
|
|
self.medical.save()
|
|
|
|
mark_complete(self.membership, self.photo, user=self.staff)
|
|
|
|
self.assertTrue(self.membership.onboarding_complete)
|
|
|
|
def test_checklist_for_pairs_every_active_requirement_with_its_status_or_none(self):
|
|
mark_complete(self.membership, self.photo, user=self.staff)
|
|
|
|
checklist = checklist_for(self.membership)
|
|
by_requirement = dict(checklist)
|
|
|
|
self.assertEqual(len(checklist), 2)
|
|
self.assertTrue(by_requirement[self.photo].is_complete)
|
|
self.assertIsNone(by_requirement[self.medical])
|
|
|
|
def test_a_second_clubs_requirement_never_applies_here(self):
|
|
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
|
|
OnboardingRequirement.objects.create(club=other_club, name="Waiver")
|
|
|
|
self.assertEqual(self.membership.open_requirement_count, 2) # not 3
|
|
|
|
def test_annotate_onboarding_status_matches_the_per_row_property_across_a_list(self):
|
|
second = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="Sam", last_name="Lee"), season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
|
|
mark_complete(self.membership, self.photo, user=self.staff)
|
|
|
|
annotated = annotate_onboarding_status(ClubMembership.objects.filter(club=self.club))
|
|
by_pk = {membership.pk: membership.onboarding_open for membership in annotated}
|
|
|
|
self.assertEqual(by_pk[self.membership.pk], 1)
|
|
self.assertEqual(by_pk[second.pk], 2)
|
|
|
|
def test_annotate_onboarding_status_costs_a_fixed_number_of_queries_regardless_of_list_size(self):
|
|
# One for the queryset itself, one for the club's required requirements, one for
|
|
# every membership's completed statuses -- flat regardless of how many rows.
|
|
for i in range(5):
|
|
ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name=f"M{i}", last_name="Roe"), season=self.season)
|
|
|
|
with self.assertNumQueries(3):
|
|
annotate_onboarding_status(ClubMembership.objects.filter(club=self.club))
|
|
|
|
# --- mark_bypassed ---
|
|
def test_mark_bypassed_resolves_the_item_without_marking_it_complete(self):
|
|
status = mark_bypassed(self.membership, self.photo, user=self.staff, note="already has a recent one on file")
|
|
|
|
self.assertFalse(status.is_complete)
|
|
self.assertTrue(status.is_bypassed)
|
|
self.assertEqual(status.note, "already has a recent one on file")
|
|
self.assertEqual(self.membership.open_requirement_count, 1)
|
|
|
|
def test_mark_complete_clears_a_prior_bypass(self):
|
|
mark_bypassed(self.membership, self.photo, user=self.staff, note="not needed")
|
|
status = mark_complete(self.membership, self.photo, user=self.staff)
|
|
|
|
self.assertTrue(status.is_complete)
|
|
self.assertFalse(status.is_bypassed)
|
|
|
|
def test_mark_bypassed_clears_a_prior_completion(self):
|
|
mark_complete(self.membership, self.photo, user=self.staff)
|
|
status = mark_bypassed(self.membership, self.photo, user=self.staff, note="turns out not needed")
|
|
|
|
self.assertFalse(status.is_complete)
|
|
self.assertTrue(status.is_bypassed)
|
|
|
|
def test_mark_incomplete_also_clears_a_bypass(self):
|
|
mark_bypassed(self.membership, self.photo, user=self.staff, note="not needed")
|
|
status = mark_incomplete(self.membership, self.photo)
|
|
|
|
self.assertFalse(status.is_complete)
|
|
self.assertFalse(status.is_bypassed)
|
|
self.assertEqual(self.membership.open_requirement_count, 2)
|
|
|
|
# --- blocking_event_kinds ---
|
|
def test_blocking_event_kinds_is_empty_when_nothing_blocks_anything(self):
|
|
self.assertEqual(blocking_event_kinds(self.membership), set())
|
|
|
|
def test_blocking_event_kinds_collects_kinds_from_every_open_requirement(self):
|
|
self.medical.blocked_event_kinds = ["game", "tournament"]
|
|
self.medical.save()
|
|
self.photo.blocked_event_kinds = ["game"]
|
|
self.photo.save()
|
|
|
|
self.assertEqual(blocking_event_kinds(self.membership), {"game", "tournament"})
|
|
|
|
def test_blocking_event_kinds_ignores_a_resolved_requirement(self):
|
|
self.medical.blocked_event_kinds = ["game"]
|
|
self.medical.save()
|
|
mark_complete(self.membership, self.medical, user=self.staff)
|
|
|
|
self.assertEqual(blocking_event_kinds(self.membership), set())
|
|
|
|
def test_blocking_event_kinds_ignores_a_bypassed_requirement(self):
|
|
self.medical.blocked_event_kinds = ["game"]
|
|
self.medical.save()
|
|
mark_bypassed(self.membership, self.medical, user=self.staff, note="waived")
|
|
|
|
self.assertEqual(blocking_event_kinds(self.membership), set())
|
|
|
|
# --- blocked_member_ids_for_event ---
|
|
def test_blocked_member_ids_for_event_is_empty_when_nothing_is_configured_to_block(self):
|
|
self.assertEqual(blocked_member_ids_for_event(self.club, self.season, "game"), set())
|
|
|
|
def test_blocked_member_ids_for_event_flags_a_member_with_an_open_blocking_requirement(self):
|
|
self.medical.blocked_event_kinds = ["game"]
|
|
self.medical.save()
|
|
|
|
self.assertEqual(blocked_member_ids_for_event(self.club, self.season, "game"), {self.member.pk})
|
|
|
|
def test_blocked_member_ids_for_event_is_kind_specific(self):
|
|
self.medical.blocked_event_kinds = ["game"]
|
|
self.medical.save()
|
|
|
|
self.assertEqual(blocked_member_ids_for_event(self.club, self.season, "training"), set())
|
|
|
|
def test_blocked_member_ids_for_event_excludes_a_member_who_resolved_it(self):
|
|
self.medical.blocked_event_kinds = ["game"]
|
|
self.medical.save()
|
|
mark_complete(self.membership, self.medical, user=self.staff)
|
|
|
|
self.assertEqual(blocked_member_ids_for_event(self.club, self.season, "game"), set())
|
|
|
|
def test_blocked_member_ids_for_event_excludes_a_bypassed_requirement_too(self):
|
|
self.medical.blocked_event_kinds = ["game"]
|
|
self.medical.save()
|
|
mark_bypassed(self.membership, self.medical, user=self.staff, note="waived")
|
|
|
|
self.assertEqual(blocked_member_ids_for_event(self.club, self.season, "game"), set())
|
|
|
|
# --- open_requirements_blocking (per-member "why can't I sign up" mirror) ---
|
|
def test_open_requirements_blocking_is_empty_when_nothing_is_configured_to_block(self):
|
|
self.assertEqual(open_requirements_blocking(self.member, self.club, self.season, "game"), [])
|
|
|
|
def test_open_requirements_blocking_returns_the_open_requirement(self):
|
|
self.medical.blocked_event_kinds = ["game"]
|
|
self.medical.save()
|
|
|
|
self.assertEqual(open_requirements_blocking(self.member, self.club, self.season, "game"), [self.medical])
|
|
|
|
def test_open_requirements_blocking_is_kind_specific(self):
|
|
self.medical.blocked_event_kinds = ["game"]
|
|
self.medical.save()
|
|
|
|
self.assertEqual(open_requirements_blocking(self.member, self.club, self.season, "training"), [])
|
|
|
|
def test_open_requirements_blocking_excludes_a_resolved_requirement(self):
|
|
self.medical.blocked_event_kinds = ["game"]
|
|
self.medical.save()
|
|
mark_complete(self.membership, self.medical, user=self.staff)
|
|
|
|
self.assertEqual(open_requirements_blocking(self.member, self.club, self.season, "game"), [])
|
|
|
|
def test_open_requirements_blocking_is_empty_with_no_club_membership(self):
|
|
stranger = Member.objects.create(first_name="No", last_name="Membership")
|
|
|
|
self.assertEqual(open_requirements_blocking(stranger, self.club, self.season, "game"), [])
|
|
|
|
# --- approve_all_clean ---
|
|
def test_approve_all_clean_activates_a_pending_paid_up_fully_checked_member(self):
|
|
pending = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="Tom", last_name="Roe"), season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.PAID)
|
|
mark_complete(pending, self.photo, user=self.staff)
|
|
mark_bypassed(pending, self.medical, user=self.staff, note="waived")
|
|
|
|
activated = approve_all_clean(self.club, self.season)
|
|
|
|
pending.refresh_from_db()
|
|
self.assertEqual(activated, 1)
|
|
self.assertEqual(pending.status, ClubMembership.StatusChoices.ACTIVE)
|
|
|
|
def test_approve_all_clean_skips_a_pending_member_with_an_open_requirement(self):
|
|
pending = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="Tom", last_name="Roe"), season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.PAID)
|
|
mark_complete(pending, self.photo, user=self.staff)
|
|
# self.medical left open.
|
|
|
|
activated = approve_all_clean(self.club, self.season)
|
|
|
|
pending.refresh_from_db()
|
|
self.assertEqual(activated, 0)
|
|
self.assertEqual(pending.status, ClubMembership.StatusChoices.PENDING)
|
|
|
|
def test_approve_all_clean_skips_a_pending_member_who_has_not_paid(self):
|
|
pending = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="Tom", last_name="Roe"), season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.UNPAID)
|
|
mark_complete(pending, self.photo, user=self.staff)
|
|
mark_complete(pending, self.medical, user=self.staff)
|
|
|
|
activated = approve_all_clean(self.club, self.season)
|
|
|
|
pending.refresh_from_db()
|
|
self.assertEqual(activated, 0)
|
|
self.assertEqual(pending.status, ClubMembership.StatusChoices.PENDING)
|
|
|
|
def test_approve_all_clean_never_touches_an_already_active_membership(self):
|
|
# self.membership is already ACTIVE/PAID with two open requirements --
|
|
# approve_all_clean only ever moves PENDING -> ACTIVE, it doesn't re-check
|
|
# or deactivate anyone already active.
|
|
activated = approve_all_clean(self.club, self.season)
|
|
|
|
self.membership.refresh_from_db()
|
|
self.assertEqual(activated, 0)
|
|
self.assertEqual(self.membership.status, ClubMembership.StatusChoices.ACTIVE)
|
|
|
|
def test_approve_all_clean_ignores_a_guardian_kind_membership(self):
|
|
guardian_member = Member.objects.create(first_name="Pat", last_name="Guardian")
|
|
ClubMembership.objects.create(club=self.club, member=guardian_member, season=self.season, kind=ClubMembership.Kind.GUARDIAN, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.PAID)
|
|
|
|
activated = approve_all_clean(self.club, self.season)
|
|
|
|
self.assertEqual(activated, 0)
|
|
|
|
def test_approve_all_clean_stamps_activated_at(self):
|
|
pending = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="Tom", last_name="Roe"), season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.PAID)
|
|
mark_complete(pending, self.photo, user=self.staff)
|
|
mark_complete(pending, self.medical, user=self.staff)
|
|
|
|
approve_all_clean(self.club, self.season)
|
|
|
|
pending.refresh_from_db()
|
|
self.assertEqual(pending.activated_at, timezone.localdate())
|
|
|
|
# --- approve_one ---
|
|
def test_approve_one_activates_a_clean_pending_membership_and_stamps_activated_at(self):
|
|
pending = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="Tom", last_name="Roe"), season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.PAID)
|
|
mark_complete(pending, self.photo, user=self.staff)
|
|
mark_complete(pending, self.medical, user=self.staff)
|
|
|
|
activated = approve_one(pending)
|
|
|
|
pending.refresh_from_db()
|
|
self.assertTrue(activated)
|
|
self.assertEqual(pending.status, ClubMembership.StatusChoices.ACTIVE)
|
|
self.assertEqual(pending.activated_at, timezone.localdate())
|
|
|
|
def test_approve_one_refuses_a_paid_but_unchecked_membership(self):
|
|
# Fully paid is not enough on its own -- the whole point of this change is
|
|
# that fee_status alone never activates; the checklist must be resolved too.
|
|
pending = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="Tom", last_name="Roe"), season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.PAID)
|
|
mark_complete(pending, self.photo, user=self.staff)
|
|
# self.medical left open.
|
|
|
|
activated = approve_one(pending)
|
|
|
|
pending.refresh_from_db()
|
|
self.assertFalse(activated)
|
|
self.assertEqual(pending.status, ClubMembership.StatusChoices.PENDING)
|
|
self.assertIsNone(pending.activated_at)
|