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

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

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

View File

@@ -1,9 +1,9 @@
from datetime import timedelta
from datetime import date, timedelta
from decimal import Decimal
from django.db import IntegrityError, transaction
from django.db.models import Count, ProtectedError, Q
from django.http import HttpResponse
from django.http import FileResponse, Http404, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
@@ -19,19 +19,22 @@ from club.mixins import (
EventManagerRequiredMixin,
FeatureRequiredMixin,
ManagementPositionRequiredMixin,
MemberAdminRequiredMixin,
NewsAuthorRequiredMixin,
NewsEditRequiredMixin,
NewsPublisherRequiredMixin,
TeamManagerRequiredMixin,
)
from club.models import ClubMembership, ClubRole, Season, Sponsor
from club.models import ClubMembership, ClubRole, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
from club.services.access import _guardians_only, can_edit_news, can_publish_news, current_season, groups_manageable_by, is_club_admin, members_visible_to, teams_managed_by, teams_staffed_by
from club.services.fees import mark_as_paid, record_payment, remaining_balance
from club.services.onboarding import annotate_onboarding_status, approve_all_clean, approve_one, blocking_event_kinds, checklist_for, is_signup_clean, mark_bypassed, mark_complete, mark_incomplete, members_with_open_requirements
from controlpanel.messages import notify
from controlpanel.mixins import RedirectOnInvalidMixin
from controlpanel.services.statistics import club_attention, club_charts, club_statistics
from controlpanel.services.statistics import club_attention, club_charts, club_statistics, unrostered_members
from events.models import Attendance, Event, EventReferee, EventSeries, Location, Opponent
from events.services.attendance import player_attendance_rankings, players_who_missed_recent_practices, team_attendance_rate, team_no_shows
from events.services.calendar import add_months, month_bounds, month_grid, season_grid, week_bounds, week_grid
from events.services.competitions import CompetitionFetchError, fetch_game_info
from events.services.rbihf_import import RBIHFImportError, apply_plan, build_plan, extract_team_id, fetch_html
from events.services.recurrence import cancel_occurrence, detach_occurrence, generate_occurrences, propagate_series
@@ -54,6 +57,7 @@ from .forms import (
AttachToFamilyForm,
ClubMembershipForm,
ClubRoleAssignForm,
ClubSettingsForm,
EventForm,
EventRefereeFeeForm,
EventSeriesForm,
@@ -69,11 +73,15 @@ from .forms import (
NewsForm,
NewsPhotoUploadForm,
NewsPublishForm,
OnboardingRequirementForm,
OpponentForm,
PositionForm,
RBIHFImportForm,
RecordFeePaymentForm,
RefereeLevelForm,
RequirementBypassForm,
RequirementCompletionForm,
SignupTeamPlacementForm,
SponsorForm,
StaffAssignmentForm,
TeamBulkAddFormSet,
@@ -112,11 +120,36 @@ class HomeView(ClubStaffRequiredMixin, TemplateView):
upcoming_events = scoped_to_managed_teams(Event.objects.filter(club=club, start__gte=timezone.now()), user, club).order_by("start").prefetch_related("teams")[:5]
attention = club_attention(club)
season = attention["season"]
# Compared against the season right before this one (not last year on the
# calendar) -- Season.before, same "adjacent by date" idea as next_after.
member_count = ClubMembership.objects.filter(club=club, season=season, kind=ClubMembership.Kind.MEMBER).count() if season else 0
previous_season = Season.before(club, season) if season else None
member_count_change = None
if previous_season is not None:
previous_member_count = ClubMembership.objects.filter(club=club, season=previous_season, kind=ClubMembership.Kind.MEMBER).count()
member_count_change = member_count - previous_member_count
# Hidden entirely (not just zeroed) for a club that hasn't set up any
# onboarding requirements -- "0 missing" would otherwise read as "everyone's
# paperwork is in", when really there's no paperwork being tracked at all.
requirements_configured = OnboardingRequirement.objects.filter(club=club, is_active=True).exists()
missing_documentation_count = 0
if requirements_configured and season is not None:
memberships_this_season = ClubMembership.objects.filter(club=club, season=season, kind=ClubMembership.Kind.MEMBER)
missing_documentation_count = sum(1 for membership in annotate_onboarding_status(memberships_this_season) if membership.onboarding_open)
return super().get_context_data(
attention=club_attention(club),
attention=attention,
charts=club_charts(club),
groups=club_statistics(club),
upcoming_events=upcoming_events,
member_count=member_count,
member_count_change=member_count_change,
requirements_configured=requirements_configured,
missing_documentation_count=missing_documentation_count,
published_news=News.objects.filter(club=club, status=News.Status.PUBLISHED, published_at__lte=timezone.now()).order_by("-published_at")[:5],
billing_ends_at=billing_ends_at,
billing_auto_renews=subscription.auto_renew if subscription else False,
@@ -206,13 +239,52 @@ class MemberListView(ClubStaffRequiredMixin, ListView):
members = members_visible_to(self.request.user, self.request.club)
self.selected_kind = kind
# Status/fee status/team all key off the *current* season's ClubMembership --
# a member's status/fee last season (or next) isn't what "filter by Pending"
# means on a page showing everyone's standing right now.
season = current_season(self.request.club)
self.selected_status = self.request.GET.get("status", "")
self.selected_fee_status = self.request.GET.get("fee_status", "")
self.selected_team = self.request.GET.get("team", "")
self.selected_unrostered = self.request.GET.get("unrostered", "")
self.selected_docs = self.request.GET.get("docs", "")
if self.selected_status:
members = members.filter(member_of__season=season, member_of__status=self.selected_status)
if self.selected_fee_status:
members = members.filter(member_of__season=season, member_of__fee_status=self.selected_fee_status)
if self.selected_team:
members = members.filter(team_memberships__season=season, team_memberships__team_id=self.selected_team)
if self.selected_unrostered:
# The dashboard's "Unrostered members" attention row -- same query
# club_attention() itself counts (controlpanel.services.statistics).
members = members.filter(pk__in=unrostered_members(self.request.club, season).values("pk"))
if self.selected_docs == "open":
# The dashboard's "Missing documentation" KPI/attention row.
members = members.filter(pk__in=members_with_open_requirements(self.request.club, season).values("pk"))
search = self.request.GET.get("q", "").strip()
if search:
members = members.filter(first_name__icontains=search) | members.filter(last_name__icontains=search) | members.filter(email__icontains=search) | members.filter(user__email__icontains=search)
return members.distinct()
def get_context_data(self, **kwargs):
context = super().get_context_data(search=self.request.GET.get("q", ""), selected_kind=self.selected_kind, **kwargs)
total_requirements = OnboardingRequirement.objects.filter(club=self.request.club, is_active=True).count()
context = super().get_context_data(
search=self.request.GET.get("q", ""),
selected_kind=self.selected_kind,
selected_status=self.selected_status,
selected_fee_status=self.selected_fee_status,
selected_team=self.selected_team,
selected_unrostered=self.selected_unrostered,
selected_docs=self.selected_docs,
status_choices=ClubMembership.StatusChoices.choices,
fee_status_choices=ClubMembership.FeeStatus.choices,
teams=Team.objects.filter(club=self.request.club),
requirements_configured=total_requirements > 0,
total_requirements=total_requirements,
**kwargs,
)
members = list(context["members"])
memberships = FamilyMembership.objects.filter(member__in=members).select_related("family")
@@ -226,6 +298,10 @@ class MemberListView(ClubStaffRequiredMixin, ListView):
club_memberships_by_member_id = {}
if season is not None:
club_memberships = ClubMembership.objects.filter(club=self.request.club, season=season, member__in=members)
if total_requirements:
club_memberships = annotate_onboarding_status(club_memberships)
for cm in club_memberships:
cm.completed_requirements = total_requirements - cm.onboarding_open
club_memberships_by_member_id = {cm.member_id: cm for cm in club_memberships}
for member in members:
member.current_membership = club_memberships_by_member_id.get(member.pk)
@@ -352,16 +428,16 @@ class MembershipListView(ClubAdminRequiredMixin, ListView):
class MembershipMarkPaidView(ClubAdminRequiredMixin, View):
"""Flag a batch of memberships as settled: active + paid, in one go. There's no
bank integration, so this is always a manual admin action -- the point of this
view is to make the manual action fast, not to replace it with automation.
"""Flag a batch of memberships as settled: fee_status -> paid, in one go. There's
no bank integration, so this is always a manual admin action -- the point of this
view is to make the manual action fast, not to replace it with automation. Does
not touch status/activate the membership -- see club.services.fees.mark_as_paid
and OnboardingRequirement's docstring (club/models.py) for why that's exclusively
the Sign-up page's Approve step.
Uses club.services.fees.mark_as_paid per row (a .save() loop under the hood,
never a bulk .update()): club/signals.py grants the MEMBER ClubRole via a
post_save signal on ClubMembership, which .update() would bypass entirely,
silently leaving a marked-paid member without their role. Same function backs
the per-row "Mark fully paid" button (MembershipMarkFullyPaidView) -- one place
decides how a membership becomes paid.
never a bulk .update()), matching the per-row "Mark fully paid" button
(MembershipMarkFullyPaidView) -- one place decides how a membership becomes paid.
"""
def post(self, request):
@@ -390,7 +466,7 @@ class MembershipMarkFullyPaidView(ClubAdminRequiredMixin, View):
def post(self, request, pk):
membership = get_object_or_404(ClubMembership, pk=pk, club=request.club)
mark_as_paid(membership, recorded_by=request.user)
notify(request, f"s|{_('Marked as paid')}|{_('%(member)s” is now active and paid.') % {'member': membership.member}}")
notify(request, f"s|{_('Marked as paid')}|{_('%(member)s” is now paid.') % {'member': membership.member}}")
next_url = request.POST.get("next")
if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}, require_https=request.is_secure()):
@@ -462,7 +538,7 @@ class MemberImportTemplateView(ClubStaffRequiredMixin, View):
return response
class MemberImportView(ClubAdminRequiredMixin, View):
class MemberImportView(MemberAdminRequiredMixin, View):
"""Step 1 of the mass-upload: upload a filled-in template, see exactly what
will be created before anything actually is. Row data -- already extracted to
plain values, see bulk_import.read_member_import_workbook -- rides in the
@@ -497,7 +573,7 @@ class MemberImportView(ClubAdminRequiredMixin, View):
)
class MemberImportConfirmView(ClubAdminRequiredMixin, View):
class MemberImportConfirmView(MemberAdminRequiredMixin, View):
"""Step 2: no row data in the request at all, just a submit button -- there's
nothing here for a client to tamper with. Re-parses the rows stashed in the
session by MemberImportView, so what gets created is guaranteed to match what
@@ -559,7 +635,7 @@ class MemberImportConfirmView(ClubAdminRequiredMixin, View):
return redirect("management:member_list")
class MemberCreateView(ClubAdminRequiredMixin, CreateView):
class MemberCreateView(MemberAdminRequiredMixin, CreateView):
model = Member
form_class = MemberForm
template_name = "management/member_form.html"
@@ -590,7 +666,7 @@ class MemberCreateView(ClubAdminRequiredMixin, CreateView):
return reverse("management:member_detail", args=[self.object.pk])
class MemberUpdateView(ClubAdminRequiredMixin, View):
class MemberUpdateView(MemberAdminRequiredMixin, View):
"""Not a generic UpdateView: this page owns two forms on one submit -- the
member's own fields, and (when they're actually rostered this season) their
ClubMembership's license/status/fee status. A Member has no club of its own
@@ -647,6 +723,7 @@ class MemberDetailView(ClubStaffRequiredMixin, DetailView):
is_admin = is_club_admin(self.request.user, self.request.club)
referee_profile = RefereeProfile.objects.filter(member=self.object).select_related("level").first()
current_membership = ClubMembership.objects.filter(club=self.request.club, member=self.object, season=current_season(self.request.club)).first()
return super().get_context_data(
family_groups=family_groups,
@@ -654,7 +731,8 @@ class MemberDetailView(ClubStaffRequiredMixin, DetailView):
add_child_form=AddChildForm(),
add_parent_form=AddParentForm(),
attach_to_family_form=AttachToFamilyForm(club=self.request.club, member=self.object),
current_membership=ClubMembership.objects.filter(club=self.request.club, member=self.object, season=current_season(self.request.club)).first(),
current_membership=current_membership,
checklist=checklist_for(current_membership) if current_membership else [],
membership_history=ClubMembership.objects.filter(club=self.request.club, member=self.object).select_related("season").order_by("-season__start_date"),
# Non-empty only when `member` is a CHILD in some family -- that's the same
# signal the Personal information card uses to decide whether to show parent
@@ -666,7 +744,7 @@ class MemberDetailView(ClubStaffRequiredMixin, DetailView):
)
class MemberAttachToFamilyView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormView):
class MemberAttachToFamilyView(MemberAdminRequiredMixin, RedirectOnInvalidMixin, FormView):
"""Reachable only via the "Add to family" modal on a standalone member's page."""
form_class = AttachToFamilyForm
@@ -688,7 +766,7 @@ class MemberAttachToFamilyView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, F
return redirect("management:member_detail", pk=member.pk)
class MemberRefereeEligibilityUpdateView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormView):
class MemberRefereeEligibilityUpdateView(MemberAdminRequiredMixin, RedirectOnInvalidMixin, FormView):
"""Reachable only via the "Referee eligibility" modal on a member's page --
which teams' home games this member can be assigned to referee
(teams.RefereeProfile). Admin-only, same as Roles/Positions/Groups."""
@@ -713,7 +791,7 @@ class MemberRefereeEligibilityUpdateView(ClubAdminRequiredMixin, RedirectOnInval
return redirect("management:member_detail", pk=member.pk)
class MemberGrantLoginView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormView):
class MemberGrantLoginView(MemberAdminRequiredMixin, RedirectOnInvalidMixin, FormView):
"""Reachable only via the "Grant login" modal on a login-less child's row in
_family_members_table.html. The form itself (management.forms.GrantLoginForm)
already rejects an email already in use, so form_valid only ever runs with a
@@ -738,7 +816,7 @@ class MemberGrantLoginView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormV
return redirect("management:member_detail", pk=member.pk)
class MemberDetachFromFamilyView(ClubAdminRequiredMixin, View):
class MemberDetachFromFamilyView(MemberAdminRequiredMixin, View):
def post(self, request, pk, family_pk):
member = get_object_or_404(members_visible_to(request.user, request.club, include_guardians=True), pk=pk)
family = get_object_or_404(Family, pk=family_pk, memberships__member=member)
@@ -751,7 +829,7 @@ class MemberDetachFromFamilyView(ClubAdminRequiredMixin, View):
return redirect("management:member_detail", pk=member.pk)
class FamilyMembershipRoleUpdateView(ClubAdminRequiredMixin, View):
class FamilyMembershipRoleUpdateView(MemberAdminRequiredMixin, View):
"""Reclassify one person's role within one specific family -- from the inline
dropdown in _family_members_table.html, reachable from both the member and
family detail pages since that partial is shared between them. Lands back on
@@ -780,7 +858,7 @@ class FamilyMembershipRoleUpdateView(ClubAdminRequiredMixin, View):
return redirect("management:family_detail", pk=family.pk)
class MemberDeleteView(ClubAdminRequiredMixin, View):
class MemberDeleteView(MemberAdminRequiredMixin, View):
def post(self, request, pk):
member = get_object_or_404(members_visible_to(request.user, request.club, include_guardians=True), pk=pk)
name = str(member)
@@ -837,7 +915,7 @@ class TeamListView(ClubStaffRequiredMixin, ListView):
return super().get_context_data(search=self.request.GET.get("q", ""), **kwargs)
class TeamCreateView(ClubAdminRequiredMixin, CreateView):
class TeamCreateView(MemberAdminRequiredMixin, CreateView):
model = Team
form_class = TeamForm
template_name = "management/team_form.html"
@@ -852,7 +930,7 @@ class TeamCreateView(ClubAdminRequiredMixin, CreateView):
return reverse("management:team_detail", args=[self.object.pk])
class TeamUpdateView(ClubAdminRequiredMixin, UpdateView):
class TeamUpdateView(MemberAdminRequiredMixin, UpdateView):
model = Team
form_class = TeamForm
template_name = "management/team_form.html"
@@ -873,7 +951,7 @@ class TeamUpdateView(ClubAdminRequiredMixin, UpdateView):
return super().get_context_data(update_view=True, **kwargs)
class TeamDeleteView(ClubAdminRequiredMixin, View):
class TeamDeleteView(MemberAdminRequiredMixin, View):
def post(self, request, pk):
team = get_object_or_404(Team.objects.filter(club=request.club), pk=pk)
name = str(team)
@@ -1403,7 +1481,7 @@ class FamilyListView(ClubStaffRequiredMixin, ListView):
return context | {"families": families}
class FamilyCreateView(ClubAdminRequiredMixin, FormView):
class FamilyCreateView(MemberAdminRequiredMixin, FormView):
"""One new family in one go: a parent (who gets a login) and a child (who
doesn't) -- see members.services.family.register_family."""
@@ -1469,7 +1547,7 @@ class FamilyDetailView(ClubStaffRequiredMixin, DetailView):
)
class FamilyAddChildView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormView):
class FamilyAddChildView(MemberAdminRequiredMixin, RedirectOnInvalidMixin, FormView):
"""Reachable only via the "Add child" modal on the family overview / member
detail pages -- a family that needs one more child registered, most often a
sibling joining."""
@@ -1489,7 +1567,7 @@ class FamilyAddChildView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormVie
return redirect("management:family_detail", pk=family.pk)
class FamilyAddParentView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormView):
class FamilyAddParentView(MemberAdminRequiredMixin, RedirectOnInvalidMixin, FormView):
"""Reachable only via the "Add parent" modal on the family overview / member
detail pages -- a family that needs one more parent/guardian registered."""
@@ -1508,7 +1586,7 @@ class FamilyAddParentView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormVi
return redirect("management:family_detail", pk=family.pk)
class ParentClaimListView(ClubAdminRequiredMixin, ListView):
class ParentClaimListView(MemberAdminRequiredMixin, ListView):
"""The review queue for parents asking to be linked to a child.
Approving is a human decision on purpose -- see members.models.ParentClaim.
@@ -1546,7 +1624,7 @@ class ParentClaimListView(ClubAdminRequiredMixin, ListView):
)
class ParentClaimApproveView(ClubAdminRequiredMixin, View):
class ParentClaimApproveView(MemberAdminRequiredMixin, View):
"""Link the claim's parent to the chosen child. The parent lands as a
guardian, not a member -- see members.services.claims.approve_claim."""
@@ -1576,7 +1654,7 @@ class ParentClaimApproveView(ClubAdminRequiredMixin, View):
return redirect("management:parent_claim_list")
class ParentClaimRejectView(ClubAdminRequiredMixin, View):
class ParentClaimRejectView(MemberAdminRequiredMixin, View):
def post(self, request, pk):
claim = get_object_or_404(ParentClaim.objects.filter(club=request.club), pk=pk)
reviewer = Member.objects.filter(user=request.user).first()
@@ -1649,7 +1727,7 @@ class RefereeLevelListView(ClubStaffRequiredMixin, ListView):
return RefereeLevel.objects.filter(club=self.request.club).prefetch_related("teams")
class RefereeLevelCreateView(ClubAdminRequiredMixin, CreateView):
class RefereeLevelCreateView(MemberAdminRequiredMixin, CreateView):
model = RefereeLevel
form_class = RefereeLevelForm
template_name = "management/referee_level_form.html"
@@ -1668,7 +1746,7 @@ class RefereeLevelCreateView(ClubAdminRequiredMixin, CreateView):
return reverse("management:referee_level_list")
class RefereeLevelUpdateView(ClubAdminRequiredMixin, UpdateView):
class RefereeLevelUpdateView(MemberAdminRequiredMixin, UpdateView):
model = RefereeLevel
form_class = RefereeLevelForm
template_name = "management/referee_level_form.html"
@@ -1709,7 +1787,7 @@ class RefereeListView(ClubStaffRequiredMixin, ListView):
# a referee pool, ...) -- admin-only, like Positions/Roles above -------------------
class GroupListView(ClubAdminRequiredMixin, ListView):
class GroupListView(MemberAdminRequiredMixin, ListView):
template_name = "management/group_list.html"
context_object_name = "groups"
paginate_by = 25
@@ -1721,7 +1799,7 @@ class GroupListView(ClubAdminRequiredMixin, ListView):
return Group.objects.filter(club=self.request.club).annotate(member_count=Count("memberships", distinct=True)).order_by("name")
class GroupCreateView(ClubAdminRequiredMixin, CreateView):
class GroupCreateView(MemberAdminRequiredMixin, CreateView):
model = Group
form_class = GroupForm
template_name = "management/group_form.html"
@@ -1737,7 +1815,7 @@ class GroupCreateView(ClubAdminRequiredMixin, CreateView):
return reverse("management:group_detail", args=[self.object.pk])
class GroupUpdateView(ClubAdminRequiredMixin, UpdateView):
class GroupUpdateView(MemberAdminRequiredMixin, UpdateView):
model = Group
form_class = GroupForm
template_name = "management/group_form.html"
@@ -1758,7 +1836,7 @@ class GroupUpdateView(ClubAdminRequiredMixin, UpdateView):
return super().get_context_data(update_view=True, **kwargs)
class GroupDeleteView(ClubAdminRequiredMixin, View):
class GroupDeleteView(MemberAdminRequiredMixin, View):
def post(self, request, pk):
group = get_object_or_404(Group.objects.filter(club=request.club), pk=pk)
name = str(group)
@@ -1767,7 +1845,7 @@ class GroupDeleteView(ClubAdminRequiredMixin, View):
return redirect("management:group_list")
class GroupDetailView(ClubAdminRequiredMixin, DetailView):
class GroupDetailView(MemberAdminRequiredMixin, DetailView):
template_name = "management/group_detail.html"
context_object_name = "group"
@@ -1781,7 +1859,7 @@ class GroupDetailView(ClubAdminRequiredMixin, DetailView):
)
class GroupBulkAddView(ClubAdminRequiredMixin, View):
class GroupBulkAddView(MemberAdminRequiredMixin, View):
"""Add many members to a group in one go -- mirrors TeamBulkAddView's
searchable-row formset, minus the position/jersey columns (group membership
carries no per-member attributes)."""
@@ -1827,7 +1905,7 @@ class GroupBulkAddView(ClubAdminRequiredMixin, View):
return redirect("management:group_detail", pk=group.pk)
class GroupMemberRemoveView(ClubAdminRequiredMixin, View):
class GroupMemberRemoveView(MemberAdminRequiredMixin, View):
def post(self, request, pk, membership_pk):
group = get_object_or_404(Group.objects.filter(club=request.club), pk=pk)
membership = get_object_or_404(GroupMembership.objects.filter(group=group), pk=membership_pk)
@@ -2029,71 +2107,138 @@ class NewsPhotoDeleteView(NewsEditRequiredMixin, View):
def scoped_to_managed_teams(queryset, user, club):
"""Non-admin: only rows for a team they manage, plus team-less/club-wide
ones (a social, an AGM) -- there's no team to scope those to, so they stay
visible to everyone. Same "manages" rule as who can edit (teams_managed_by),
not the broader "staffed on any role" one. Works for any queryset whose
model has a `teams` M2M -- Event and EventSeries both do -- so it backs the
events list, the dashboard's upcoming-events widget, and both detail views
(an out-of-scope one 404s if opened directly, same as any other
"""Non-admin: only rows for a team they manage or a group they belong to,
plus rows tied to neither (a club-wide social, an AGM) -- there's nothing
to scope those to, so they stay visible to everyone. Same "manages"/
"belongs to" rules as who can create against a given team/group
(teams_managed_by/groups_manageable_by), not the broader "staffed on any
role" one. Works for any queryset whose model has `teams`/`groups` M2Ms --
Event and EventSeries both do -- so it backs the events list, the
dashboard's upcoming-events widget, and both detail views (an
out-of-scope one 404s if opened directly, same as any other
queryset-scoped detail view here, not just unlisted)."""
if is_club_admin(user, club):
return queryset
managed_team_ids = teams_managed_by(user, club).values_list("pk", flat=True)
return queryset.filter(Q(teams__in=managed_team_ids) | Q(teams__isnull=True)).distinct()
member_group_ids = groups_manageable_by(user, club).values_list("pk", flat=True)
return queryset.filter(Q(teams__in=managed_team_ids) | Q(groups__in=member_group_ids) | Q(teams__isnull=True, groups__isnull=True)).distinct()
class EventListView(ClubStaffRequiredMixin, ListView):
"""Upcoming by default (what a coach actually opens this page to check);
``?show_past=1`` flips to the most recent past events instead. Season
filtering mirrors Event.season's own "explicit, else derived from start
date" rule (events/models.py) rather than requiring a stored season on
every row."""
"""The D5-alike calendar (``?view=calendar``, the default) plus the
original table (``?view=list``) this page used to be exclusively --
the List toggle in the template switches between the two without
changing URL. Calendar mode further switches between Week/Month/Season
via ``?range=``, navigated with ``?date=<iso date>`` (week/month) or the
existing ``?season=<pk>`` selector (season range, matching every other
season-scoped page).
List mode is upcoming-by-default (what a coach actually opens this page
to check); ``?show_past=1`` flips it to the most recent past events
instead, and it alone paginates -- a calendar view always shows its whole
window at once, see get_paginate_by. Calendar mode's season filtering
mirrors Event.season's own "explicit, else derived from start date" rule
(events/models.py) rather than requiring a stored season on every row."""
template_name = "management/event_list.html"
context_object_name = "events"
paginate_by = 25
def get_queryset(self):
club = self.request.club
user = self.request.user
def get_paginate_by(self, queryset):
return None if self.request.GET.get("view", "calendar") == "calendar" else self.paginate_by
def _anchor_date(self):
raw = self.request.GET.get("date")
if raw:
try:
return date.fromisoformat(raw)
except ValueError:
pass
return timezone.localdate()
def _base_queryset(self):
club, user = self.request.club, self.request.user
events = Event.objects.filter(club=club).select_related("location", "opponent").prefetch_related("teams")
season = selected_season_from_request(self.request, club)
if season is not None:
events = events.filter(Q(season=season) | Q(season__isnull=True, start__date__gte=season.start_date, start__date__lte=season.end_date))
kind = self.request.GET.get("kind", "")
if kind:
events = events.filter(kind=kind)
return scoped_to_managed_teams(events, user, club)
events = scoped_to_managed_teams(events, user, club)
def get_queryset(self):
club, user = self.request.club, self.request.user
events = self._base_queryset()
is_admin = is_club_admin(user, club)
managed_team_ids = set() if is_admin else set(teams_managed_by(user, club).values_list("pk", flat=True))
now = timezone.now()
if self.request.GET.get("show_past") == "1":
events = list(events.filter(start__lt=now).order_by("-start"))
if self.request.GET.get("view", "calendar") == "calendar":
range_kind = self.request.GET.get("range", "week")
if range_kind == "week":
start, end = week_bounds(self._anchor_date())
elif range_kind == "season":
season = selected_season_from_request(self.request, club)
start, end = (season.start_date, season.end_date) if season else month_bounds(self._anchor_date())
else:
start, end = month_bounds(self._anchor_date())
events = list(events.filter(start__date__gte=start, start__date__lte=end).order_by("start"))
else:
events = list(events.filter(start__gte=now).order_by("start"))
season = selected_season_from_request(self.request, club)
if season is not None:
events = events.filter(Q(season=season) | Q(season__isnull=True, start__date__gte=season.start_date, start__date__lte=season.end_date))
now = timezone.now()
if self.request.GET.get("show_past") == "1":
events = list(events.filter(start__lt=now).order_by("-start"))
else:
events = list(events.filter(start__gte=now).order_by("start"))
# Attached per row so the template can show/hide Edit/Delete per event --
# computed once here rather than a query per row (teams is already
# prefetched above).
is_admin = is_club_admin(user, club)
managed_team_ids = set() if is_admin else set(teams_managed_by(user, club).values_list("pk", flat=True))
for event in events:
event.can_manage = is_admin or any(team.pk in managed_team_ids for team in event.teams.all())
return events
def _calendar_context(self, range_kind, anchor, selected_season):
"""The grid + prev/next/today nav for whichever range is active --
events is self.object_list, already windowed to it by get_queryset
above. Season range has no anchor-based nav: it steps between actual
Season rows (seasons, already in context) via next(start_date) since
that list is sorted newest-first."""
events = self.object_list
if range_kind == "week":
grid = week_grid(events, week_bounds(anchor)[0])
nav = {"prev": anchor - timedelta(days=7), "next": anchor + timedelta(days=7)}
elif range_kind == "season":
grid = {"months": season_grid(events, selected_season)} if selected_season else None
nav = {}
else:
grid = month_grid(events, anchor)
nav = {"prev": add_months(anchor, -1), "next": add_months(anchor, 1)}
return grid, nav
def get_context_data(self, **kwargs):
club, user = self.request.club, self.request.user
view_mode = self.request.GET.get("view", "calendar")
range_kind = self.request.GET.get("range", "week")
anchor = self._anchor_date()
selected_season = selected_season_from_request(self.request, club)
calendar, nav = (None, None)
if view_mode == "calendar":
calendar, nav = self._calendar_context(range_kind, anchor, selected_season)
return super().get_context_data(
seasons=Season.objects.filter(club=club).order_by("-start_date"),
selected_season=selected_season_from_request(self.request, club),
selected_season=selected_season,
selected_kind=self.request.GET.get("kind", ""),
show_past=self.request.GET.get("show_past") == "1",
event_kinds=Event.EventKind.choices,
can_create=is_club_admin(user, club) or teams_managed_by(user, club).exists(),
view_mode=view_mode,
calendar_range=range_kind,
anchor=anchor,
today=timezone.localdate(),
calendar=calendar,
nav=nav,
**kwargs,
)
@@ -2132,9 +2277,6 @@ class EventDetailView(ClubStaffRequiredMixin, DetailView):
if referee_management_needed:
referees = list(event.referees.select_related("member", "assigned_by").order_by("member__last_name", "member__first_name"))
referees_full = len(referees) >= event.max_referees
if can_manage_referees:
for referee in referees:
referee.fee_form = EventRefereeFeeForm(instance=referee)
if can_manage_referees and not referees_full:
for candidate in eligible_referees(event):
conflicts = conflicting_events(candidate, event)
@@ -2327,7 +2469,7 @@ def games_missing_referees_count(club, limit=10):
return sum(1 for game in games if game.referee_count == 0)
class RefereeManagementDashboardView(ClubAdminRequiredMixin, TemplateView):
class RefereeManagementDashboardView(MemberAdminRequiredMixin, TemplateView):
"""One-stop admin view of every upcoming home game that needs a
club-arranged referee (federation-managed teams never appear here, see
events.services.referees.needs_referee_management), with inline
@@ -2366,19 +2508,21 @@ class RefereeManagementDashboardView(ClubAdminRequiredMixin, TemplateView):
kpi_no_referee = 0
kpi_understaffed = 0
kpi_fully_staffed = 0
kpi_fees_pending = 0
for game in games:
game.referee_rows = list(game.referees.all())
for referee in game.referee_rows:
referee.fee_form = EventRefereeFeeForm(instance=referee)
game.referees_full = len(game.referee_rows) >= game.max_referees
game.referee_candidates = []
game.fees_pending = any(not referee.fee for referee in game.referee_rows)
if not game.referee_rows:
kpi_no_referee += 1
elif not game.referees_full:
kpi_understaffed += 1
else:
kpi_fully_staffed += 1
if game.fees_pending:
kpi_fees_pending += 1
if not game.referees_full:
for candidate in eligible_referees(game):
conflicts = conflicting_events(candidate, game)
@@ -2393,6 +2537,7 @@ class RefereeManagementDashboardView(ClubAdminRequiredMixin, TemplateView):
kpi_no_referee=kpi_no_referee,
kpi_understaffed=kpi_understaffed,
kpi_fully_staffed=kpi_fully_staffed,
kpi_fees_pending=kpi_fees_pending,
**kwargs,
)
@@ -2975,3 +3120,312 @@ class SubmissionListView(FeatureRequiredMixin, StubListMixin, ListView):
def get_queryset(self):
return Submission.objects.filter(form__club=self.request.club, form_id=self.kwargs["pk"])
class ClubSettingsView(ClubAdminRequiredMixin, UpdateView):
"""A club's own self-service identity/branding editor -- the "Club identity"
settings sub-item. Singleton by construction: always edits request.club, never
a pk from the URL (there is exactly one club to edit here, unlike controlpanel's
ClubForm which picks one out of every club on the platform)."""
form_class = ClubSettingsForm
template_name = "management/club_settings.html"
def get_object(self, queryset=None):
return self.request.club
def form_valid(self, form):
response = super().form_valid(form)
notify(self.request, f"s|{_('Settings saved')}|{_('Club identity updated.')}")
return response
def get_success_url(self):
return reverse("management:club_settings")
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs)
class OnboardingRequirementListView(MemberAdminRequiredMixin, ListView):
"""What a club requires from every member after they sign up or renew (a
photo, a medical certificate, ...) -- see club/models.py's OnboardingRequirement
docstring for why this is tracked separately from status/fee_status."""
template_name = "management/onboarding_requirement_list.html"
context_object_name = "requirements"
def get_queryset(self):
return OnboardingRequirement.objects.filter(club=self.request.club)
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs)
class OnboardingRequirementCreateView(MemberAdminRequiredMixin, CreateView):
model = OnboardingRequirement
form_class = OnboardingRequirementForm
template_name = "management/onboarding_requirement_form.html"
def form_valid(self, form):
form.instance.club = self.request.club
response = super().form_valid(form)
notify(self.request, f"s|{_('Requirement added')}|" + _("%(name)s” is now required for every member.") % {"name": self.object.name})
return response
def get_success_url(self):
return reverse("management:onboarding_requirement_list")
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs)
class OnboardingRequirementUpdateView(MemberAdminRequiredMixin, UpdateView):
model = OnboardingRequirement
form_class = OnboardingRequirementForm
template_name = "management/onboarding_requirement_form.html"
def get_queryset(self):
return OnboardingRequirement.objects.filter(club=self.request.club)
def form_valid(self, form):
response = super().form_valid(form)
notify(self.request, f"s|{_('Requirement updated')}|" + _("%(name)s” updated.") % {"name": self.object.name})
return response
def get_success_url(self):
return reverse("management:onboarding_requirement_list")
def get_context_data(self, **kwargs):
return super().get_context_data(update_view=True, **kwargs)
class OnboardingRequirementDeleteView(MemberAdminRequiredMixin, View):
def post(self, request, pk):
requirement = get_object_or_404(OnboardingRequirement.objects.filter(club=request.club), pk=pk)
name = requirement.name
requirement.delete()
notify(request, f"w|{_('Requirement deleted')}|" + _("%(name)s” is no longer required. Existing checklists keep whatever was already recorded for it.") % {"name": name})
return redirect("management:onboarding_requirement_list")
def _current_membership_or_404(request, member_pk):
"""The signed-in club's current-season ClubMembership for member_pk -- same
lookup MemberDetailView itself uses for `current_membership`, since the
Documents tab these three views serve lives on that same page and a
checklist item only ever makes sense against a member's *current* signup."""
membership = ClubMembership.objects.filter(club=request.club, member_id=member_pk, season=current_season(request.club)).first()
if membership is None:
raise Http404("No current-season membership for this member.")
return membership
class MemberRequirementCompleteView(ClubStaffRequiredMixin, View):
"""Mark one checklist item done for one membership -- any staff, not just
admins (same visibility as the rest of a member's profile), can record that
a document came in. Reachable from the member detail page's Documents tab
(the default fallback) and from the admin-only Sign-up page (via `next`)."""
def post(self, request, pk, requirement_pk):
membership = _current_membership_or_404(request, pk)
requirement = get_object_or_404(OnboardingRequirement.objects.filter(club=request.club), pk=requirement_pk)
form = RequirementCompletionForm(request.POST, request.FILES)
if form.is_valid():
mark_complete(membership, requirement, user=request.user, document=form.cleaned_data.get("document") or None, note=form.cleaned_data["note"])
notify(request, f"s|{_('Marked complete')}|" + _("%(name)s” marked complete for %(member)s.") % {"name": requirement.name, "member": membership.member})
else:
notify(request, f"e|{_('Could not save')}|{' '.join(str(error) for errors in form.errors.values() for error in errors)}")
return _redirect_next_or(request, reverse("management:member_detail", args=[membership.member_id]))
class MemberRequirementBypassView(ClubStaffRequiredMixin, View):
"""Confirm one checklist item isn't needed for this member (e.g. they already
have a recent photo on file) -- see club.services.onboarding.mark_bypassed.
Same visibility as MemberRequirementCompleteView; bypassing isn't a bigger
deal than completing, it's just a different reason the item stops blocking
anything."""
def post(self, request, pk, requirement_pk):
membership = _current_membership_or_404(request, pk)
requirement = get_object_or_404(OnboardingRequirement.objects.filter(club=request.club), pk=requirement_pk)
form = RequirementBypassForm(request.POST)
if form.is_valid():
mark_bypassed(membership, requirement, user=request.user, note=form.cleaned_data["note"])
notify(request, f"s|{_('Marked as not needed')}|" + _("%(name)s” bypassed for %(member)s.") % {"name": requirement.name, "member": membership.member})
else:
notify(request, f"e|{_('Could not save')}|{' '.join(str(error) for errors in form.errors.values() for error in errors)}")
return _redirect_next_or(request, reverse("management:member_detail", args=[membership.member_id]))
class MemberRequirementIncompleteView(ClubStaffRequiredMixin, View):
def post(self, request, pk, requirement_pk):
membership = _current_membership_or_404(request, pk)
requirement = get_object_or_404(OnboardingRequirement.objects.filter(club=request.club), pk=requirement_pk)
mark_incomplete(membership, requirement)
notify(request, f"w|{_('Marked incomplete')}|" + _("%(name)s” reopened for %(member)s.") % {"name": requirement.name, "member": membership.member})
return _redirect_next_or(request, reverse("management:member_detail", args=[membership.member_id]))
class MemberRequirementDocumentView(ClubStaffRequiredMixin, View):
"""Streams a checklist document (see MemberRequirementStatus.document) to any
signed-in staff -- same visibility as the rest of a member's profile. The file
itself lives on rosterchief.storage.private_storage, which has no public URL at
all; this view is the only way to read one."""
def get(self, request, pk, requirement_pk):
membership = _current_membership_or_404(request, pk)
status = get_object_or_404(MemberRequirementStatus.objects.filter(membership=membership, requirement_id=requirement_pk))
if not status.document:
raise Http404("No document has been uploaded for this requirement.")
return FileResponse(status.document.open("rb"), as_attachment=True, filename=status.document.name.rsplit("/", 1)[-1])
# --- Sign-up (admin only) ----------------------------------------------------------
class SignupDashboardView(ClubAdminRequiredMixin, TemplateView):
"""Every current-season MEMBER-kind membership, checklist status and fee status
side by side, plus which teams they've been placed on so far -- the D3-inspired
admin queue for processing this season's sign-ups end to end. Admin-only: fee
status feeds this page directly (see MembershipListView, its own separate
Finance-side page for actually recording payment), so it sits on the same side
of that boundary, unlike the rest of the Members section which MEMBER_ADMIN can
also reach.
Team placement (SignupPlaceInTeamView) creates the roster row immediately,
regardless of status -- see events.services.attendance.effective_members and
club.services.onboarding.blocked_member_ids_for_event for how an event's
invitations/selection still exclude a member per event kind until whatever
blocks that kind clears, so a provisional player is visible to their coach
without being invited to a game their paperwork isn't ready for."""
template_name = "management/signup_list.html"
def get_context_data(self, **kwargs):
club = self.request.club
season = current_season(club)
memberships = []
teams = Team.objects.filter(club=club)
if season is not None:
# PENDING first and oldest-first within that -- same "the queue's own
# priority order" reasoning as D3's own longest-waiting-on-top table,
# with anyone already ACTIVE but not yet clean (unpaid, or an open
# checklist item) trailing behind since it's lower priority, not done.
memberships = list(ClubMembership.objects.filter(club=club, season=season, kind=ClubMembership.Kind.MEMBER).select_related("member"))
# Sorted in Python, not via .order_by("-status", ...): StatusChoices'
# string values only put PENDING first alphabetically by accident, and
# that would silently break the moment a choice's value changes.
memberships.sort(key=lambda membership: (membership.status != ClubMembership.StatusChoices.PENDING, membership.created))
annotate_onboarding_status(memberships)
oldest_pending_id = next((membership.pk for membership in memberships if membership.status == ClubMembership.StatusChoices.PENDING), None)
for membership in memberships:
membership.checklist = checklist_for(membership)
membership.blocked_kinds = blocking_event_kinds(membership)
membership.teams_registered = teams.filter(roster__member_id=membership.member_id, roster__season=season).distinct()
membership.placement_form = SignupTeamPlacementForm(club=club, season=season, member=membership.member)
membership.is_clean = is_signup_clean(membership)
membership.is_oldest_pending = membership.pk == oldest_pending_id
# Already-ACTIVE *and* clean (paid, checklist fully resolved) memberships
# have nothing left for this queue to do -- drop them rather than leaving
# them trailing at the bottom, where they read as still needing attention.
memberships = [membership for membership in memberships if not (membership.status == ClubMembership.StatusChoices.ACTIVE and membership.is_clean)]
return super().get_context_data(
season=season,
memberships=memberships,
teams=teams,
requirements_configured=OnboardingRequirement.objects.filter(club=club, is_active=True).exists(),
**kwargs,
)
class SignupApproveAllCleanView(ClubAdminRequiredMixin, View):
"""Bulk-activates every membership that's both paid up and has resolved its
whole checklist -- see club.services.onboarding.approve_all_clean for exactly
what "clean" means; it's the only path to ACTIVE, deliberately manual even
for a fully-paid membership."""
def post(self, request):
season = current_season(request.club)
if season is None:
notify(request, f"e|{_('No active season')}|{_('There is no season covering today to approve sign-ups for.')}")
return redirect("management:signup_list")
activated = approve_all_clean(request.club, season)
if activated:
notify(request, f"s|{_('Approved')}|" + _("%(count)d membership moved to active.") % {"count": activated})
else:
notify(request, f"w|{_('Nothing to approve')}|{_('No pending membership is both paid up and fully checked off yet.')}")
return redirect("management:signup_list")
class SignupApproveOneView(ClubAdminRequiredMixin, View):
"""The detail panel's own "Approve" button -- club.services.onboarding.approve_one
for a single membership, same rule as the bulk version above."""
def post(self, request, pk):
club = request.club
season = current_season(club)
if season is None:
raise Http404("No active season to approve this member in.")
membership = get_object_or_404(ClubMembership.objects.filter(club=club, season=season, kind=ClubMembership.Kind.MEMBER), member_id=pk)
if approve_one(membership):
notify(request, f"s|{_('Approved')}|" + _("%(member)s is now active.") % {"member": membership.member})
else:
notify(request, f"w|{_('Not ready yet')}|" + _("%(member)s isn't both paid up and fully checked off yet.") % {"member": membership.member})
return redirect("management:signup_list")
class SignupPlaceInTeamView(ClubAdminRequiredMixin, View):
"""Rosters one member from the Sign-up page onto a team for the current season
-- see SignupTeamPlacementForm for why this bypasses eligible_roster_members'
active-only filter on purpose.
signup_list.html posts to this in the background (fetch, X-Requested-With) and
just toggles the clicked button's own colour on success -- no full-page reload
for what's a one-field, one-click action. That's progressive enhancement, not
the only way in: a plain (non-JS) POST still works exactly as before, redirected
back to the page with a normal Django message."""
def post(self, request, pk):
club = request.club
season = current_season(club)
if season is None:
raise Http404("No active season to place this member in.")
membership = get_object_or_404(ClubMembership.objects.filter(club=club, season=season, kind=ClubMembership.Kind.MEMBER), member_id=pk)
is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest"
form = SignupTeamPlacementForm(request.POST, club=club, season=season, member=membership.member)
# Team's the only real form field -- season/member are pre-seeded straight
# onto the instance (not form fields), so ModelForm.validate_unique() skips
# unique_member_per_team_per_season entirely (Django excludes a unique
# check the moment any field it covers isn't part of the form). The
# already-placed team's own button renders disabled, but a resent POST
# still has to be handled here rather than 500ing on the DB constraint.
if not form.is_valid():
ok = False
level, title, body = "e", _("Could not place on team"), " ".join(str(error) for errors in form.errors.values() for error in errors)
elif TeamMembership.objects.filter(team=form.cleaned_data["team"], member=membership.member, season=season).exists():
ok = False
level, title, body = "w", _("Already placed"), _("%(member)s is already on %(team)s.") % {"member": membership.member, "team": form.cleaned_data["team"]}
else:
form.save()
ok = True
level, title, body = "s", _("Placed on team"), _("%(member)s added to %(team)s.") % {"member": membership.member, "team": form.cleaned_data["team"]}
if is_ajax:
return JsonResponse({"ok": ok, "title": str(title), "body": str(body)})
notify(request, f"{level}|{title}|{body}")
return redirect("management:signup_list")