"""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 def open_requirements_blocking(member, club, season, event_kind) -> list: """The specific, still-open `OnboardingRequirement`s blocking `member` from `event_kind` events this season -- the per-member, explain-*why* mirror of `blocked_member_ids_for_event`'s bulk set. Built for the member-facing "you can't sign up for this yet" card (mobile app): unlike that function, this one is meant to be called once for a person looking at one event, not once per event for a whole club, so the per-member cost here is fine. Empty (never blocked) for a member with no current-season ClubMembership.MEMBER row -- same "nothing to check" reasoning as blocked_member_ids_for_event's own.""" membership = ClubMembership.objects.filter(club=club, season=season, member=member, kind=ClubMembership.Kind.MEMBER).first() if membership is None: return [] blocking = [requirement for requirement in OnboardingRequirement.objects.filter(club=club, is_active=True) if event_kind in requirement.blocked_event_kinds] if not blocking: return [] resolved_ids = set(MemberRequirementStatus.objects.filter(membership=membership, requirement__in=blocking).filter(_RESOLVED).values_list("requirement_id", flat=True)) return [requirement for requirement in blocking if requirement.pk not in resolved_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)