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:
@@ -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)
|
||||
Reference in New Issue
Block a user