Checkpoint: management app redesign, onboarding/signup workflow, and events calendar backend

Large uncommitted body of work accumulated across sessions on this branch --
committing as a checkpoint so it's tracked and future worktree-isolated agents
see the real codebase instead of a stale ancestor commit. Covers the
management app's dedicated Tailwind theme and templates, the club onboarding
requirement/signup workflow (club/services/onboarding.py, requirement/status
models, sign-up dashboard), fee/status auto-activation decoupling, referee
management, and the new events calendar grid service layer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
2026-08-19 23:34:43 +02:00
parent bff685966d
commit adf1120358
157 changed files with 20342 additions and 4008 deletions

View File

@@ -1,7 +1,7 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from .models import Club, ClubMembership, ClubRole, FeePayment, Season, Sponsor
from .models import Club, ClubMembership, ClubRole, FeePayment, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
@admin.register(Club)
@@ -63,3 +63,19 @@ class ClubRoleAdmin(admin.ModelAdmin):
search_fields = ["club__name", "member__last_name", "member__first_name"]
list_filter = ["club", "role"]
raw_id_fields = ["member"]
@admin.register(OnboardingRequirement)
class OnboardingRequirementAdmin(admin.ModelAdmin):
list_display = ["name", "club", "requires_document", "is_active", "order"]
list_filter = ["club", "is_active", "requires_document"]
search_fields = ["name", "club__name"]
ordering = ["club", "order", "name"]
@admin.register(MemberRequirementStatus)
class MemberRequirementStatusAdmin(admin.ModelAdmin):
list_display = ["membership", "requirement", "is_complete", "completed_at", "completed_by"]
list_filter = ["requirement__club", "is_complete", "requirement"]
search_fields = ["membership__member__last_name", "membership__member__first_name", "requirement__name"]
raw_id_fields = ["membership"]

View File

@@ -1,23 +1,46 @@
"""Tenant-aware page branding.
Every page inherits its chrome from ``base_template``. On a club subdomain that
resolves to the club-branded skin, on the base domain to the RosterChief one, so
the auth screens (login, password reset, MFA, passkeys — anything allauth ships,
now or later) follow the tenant without a single template of their own knowing
that clubs exist.
resolves to the club-branded skin, on the base domain to the platform one — the
control panel's own industrial design system (assets/controlpanel.css) — so the
auth screens (login, password reset, MFA, passkeys — anything allauth ships, now
or later) follow the tenant without a single template of their own knowing that
clubs exist. templates/403.html and templates/maintenance.html extend
``base_template`` directly too, so they follow the same split.
The control panel deliberately does *not* use this: it hardcodes the platform
base, so no branding bug can ever dress the platform panel up as a club.
A club subdomain serves two very different chromes, though: the public club site
(daisyUI, assets/app.css) and the management app (assets/management.css) live on
the same tenant, distinguished only by path. Without the checks below, a staff
member clicking "Change password" from inside the management app would land back
on the club's *public* skin -- jarring, and visually nothing like where they just
were. MANAGEMENT_BASE_TEMPLATE picks up management/base.html's own chrome instead,
for two cases: a request path directly under /manage/ (matching management/urls.py's
own hardcoded "manage/" prefix in rosterchief/urls.py -- e.g. a 403 on a management
page), and the session flag ClubStaffRequiredMixin.dispatch sets on every management
view (club/mixins.py) -- needed because allauth's password-change/MFA/logout screens
live under /accounts/, outside /manage/, so the path check alone can't see they were
reached from the management app's own user menu.
The control panel's own pages deliberately do *not* use this: controlpanel/base.html
hardcodes itself, so no branding bug can ever dress the platform panel up as a club.
"""
PLATFORM_BASE_TEMPLATE = "_platform_base.html"
PLATFORM_BASE_TEMPLATE = "controlpanel/_auth_base.html"
CLUB_BASE_TEMPLATE = "_club_base.html"
MANAGEMENT_BASE_TEMPLATE = "management/_auth_base.html"
def branding(request):
club = getattr(request, "club", None) # set by ClubTenantMiddleware
if club and (request.path.startswith("/manage/") or request.session.get("management_context")):
base_template = MANAGEMENT_BASE_TEMPLATE
elif club:
base_template = CLUB_BASE_TEMPLATE
else:
base_template = PLATFORM_BASE_TEMPLATE
return {
"club": club,
"base_template": CLUB_BASE_TEMPLATE if club else PLATFORM_BASE_TEMPLATE,
"base_template": base_template,
}

View File

@@ -0,0 +1,65 @@
# Generated by Django 6.0.6 on 2026-08-16 20:42
import club.models
import django.core.files.storage
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0024_club_contact_email'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='OnboardingRequirement',
fields=[
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, verbose_name='name')),
('description', models.TextField(blank=True, help_text="Shown to staff on the member's checklist.", verbose_name='description')),
('requires_document', models.BooleanField(default=False, help_text='Staff can attach a file (e.g. the certificate itself) when marking this complete.', verbose_name='requires a document')),
('is_active', models.BooleanField(default=True, help_text='Inactive requirements no longer apply to new memberships, but existing statuses are kept.', verbose_name='active')),
('order', models.PositiveIntegerField(default=0, help_text='Lower numbers show first on the checklist.', verbose_name='order')),
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
],
options={
'verbose_name': 'onboarding requirement',
'verbose_name_plural': 'onboarding requirements',
'ordering': ['order', 'name'],
},
),
migrations.CreateModel(
name='MemberRequirementStatus',
fields=[
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('is_complete', models.BooleanField(default=False, verbose_name='complete')),
('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='completed at')),
('document', models.FileField(blank=True, help_text="Stored privately -- readable only through this member's own page, never a direct link.", storage=django.core.files.storage.FileSystemStorage(base_url=None, location='/Users/bernard/Code/PycharmProjects/RosterChief/private_media'), upload_to=club.models.onboarding_document_path, verbose_name='document')),
('note', models.TextField(blank=True, help_text='Staff-only, e.g. how or when this was received.', verbose_name='note')),
('completed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='completed by')),
('membership', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='requirement_statuses', to='club.clubmembership', verbose_name='membership')),
('requirement', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='statuses', to='club.onboardingrequirement', verbose_name='requirement')),
],
options={
'verbose_name': 'member requirement status',
'verbose_name_plural': 'member requirement statuses',
},
),
migrations.AddConstraint(
model_name='onboardingrequirement',
constraint=models.UniqueConstraint(fields=('club', 'name'), name='unique_onboarding_requirement_name_per_club'),
),
migrations.AddConstraint(
model_name='memberrequirementstatus',
constraint=models.UniqueConstraint(fields=('membership', 'requirement'), name='unique_requirement_status_per_membership'),
),
]

View File

@@ -0,0 +1,35 @@
# Generated by Django 6.0.6 on 2026-08-17 11:39
import club.models
import rosterchief.storage
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0025_onboardingrequirement_memberrequirementstatus_and_more'),
]
operations = [
migrations.AddField(
model_name='memberrequirementstatus',
name='is_bypassed',
field=models.BooleanField(default=False, verbose_name='bypassed'),
),
migrations.AddField(
model_name='onboardingrequirement',
name='blocked_event_kinds',
field=models.JSONField(blank=True, default=list, help_text="Event kinds a member can't be invited to or selected for while this is open. Empty means purely informational.", verbose_name='blocks selection for'),
),
migrations.AlterField(
model_name='clubrole',
name='role',
field=models.CharField(choices=[('admin', 'admin'), ('member', 'member'), ('editor', 'editor'), ('member_admin', 'member admin')], default='member', max_length=250, verbose_name='role'),
),
migrations.AlterField(
model_name='memberrequirementstatus',
name='document',
field=models.FileField(blank=True, help_text="Stored privately -- readable only through this member's own page, never a direct link.", storage=rosterchief.storage.PrivateStorage(location='/Users/bernard/Code/PycharmProjects/RosterChief/private_media'), upload_to=club.models.onboarding_document_path, verbose_name='document'),
),
]

View File

@@ -4,7 +4,7 @@ from waffle import flag_is_active
from members.models import Group
from .services.access import can_add_news, can_edit_news, can_publish_news, groups_manageable_by, has_management_access, is_club_admin, is_coach_manager, teams_managed_by
from .services.access import can_add_news, can_edit_news, can_manage_members, can_publish_news, groups_manageable_by, has_management_access, is_club_admin, is_coach_manager, teams_managed_by
class ClubStaffRequiredMixin(LoginRequiredMixin, UserPassesTestMixin):
@@ -25,6 +25,12 @@ class ClubStaffRequiredMixin(LoginRequiredMixin, UserPassesTestMixin):
def dispatch(self, request, *args, **kwargs):
if getattr(request, "club", None) is None:
raise Http404("The management app is not available on the base domain.")
# Read by club/context_processors.py's branding() -- allauth's password-change/MFA/
# logout screens live under /accounts/, not /manage/, so a path check alone can't
# tell they were reached from the management app's own user menu. This sticks for
# the rest of the session (nothing clears it back to False on a public-site visit),
# which is the right default for the common case of one person, one role.
request.session["management_context"] = True
return super().dispatch(request, *args, **kwargs)
def test_func(self):
@@ -32,13 +38,28 @@ class ClubStaffRequiredMixin(LoginRequiredMixin, UserPassesTestMixin):
class ClubAdminRequiredMixin(ClubStaffRequiredMixin):
"""ADMIN role only — club-wide settings that aren't scoped to a single team:
seasons, positions, roles, shop configuration."""
"""ADMIN role only (a platform superuser always passes too, see
is_club_admin) — genuinely admin-only ground: Finance/Shop, Club identity,
Sponsors, seasons, and granting/revoking ClubRole itself. Everything a
MEMBER_ADMIN may also touch uses MemberAdminRequiredMixin below instead."""
def test_func(self):
return is_club_admin(self.request.user, self.request.club)
class MemberAdminRequiredMixin(ClubStaffRequiredMixin):
"""ADMIN, a platform superuser, or MEMBER_ADMIN specifically -- full read/write
on people: members, families, groups, parent claims, member import, teams
(roster/staff/CRUD), referee levels, referee management, and onboarding
requirements. Deliberately does NOT cover Finance/Shop, Club identity,
Sponsors, or role-granting (role_list/role_create/role_revoke stay
ClubAdminRequiredMixin) -- a MEMBER_ADMIN must never be able to grant
themselves, or anyone else, real ADMIN."""
def test_func(self):
return can_manage_members(self.request.user, self.request.club)
class FeatureRequiredMixin(ClubAdminRequiredMixin):
"""Gate for a whole management section (shop, forms, ...) this club doesn't
have at all unless its waffle Flag (see the ``features`` app, set per-club

View File

@@ -5,11 +5,13 @@ from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator, MaxValueValidator, MinValueValidator, RegexValidator
from django.db import models
from django.db.models import Q
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from members.models import Member
from rosterchief.base import ClubScopedModel, UUIDModel, unique_slugify, validate_club_scope
from rosterchief.storage import private_storage
class ClubManager(models.Manager):
@@ -256,6 +258,14 @@ class Season(ClubScopedModel):
context needed) -- the season that follows the one covering ``date``."""
return cls.objects.filter(club=club, start_date__gt=date).order_by("start_date").first()
@classmethod
def before(cls, club, season):
"""Return ``club``'s most recent season starting before ``season`` --
e.g. the management dashboard's member-count trend compares against
this. Mirrors next_after's own "adjacent by date" reasoning, just
looking the other way."""
return cls.objects.filter(club=club, start_date__lt=season.start_date).order_by("-start_date").first()
class ClubMembership(ClubScopedModel):
class Kind(models.TextChoices):
@@ -319,6 +329,21 @@ class ClubMembership(ClubScopedModel):
"""
return self.kind == self.Kind.GUARDIAN
@property
def open_requirement_count(self) -> int:
"""How many active onboarding requirements this membership hasn't resolved
yet (completed or bypassed) -- see OnboardingRequirement's docstring for why
this is separate from status/fee_status. One query per call; for a list of
memberships, annotate with club.services.onboarding.annotate_onboarding_status
instead."""
met = set(self.requirement_statuses.filter(Q(is_complete=True) | Q(is_bypassed=True)).values_list("requirement_id", flat=True))
required = set(OnboardingRequirement.objects.filter(club_id=self.club_id, is_active=True).values_list("pk", flat=True))
return len(required - met)
@property
def onboarding_complete(self) -> bool:
return self.open_requirement_count == 0
def clean(self):
validate_club_scope(self, self.club_id, same_club_fields=("season",))
# A guardian owes nothing -- they're not a member. Caught here rather than
@@ -356,11 +381,104 @@ class FeePayment(UUIDModel):
return f"{self.membership}{self.amount}"
def onboarding_document_path(instance: MemberRequirementStatus, filename: str) -> str:
return f"clubs/{instance.membership.club.slug}/onboarding/{instance.membership_id}/{filename}"
class OnboardingRequirement(ClubScopedModel):
"""A club-defined item every member must satisfy after signing up or renewing --
e.g. "provide a medical certificate", "upload a photo".
``ClubMembership.fee_status`` is still driven by payment alone (see
``club.services.fees._sync_fee_status``) and this never touches it -- a member
reads as paid *and* still has an open checklist, both true at once. ``status``
is different: paying in full only ever settles ``fee_status`` now -- it never
flips ``status`` to ACTIVE by itself. The only path there is the deliberately
manual one, ``club.services.onboarding.approve_one``/``approve_all_clean``, run
by an admin from the Sign-up page, which additionally requires every blocking
requirement to be resolved first. Nothing flips status automatically just
because the fee cleared or the last checklist item was ticked (checklist actions
aren't even admin-gated); activation is always that one deliberate admin step,
so a membership can be fully paid *and* fully checked off and still sit PENDING
until someone actually clicks Approve.
``blocked_event_kinds`` is what makes a specific requirement matter before that
point: a club can decide e.g. a medical certificate blocks GAME invitations/
selection but not TRAINING ones, so a provisionally-rostered member (see
``events.services.attendance.effective_members``) can still be invited to practice
while their paperwork is outstanding. Empty means "informational only" -- open or
not, it never blocks anything. Stored as a plain list of ``events.models.Event.
EventKind`` values (not a FK/enum at the DB layer) specifically to avoid a
club -> events import cycle (events already imports club for Event.club); the
form layer (management/forms.py) is what actually validates against EventKind.
``MemberRequirementStatus`` tracks completion per ``ClubMembership`` (so a fresh
checklist starts each season, matching how membership itself is season-scoped).
"""
name = models.CharField(_("name"), max_length=100)
description = models.TextField(_("description"), blank=True, help_text=_("Shown to staff on the member's checklist."))
requires_document = models.BooleanField(_("requires a document"), default=False, help_text=_("Staff can attach a file (e.g. the certificate itself) when marking this complete."))
blocked_event_kinds = models.JSONField(_("blocks selection for"), default=list, blank=True, help_text=_("Event kinds a member can't be invited to or selected for while this is open. Empty means purely informational."))
is_active = models.BooleanField(_("active"), default=True, help_text=_("Inactive requirements no longer apply to new memberships, but existing statuses are kept."))
order = models.PositiveIntegerField(_("order"), default=0, help_text=_("Lower numbers show first on the checklist."))
class Meta:
verbose_name = _("onboarding requirement")
verbose_name_plural = _("onboarding requirements")
ordering = ["order", "name"]
constraints = [
models.UniqueConstraint(fields=["club", "name"], name="unique_onboarding_requirement_name_per_club"),
]
def __str__(self):
return self.name
class MemberRequirementStatus(UUIDModel):
"""Whether one ``ClubMembership`` has satisfied one ``OnboardingRequirement``,
this season. Not itself club-scoped -- its club is reached through ``membership``,
same reasoning as ``FeePayment`` above."""
membership = models.ForeignKey(ClubMembership, on_delete=models.CASCADE, related_name="requirement_statuses", verbose_name=_("membership"))
requirement = models.ForeignKey(OnboardingRequirement, on_delete=models.CASCADE, related_name="statuses", verbose_name=_("requirement"))
is_complete = models.BooleanField(_("complete"), default=False)
#: Distinct from is_complete -- "confirmed, not needed for this person" (e.g. they
#: already have a recent photo on file) reads differently from "actually received"
#: on a checklist/audit, even though both equally stop this item from blocking
#: anything (see club.services.onboarding.is_open). Mutually exclusive with
#: is_complete in practice (mark_bypassed/mark_complete each clear the other).
is_bypassed = models.BooleanField(_("bypassed"), default=False)
completed_at = models.DateTimeField(_("completed at"), null=True, blank=True)
completed_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="+", verbose_name=_("completed by"))
document = models.FileField(_("document"), storage=private_storage, upload_to=onboarding_document_path, blank=True, help_text=_("Stored privately -- readable only through this member's own page, never a direct link."))
note = models.TextField(_("note"), blank=True, help_text=_("Staff-only, e.g. how or when this was received."))
class Meta:
verbose_name = _("member requirement status")
verbose_name_plural = _("member requirement statuses")
constraints = [
models.UniqueConstraint(fields=["membership", "requirement"], name="unique_requirement_status_per_membership"),
]
def __str__(self):
return f"{self.membership}{self.requirement}"
def clean(self):
validate_club_scope(self, self.membership.club_id, same_club_fields=("requirement",))
class ClubRole(ClubScopedModel):
class Roles(models.TextChoices):
ADMIN = "admin", _("admin")
MEMBER = "member", _("member")
EDITOR = "editor", _("editor")
#: Full read/write on people (members, families, groups, parent claims,
#: teams, referee setup, onboarding requirements) without Finance/Shop,
#: Club identity, Sponsors, or the ability to grant/revoke ClubRole itself
#: -- see club.services.access.can_manage_members and
#: club.mixins.MemberAdminRequiredMixin for exactly what that covers.
MEMBER_ADMIN = "member_admin", _("member admin")
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="roles", verbose_name=_("member"))
role = models.CharField(_("role"), max_length=250, choices=Roles.choices, default=Roles.MEMBER)

View File

@@ -45,19 +45,44 @@ def has_club_role(user: User, club: Club, role: ClubRole.Roles) -> bool:
return ClubRole.objects.filter(member__user=user, club=club, role=role).exists()
def is_platform_superuser(user: User) -> bool:
"""A Django superuser sees and manages every club as if they held ADMIN there,
with no ClubRole row needed -- the platform-operator override. Already forced
through MFA regardless (authentication.middleware.mfa_required_for checks
is_superuser directly), so this bypass never skips that."""
return bool(user and user.is_authenticated and user.is_superuser)
def is_club_admin(user: User, club: Club) -> bool:
return has_club_role(user, club, ClubRole.Roles.ADMIN)
return is_platform_superuser(user) or has_club_role(user, club, ClubRole.Roles.ADMIN)
def is_member_admin(user: User, club: Club) -> bool:
"""MEMBER_ADMIN: full read/write on people (members, families, groups, parent
claims, teams, referee setup, onboarding requirements) without Finance/Shop,
Club identity, Sponsors, or the ability to grant/revoke ClubRole itself --
see can_manage_members for the actual gate, this is just the role check."""
return has_club_role(user, club, ClubRole.Roles.MEMBER_ADMIN)
def can_manage_members(user: User, club: Club) -> bool:
"""The gate for club.mixins.MemberAdminRequiredMixin -- real ADMIN (which already
includes the superuser bypass), or MEMBER_ADMIN specifically."""
return is_club_admin(user, club) or is_member_admin(user, club)
def has_management_access(user: User, club: Club) -> bool:
"""Anyone with real authority in the club: ADMIN/EDITOR, or *any* current-season
staff assignment (coach, team manager, physio, ...).
"""Anyone with real authority in the club: ADMIN/EDITOR/MEMBER_ADMIN, a platform
superuser, or *any* current-season staff assignment (coach, team manager,
physio, ...).
Deliberately excludes the plain MEMBER role -- every signed-up player (or club
member generally) holds that automatically the moment their ClubMembership goes
active (club/signals.py), so it says nothing about whether someone is staff.
"""
elevated = ClubRole.objects.filter(member__user=user, club=club, role__in=(ClubRole.Roles.ADMIN, ClubRole.Roles.EDITOR)).exists()
if is_platform_superuser(user):
return True
elevated = ClubRole.objects.filter(member__user=user, club=club, role__in=(ClubRole.Roles.ADMIN, ClubRole.Roles.EDITOR, ClubRole.Roles.MEMBER_ADMIN)).exists()
return elevated or teams_staffed_by(user, club).exists()

View File

@@ -8,7 +8,6 @@ step here, never recomputed by re-aggregating FeePayment on every read.
from decimal import Decimal
from django.db.models import F
from django.utils import timezone
from club.models import ClubMembership, FeePayment
@@ -20,7 +19,8 @@ def remaining_balance(membership):
def record_payment(membership, *, amount, method=FeePayment.Method.BANK_TRANSFER, reference="", note="", recorded_by=None):
"""Record money received against one membership's fee. Several payments may
land on one membership -- a family paying in two installments must not read as
unpaid. Updates amount_paid and re-syncs fee_status/status to match."""
unpaid. Updates amount_paid and re-syncs fee_status to match; membership.status
is untouched -- see _sync_fee_status."""
payment = FeePayment.objects.create(membership=membership, amount=amount, method=method, reference=reference, note=note, recorded_by=recorded_by)
membership.amount_paid = F("amount_paid") + amount
@@ -54,16 +54,10 @@ def _sync_fee_status(membership, *, force_paid=False):
else:
new_status = ClubMembership.FeeStatus.UNPAID
# fee_status only -- membership.status is never touched here. Paying in full
# used to also flip status straight to ACTIVE on its own; now that's exclusively
# club.services.onboarding.approve_one/approve_all_clean's call, so a paid-up
# membership still waits on that deliberate admin step. See OnboardingRequirement's
# docstring (club/models.py) for why.
membership.fee_status = new_status
update_fields = ["fee_status"]
# Same "become a full member" behavior the bulk action already had: settling
# the fee in full also activates the membership, once, first time only.
if new_status == ClubMembership.FeeStatus.PAID:
membership.status = ClubMembership.StatusChoices.ACTIVE
update_fields.append("status")
if membership.activated_at is None:
membership.activated_at = timezone.localdate()
update_fields.append("activated_at")
membership.save(update_fields=update_fields)
membership.save(update_fields=["fee_status"])

231
club/services/onboarding.py Normal file
View File

@@ -0,0 +1,231 @@
"""Per-member onboarding checklist -- see OnboardingRequirement's docstring
(club/models.py) for why fee_status stays untouched by any of this, and for
why approve_one/approve_all_clean below are the only way to reach
ClubMembership.status ACTIVE (fee_status alone, even fully PAID, never does).
No signal pre-creates a MemberRequirementStatus row when a membership is created
or a requirement is added: "required, no row yet" and "required, row with
is_complete=is_bypassed=False" both mean the same thing (not done), so there is
nothing to backfill either way -- a club adding a new requirement mid-season
immediately shows it as open on every existing membership, and deactivating one
immediately stops asking for it, with no migration-shaped cleanup step in either
direction.
"""
from collections import defaultdict
from django.db.models import Q
from django.utils import timezone
from club.models import ClubMembership, MemberRequirementStatus, OnboardingRequirement
from members.models import Member
#: Shared by every "is this item resolved" check below -- resolved means it no
#: longer blocks anything, whether that's because it was actually completed or
#: because staff decided it doesn't apply to this person.
_RESOLVED = Q(is_complete=True) | Q(is_bypassed=True)
def checklist_for(membership):
"""Every active requirement for this membership's club, each paired with its
status row if one exists (or None -- not started). One query for the
requirements, one for the statuses that exist; the membership detail page
renders exactly this list under its Documents tab."""
requirements = OnboardingRequirement.objects.filter(club_id=membership.club_id, is_active=True)
statuses = {status.requirement_id: status for status in membership.requirement_statuses.select_related("completed_by")}
return [(requirement, statuses.get(requirement.pk)) for requirement in requirements]
def mark_complete(membership, requirement, *, user, document=None, note=""):
"""Actually received/verified -- as opposed to mark_bypassed, "not needed for
this person". Clears any prior bypass: the two are mutually exclusive."""
status, _created = MemberRequirementStatus.objects.get_or_create(membership=membership, requirement=requirement)
status.is_complete = True
status.is_bypassed = False
status.completed_at = timezone.now()
status.completed_by = user
status.note = note
if document:
status.document = document
status.save()
return status
def mark_bypassed(membership, requirement, *, user, note=""):
"""Confirmed not needed for this member (e.g. they already have a recent
photo on file) -- stops the item blocking anything, same as mark_complete,
but reads correctly on the checklist/audit trail as a deliberate staff
decision rather than a document actually received. A note is expected here
(not enforced at this layer -- see RequirementBypassForm) since "why" is the
whole point of a bypass in a way it isn't for an ordinary completion."""
status, _created = MemberRequirementStatus.objects.get_or_create(membership=membership, requirement=requirement)
status.is_complete = False
status.is_bypassed = True
status.completed_at = timezone.now()
status.completed_by = user
status.note = note
status.document = None
status.save()
return status
def mark_incomplete(membership, requirement):
"""Undo a mark_complete/mark_bypassed -- kept as a row (not deleted) so the
document/note a club already collected isn't thrown away by an accidental
toggle."""
status, _created = MemberRequirementStatus.objects.get_or_create(membership=membership, requirement=requirement)
status.is_complete = False
status.is_bypassed = False
status.completed_at = None
status.completed_by = None
status.save()
return status
def annotate_onboarding_status(queryset):
"""`queryset` of ClubMembership, returned as a list with each row given an
`.onboarding_open` attribute (count of unresolved active requirements) -- the
list-page equivalent of the `open_requirement_count` property, in a fixed
number of queries regardless of list size rather than the N+1 a per-row
property call would cost across a whole table."""
memberships = list(queryset)
if not memberships:
return memberships
required_by_club = {}
for club_id in {membership.club_id for membership in memberships}:
required_by_club[club_id] = set(OnboardingRequirement.objects.filter(club_id=club_id, is_active=True).values_list("pk", flat=True))
met_by_membership = defaultdict(set)
statuses = MemberRequirementStatus.objects.filter(membership_id__in=[membership.pk for membership in memberships]).filter(_RESOLVED)
for membership_id, requirement_id in statuses.values_list("membership_id", "requirement_id"):
met_by_membership[membership_id].add(requirement_id)
for membership in memberships:
required = required_by_club.get(membership.club_id, set())
membership.onboarding_open = len(required - met_by_membership[membership.pk])
return memberships
def members_with_open_requirements(club, season):
"""Members whose current-season membership has at least one unresolved active
requirement -- the same condition the dashboard's "Missing documentation" KPI
counts (management.views.HomeView), reused here for the member list's own
?docs=open filter. None when there's no season to check against."""
if season is None:
return Member.objects.none()
memberships = list(ClubMembership.objects.filter(club=club, season=season, kind=ClubMembership.Kind.MEMBER))
annotate_onboarding_status(memberships)
member_ids = [membership.member_id for membership in memberships if membership.onboarding_open]
return Member.objects.filter(pk__in=member_ids)
def blocking_event_kinds(membership) -> set:
"""Every event kind currently blocked for this membership by at least one open
(not complete, not bypassed) active requirement -- e.g. {"game"} while a medical
certificate is outstanding but nothing blocks training. Powers the Sign-up page's
detail pane and member_detail's Documents tab ("blocks: Games" next to an open
item), so staff can see exactly what's at stake without reading every requirement."""
blocked = set()
for requirement, status in checklist_for(membership):
if status is not None and (status.is_complete or status.is_bypassed):
continue
blocked.update(requirement.blocked_event_kinds)
return blocked
def blocked_member_ids_for_event(club, season, event_kind) -> set:
"""Member ids that must NOT be invited to (or selectable for) an event of
`event_kind` this season, because at least one active requirement that blocks
that kind is still open on their current-season membership. Bulk, not per-member
-- events.services.attendance.effective_members() calls this once per event save,
not once per candidate member.
A member with no current-season ClubMembership.MEMBER row at all isn't covered
here -- effective_members() already wouldn't include them (they're not on any
roster to begin with), so there's nothing to subtract.
Filtered in Python, not via a `blocked_event_kinds__contains=[event_kind]`
queryset lookup -- JSONField `contains` isn't supported on SQLite (only
Postgres/MySQL/Oracle), and a club's own requirement count is always small
enough that fetching them all costs nothing worth optimising away."""
blocking_requirement_ids = {requirement.pk for requirement in OnboardingRequirement.objects.filter(club=club, is_active=True) if event_kind in requirement.blocked_event_kinds}
if not blocking_requirement_ids:
return set()
memberships = ClubMembership.objects.filter(club=club, season=season, kind=ClubMembership.Kind.MEMBER)
resolved_by_membership = defaultdict(set)
statuses = MemberRequirementStatus.objects.filter(membership__in=memberships, requirement_id__in=blocking_requirement_ids).filter(_RESOLVED)
for membership_id, requirement_id in statuses.values_list("membership_id", "requirement_id"):
resolved_by_membership[membership_id].add(requirement_id)
blocked_member_ids = set()
for membership_id, member_id in memberships.values_list("pk", "member_id"):
if blocking_requirement_ids - resolved_by_membership.get(membership_id, set()):
blocked_member_ids.add(member_id)
return blocked_member_ids
#: Fee states "clean" enough to activate on -- PARTIALLY_PAID/UNPAID never are.
_CLEAN_FEE_STATUSES = (ClubMembership.FeeStatus.PAID, ClubMembership.FeeStatus.WAIVED)
def is_signup_clean(membership) -> bool:
"""Paid up (or waived) and every active requirement resolved -- what both
approve_all_clean and approve_one gate on, and what the Sign-up page's
per-member Approve button enables/disables against. Not itself a shortcut
for "already active": a membership can be exactly this clean and still be
PENDING, waiting on this deliberately manual step."""
return membership.fee_status in _CLEAN_FEE_STATUSES and membership.onboarding_complete
def approve_one(membership) -> bool:
"""Admin-triggered single activation from the Sign-up page's detail panel --
same rule and same reasoning as approve_all_clean, just one membership instead
of a whole season's queue. Returns whether it actually activated (False if it
wasn't PENDING or wasn't clean)."""
if membership.status != ClubMembership.StatusChoices.PENDING or not is_signup_clean(membership):
return False
membership.status = ClubMembership.StatusChoices.ACTIVE
update_fields = ["status"]
if membership.activated_at is None:
membership.activated_at = timezone.localdate()
update_fields.append("activated_at")
membership.save(update_fields=update_fields)
return True
def approve_all_clean(club, season) -> int:
"""Admin-triggered bulk activation from the Sign-up page -- the *only* path to
ClubMembership.status ACTIVE (see OnboardingRequirement's docstring: paying in
full only settles fee_status now, club.services.fees._sync_fee_status never
touches status). Only ever moves PENDING -> ACTIVE, and only for a membership
that is both paid up (fee_status PAID or WAIVED) and has resolved every active
requirement -- "manual documentation check to be done by the admin" means
clicking this once everything has actually been checked, not something that runs
on its own. Returns how many memberships were activated."""
memberships = list(
ClubMembership.objects.filter(
club=club,
season=season,
kind=ClubMembership.Kind.MEMBER,
status=ClubMembership.StatusChoices.PENDING,
fee_status__in=_CLEAN_FEE_STATUSES,
)
)
annotate_onboarding_status(memberships)
ready = [membership for membership in memberships if membership.onboarding_open == 0]
today = timezone.localdate()
for membership in ready:
membership.status = ClubMembership.StatusChoices.ACTIVE
if membership.activated_at is None:
membership.activated_at = today
if ready:
ClubMembership.objects.bulk_update(ready, ["status", "activated_at"])
return len(ready)

34
club/tasks.py Normal file
View File

@@ -0,0 +1,34 @@
"""Celery task behind the `generate-seasons` beat schedule entry (see
rosterchief/settings.CELERY_BEAT_SCHEDULE and features/jobs.py).
Mirrors `manage.py generate_seasons`'s default behaviour (generate, not --resync) exactly --
that command still exists, unchanged, for manual use from a shell, including --resync, which
this task deliberately does not run unattended (see club/management/commands/generate_seasons.py:
--resync can delete rows, so it isn't something a beat schedule should do on its own).
"""
from celery import shared_task
from dateutil.relativedelta import relativedelta
from django.utils import timezone
from club.models import Club
from club.services.seasons import generate_seasons as generate_seasons_for_club
from features.models import Maintenance
#: How far ahead to generate, matching the management command's own default.
YEARS_AHEAD = 2
@shared_task(name="club.tasks.generate_seasons")
def generate_seasons():
if Maintenance.is_on():
raise RuntimeError("Platform is in maintenance mode; this job stood down.")
until = timezone.localdate() + relativedelta(years=YEARS_AHEAD)
clubs = Club.objects.active()
total = 0
for club in clubs:
total += len(generate_seasons_for_club(club, until))
return f"Generated {total} season(s) across {clubs.count()} club(s)."

View File

@@ -8,6 +8,7 @@ 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
@@ -21,18 +22,34 @@ 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, FeePayment, Season, Sponsor, club_logo_path
from .models import Club, ClubMembership, ClubRole, 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, record_payment, remaining_balance
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,
)
from .services.seasons import _initial_season_start, _season_end, generate_seasons, resync_seasons
from .tenancy import (
ClubTenantMiddleware,
@@ -496,6 +513,26 @@ class SeasonNextAfterTests(TestCase):
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):
@@ -1035,6 +1072,61 @@ class AccessServiceTests(TestCase):
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
@@ -1154,16 +1246,16 @@ class BrandingTests(TestCase):
def test_the_base_domain_gets_the_platform_skin(self):
response = self.login_page("rosterchief.app")
self.assertTemplateUsed(response, "_platform_base.html")
self.assertTemplateUsed(response, "controlpanel/_auth_base.html")
self.assertTemplateNotUsed(response, "_club_base.html")
self.assertContains(response, "Club &amp; Team Management")
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, "_platform_base.html")
self.assertTemplateNotUsed(response, "controlpanel/_auth_base.html")
self.assertContains(response, "Ajax United")
self.assertEqual(response.context["club"], self.club)
@@ -1171,7 +1263,7 @@ class BrandingTests(TestCase):
# The subdomain stops resolving, so there is no club to brand with.
self.club.archive()
self.assertTemplateUsed(self.login_page("ajax-united.rosterchief.app"), "_platform_base.html")
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")
@@ -1210,6 +1302,55 @@ class BrandingTests(TestCase):
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"],
@@ -1217,14 +1358,17 @@ class BrandingTests(TestCase):
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, and the navbar
(sign out, theme toggle, home link) stays reachable."""
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_club_subdomain_403_gets_the_club_skin(self):
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)
@@ -1233,7 +1377,7 @@ class Custom403PageTests(TestCase):
self.assertEqual(response.status_code, 403)
self.assertContains(response, "Access denied", status_code=403)
self.assertContains(response, "Ajax United", status_code=403)
self.assertContains(response, "Sign out", 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"))
@@ -1242,7 +1386,8 @@ class Custom403PageTests(TestCase):
self.assertEqual(response.status_code, 403)
self.assertContains(response, "Access denied", status_code=403)
self.assertContains(response, "Club &amp; Team Management", status_code=403)
self.assertTemplateUsed(response, "controlpanel/_auth_base.html")
self.assertContains(response, "RosterChief", status_code=403)
class ClubBrandingModelTests(TestCase):
@@ -1352,17 +1497,21 @@ class FeeServiceTests(TestCase):
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_and_activates(self):
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.ACTIVE)
self.assertEqual(self.membership.activated_at, timezone.localdate())
self.assertTrue(self.roles().filter(role=ClubRole.Roles.MEMBER).exists())
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_does_not_overwrite_an_earlier_activated_at(self):
def test_settling_in_full_never_touches_activated_at(self):
earlier = datetime.date(2026, 1, 1)
self.membership.activated_at = earlier
self.membership.save()
@@ -1400,7 +1549,7 @@ class FeeServiceTests(TestCase):
unpriced.refresh_from_db()
self.assertEqual(unpriced.fee_status, ClubMembership.FeeStatus.PAID)
self.assertEqual(unpriced.status, ClubMembership.StatusChoices.ACTIVE)
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):
@@ -1652,3 +1801,294 @@ class GenerateSeasonsCommandTests(TestCase):
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", order=1)
cls.medical = OnboardingRequirement.objects.create(club=cls.club, name="Medical certificate", requires_document=True, order=2)
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())
# --- 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)