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:
@@ -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"]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
118
club/models.py
118
club/models.py
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
231
club/services/onboarding.py
Normal 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
34
club/tasks.py
Normal 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)."
|
||||
472
club/tests.py
472
club/tests.py
@@ -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 & 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 & 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)
|
||||
|
||||
Reference in New Issue
Block a user