import datetime
import pathlib
from decimal import Decimal
from unittest import mock
from allauth.mfa.models import Authenticator
from django import forms
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.messages.storage.base import Message
from django.contrib.messages.storage.fallback import FallbackStorage
from django.core.cache import cache
from django.template.loader import render_to_string
from django.test import RequestFactory, TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from waffle import get_waffle_flag_model, get_waffle_switch_model
from billing.models import DEFAULT_GRACE_DAYS, Due, Plan, PlanPrice, Subscription
from billing.services import BillingError
from billing.services.dues import record_payment, start_trial, subscribe, waive
from club.models import Club, ClubMembership, ClubRole, Season
from events.models import Attendance, Event, Location
from features.models import Maintenance
from members.models import Member
from shop.models import Order
from teams.models import Position, StaffAssignment, Team, TeamMembership
from .messages import LEVELS, notify
from .services.admins import grant_club_admin
from .services.platform_admins import PlatformAdminError, is_last_superuser, set_platform_access
from .services.statistics import (
admins_pending_mfa,
attendance_rates,
club_attention,
club_statistics,
clubs_with_health,
clubs_with_totals,
clubs_without_a_season,
dormant_clubs,
fee_aging,
flag_adoption,
new_members,
onboarding_funnel,
platform_attention,
platform_charts,
platform_totals,
renewal_rate,
signup_split,
teams_without_a_manager,
unrostered_members,
)
from .templatetags.ui import as_alert, daisy, excluded, field_icon, form_field
User = get_user_model()
Flag = get_waffle_flag_model()
Switch = get_waffle_switch_model()
def enrol_mfa(user):
return Authenticator.objects.create(user=user, type=Authenticator.Type.TOTP, data={"secret": "JBSWY3DPEHPK3PXP"})
class ControlPanelTestBase(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United")
self.staff = User.objects.create_user(email="root@example.com", password="pw-secret-123", is_staff=True)
# Staff must hold a second factor, else RequireMFAMiddleware redirects.
enrol_mfa(self.staff)
self.client.force_login(self.staff)
class AccessTests(ControlPanelTestBase):
def test_staff_can_reach_the_panel(self):
self.assertEqual(self.client.get(reverse("controlpanel:dashboard")).status_code, 200)
def test_anonymous_is_sent_to_login(self):
self.client.logout()
response = self.client.get(reverse("controlpanel:dashboard"))
self.assertEqual(response.status_code, 302)
self.assertIn(reverse("account_login"), response.url)
def test_signed_in_non_staff_gets_403(self):
self.client.force_login(User.objects.create_user(email="member@example.com", password="pw-secret-123"))
self.assertEqual(self.client.get(reverse("controlpanel:dashboard")).status_code, 403)
def test_superuser_can_reach_the_panel(self):
root = User.objects.create_superuser(email="super@example.com", password="pw-secret-123")
enrol_mfa(root)
self.client.force_login(root)
self.assertEqual(self.client.get(reverse("controlpanel:dashboard")).status_code, 200)
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=[".rosterchief.app"])
def test_panel_does_not_exist_on_a_club_subdomain(self):
# It manages *all* clubs, so it must not be reachable from inside one.
Club.objects.create(name="Rival FC", slug="rival-fc")
response = self.client.get(reverse("controlpanel:dashboard"), headers={"host": "rival-fc.rosterchief.app"})
self.assertEqual(response.status_code, 404)
def test_staff_without_a_second_factor_is_sent_to_enrolment(self):
self.client.force_login(User.objects.create_user(email="nomfa@example.com", password="pw-secret-123", is_staff=True))
response = self.client.get(reverse("controlpanel:dashboard"))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse("mfa_index"))
class ClubManagementTests(ControlPanelTestBase):
def test_dashboard_lists_clubs(self):
self.assertContains(self.client.get(reverse("controlpanel:dashboard")), "Ajax United")
def test_the_club_rows_subtitle_shows_the_sport_type_behind_the_url(self):
self.club.sport_type = Club.SportType.ICE_HOCKEY
self.club.save()
response = self.client.get(reverse("controlpanel:dashboard"))
self.assertContains(response, f"{self.club.slug}.rosterchief.app · Ice hockey", html=False)
def test_create_club_derives_the_slug(self):
response = self.client.post(reverse("controlpanel:club_create"), {"name": "New Club", "slug": "", "sport_type": "other", "season_start": "2000-08-01", "season_duration_months": "12"})
club = Club.objects.get(name="New Club")
self.assertEqual(club.slug, "new-club")
self.assertRedirects(response, reverse("controlpanel:club_detail", args=[club.pk]))
def test_update_club(self):
self.client.post(
reverse("controlpanel:club_update", args=[self.club.pk]),
{"name": "Renamed", "slug": self.club.slug, "sport_type": "other", "season_start": "2000-08-01", "season_duration_months": "12"},
)
self.club.refresh_from_db()
self.assertEqual(self.club.name, "Renamed")
def test_update_club_sport_type(self):
self.client.post(
reverse("controlpanel:club_update", args=[self.club.pk]),
{"name": self.club.name, "slug": self.club.slug, "sport_type": "ice_hockey", "season_start": "2000-08-01", "season_duration_months": "12"},
)
self.club.refresh_from_db()
self.assertEqual(self.club.sport_type, "ice_hockey")
def test_club_detail_shows_statistics(self):
response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk]))
self.assertContains(response, "Members")
self.assertContains(response, "Teams & staff")
self.assertContains(response, "Shop")
def test_club_detail_subheading_shows_the_sport_type_behind_the_url(self):
self.club.sport_type = Club.SportType.ICE_HOCKEY
self.club.save()
response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk]))
self.assertContains(response, f"{self.club.slug}.rosterchief.app · Ice hockey", html=False)
def test_archive_then_restore(self):
self.client.post(reverse("controlpanel:club_archive", args=[self.club.pk]))
self.club.refresh_from_db()
self.assertTrue(self.club.is_archived)
self.client.post(reverse("controlpanel:club_restore", args=[self.club.pk]))
self.club.refresh_from_db()
self.assertFalse(self.club.is_archived)
def test_list_separates_active_from_archived(self):
Club.objects.create(name="Gone FC").archive()
active = self.client.get(reverse("controlpanel:club_list"))
self.assertContains(active, "Ajax United")
self.assertNotContains(active, "Gone FC")
archived = self.client.get(reverse("controlpanel:club_list"), {"archived": "1"})
self.assertContains(archived, "Gone FC")
self.assertNotContains(archived, "Ajax United")
def test_list_search(self):
Club.objects.create(name="Rival FC")
response = self.client.get(reverse("controlpanel:club_list"), {"q": "Ajax"})
self.assertContains(response, "Ajax United")
self.assertNotContains(response, "Rival FC")
class ClubAdminManagementTests(ControlPanelTestBase):
def add_admin(self, **data):
return self.client.post(reverse("controlpanel:club_admin_add", args=[self.club.pk]), data)
def test_granting_admin_to_a_new_email_creates_the_account(self):
self.add_admin(email="New.Admin@Example.com", first_name="Ada", last_name="Min")
user = User.objects.get(email="new.admin@example.com")
self.assertFalse(user.has_usable_password()) # they set one via password reset
self.assertEqual(ClubRole.objects.get(club=self.club, member__user=user).role, ClubRole.Roles.ADMIN)
def test_a_new_email_must_come_with_a_name(self):
# Reachable only via the "Add admin" modal on the club detail page, so a rejected
# submission bounces back there with the error as a message.
response = self.client.post(reverse("controlpanel:club_admin_add", args=[self.club.pk]), {"email": "nameless@example.com", "first_name": "", "last_name": ""}, follow=True)
self.assertRedirects(response, reverse("controlpanel:club_detail", args=[self.club.pk]))
self.assertFalse(ClubRole.objects.exists())
self.assertContains(response, "Required: this email has no account yet.")
def test_add_admin_is_post_only(self):
response = self.client.get(reverse("controlpanel:club_admin_add", args=[self.club.pk]))
self.assertEqual(response.status_code, 405)
def test_an_existing_member_is_promoted_rather_than_duplicated(self):
user = User.objects.create_user(email="existing@example.com", password="pw-secret-123")
member = Member.objects.create(user=user, first_name="Ex", last_name="Isting")
ClubRole.objects.create(club=self.club, member=member, role=ClubRole.Roles.MEMBER)
self.add_admin(email="existing@example.com")
# One role per member per club, so the MEMBER role is upgraded in place.
self.assertEqual(ClubRole.objects.get(club=self.club, member=member).role, ClubRole.Roles.ADMIN)
self.assertEqual(Member.objects.filter(user=user).count(), 1)
def test_granting_twice_is_idempotent(self):
self.add_admin(email="ada@example.com", first_name="Ada", last_name="Min")
self.add_admin(email="ada@example.com", first_name="Ada", last_name="Min")
self.assertEqual(ClubRole.objects.filter(club=self.club).count(), 1)
def test_remove_admin(self):
role = grant_club_admin(self.club, "ada@example.com", "Ada", "Min")
self.client.post(reverse("controlpanel:club_admin_remove", args=[self.club.pk, role.pk]))
self.assertFalse(ClubRole.objects.filter(pk=role.pk).exists())
def test_admins_are_listed_on_the_club(self):
grant_club_admin(self.club, "ada@example.com", "Ada", "Min")
response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk]))
self.assertContains(response, "Ada Min")
self.assertContains(response, "ada@example.com")
class ClubHomeLocationTests(ControlPanelTestBase):
"""The club detail page's "Home location" box -- see
controlpanel.views.ClubHomeLocationSetView. Setting it here creates/updates
an events.Location row flagged is_home, which is also what shows up on the
club's own Teams > Locations page (management app) -- there's no separate
sync step, it's the same row."""
def set_home_location(self, **data):
data = {"name": "Home Ground", "address": "1 Main St", "city": "Town", "zip_code": "1000", "country": "BE"} | data
return self.client.post(reverse("controlpanel:club_home_location_set", args=[self.club.pk]), data)
def test_setting_it_creates_a_location(self):
response = self.set_home_location()
self.assertRedirects(response, reverse("controlpanel:club_detail", args=[self.club.pk]))
location = Location.objects.get(club=self.club, is_home=True)
self.assertEqual(location.name, "Home Ground")
def test_the_home_location_modal_renders_country_as_a_dropdown(self):
# Regression: CountryField's widget_type ("lazyselect") wasn't recognised
# by the form_field templatetag and fell through to a broken plain
# , not a