from django.db import transaction from django.db.models import Count, ProtectedError from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils import timezone from django.utils.http import url_has_allowed_host_and_scheme from django.utils.translation import gettext_lazy as _ from django.views.generic import CreateView, DetailView, FormView, ListView, TemplateView, UpdateView, View from club.mixins import ClubAdminRequiredMixin, ClubStaffRequiredMixin from club.models import ClubMembership, ClubRole, Season from club.services.access import current_season, members_visible_to from club.services.fees import mark_as_paid, record_payment, remaining_balance from controlpanel.messages import notify from controlpanel.mixins import RedirectOnInvalidMixin from controlpanel.services.statistics import club_attention, club_charts, club_statistics from events.models import Event, EventSeries, Location, Opponent from formbuilder.models import Form as FormBuilderForm from formbuilder.models import Submission from members.models import Family, FamilyMembership, Member from members.services.family import add_child_to_family, add_parent_to_family, attach_to_family, detach_from_family, grant_login, register_family from shop.models import Discount, Invoice, Order, Product from teams.models import Position, StaffAssignment, Team, TeamMembership from .bulk_import import build_member_import_template, parse_member_import_rows, read_member_import_workbook from .forms import ( AddChildForm, AddParentForm, AttachToFamilyForm, ClubMembershipForm, ClubRoleAssignForm, FamilyCreateForm, GrantLoginForm, MemberForm, MemberImportUploadForm, PositionForm, RecordFeePaymentForm, TeamForm, ) from .pdf import PDFExportError, membership_list_pdf class HomeView(ClubStaffRequiredMixin, TemplateView): """The at-a-glance numbers a club admin/team manager/coach would actually want: club_attention/club_charts/club_statistics are the exact functions controlpanel/club_detail.html uses for the platform admin's per-club drill-down -- already club-scoped, so directly reusable for this club's own staff.""" template_name = "management/home.html" def get_context_data(self, **kwargs): club = self.request.club return super().get_context_data( attention=club_attention(club), charts=club_charts(club), groups=club_statistics(club), upcoming_events=Event.objects.filter(club=club, start__gte=timezone.now()).order_by("start")[:5], today=timezone.localdate(), **kwargs, ) # --- Members (full tier) ----------------------------------------------------------- def group_by_family(members): """Bucket an already-scoped Member iterable by family: {family, guardians, children, others}, plus whatever's left un-grouped. Deliberately built from ``members`` rather than ``Family.guardians``/``.children`` (members/models.py) -- those query a family's *entire* membership unconditionally, which would leak people outside whatever visibility scope ``members`` already represents (e.g. a coach who only sees their own rostered players). """ memberships = FamilyMembership.objects.filter(member__in=members).select_related("family", "member") groups = {} for fm in memberships: bucket = groups.setdefault(fm.family, {"family": fm.family, "guardians": [], "children": [], "others": []}) # Attached here, not read later from person.family_memberships.first() -- # a member can belong to more than one family, so "their role" only means # anything once it's scoped to *this* family's membership row. fm.member.role_in_family = fm.role fm.member.role_in_family_display = fm.get_role_display() # A login-less child can be granted one right from the table -- parents/ # guardians already get one when added (add_parent_to_family/register_family # both call get_or_create_login_member), so in practice this is only ever # None for a child, but the check is on the actual state, not the role alone. if fm.role == FamilyMembership.FamilyRole.CHILD and fm.member.user_id is None: fm.member.grant_login_form = GrantLoginForm(initial={"email": fm.member.email}) else: fm.member.grant_login_form = None if fm.role in (FamilyMembership.FamilyRole.PARENT, FamilyMembership.FamilyRole.GUARDIAN): bucket["guardians"].append(fm.member) elif fm.role == FamilyMembership.FamilyRole.CHILD: bucket["children"].append(fm.member) else: bucket["others"].append(fm.member) grouped_ids = {fm.member_id for fm in memberships} ungrouped = [member for member in members if member.pk not in grouped_ids] groups = list(groups.values()) for group in groups: # One flat list for anything that just wants "everyone in this family", # regardless of role (e.g. rendering one delete-confirm modal per member). group["all"] = group["guardians"] + group["children"] + group["others"] return groups, ungrouped class MemberListView(ClubStaffRequiredMixin, ListView): """One flat list, everybody -- family is a column, not a grouping. Each member's family/role is attached in Python below (from a single query over the already-scoped ``members``), same reasoning as group_by_family: never resolve it per-row from the template via Family.guardians/.children, which would ignore visibility scoping entirely.""" template_name = "management/member_list.html" context_object_name = "members" def get_queryset(self): members = members_visible_to(self.request.user, self.request.club) 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", ""), **kwargs) members = list(context["members"]) memberships = FamilyMembership.objects.filter(member__in=members).select_related("family") memberships_by_member_id = {} for fm in memberships: memberships_by_member_id.setdefault(fm.member_id, []).append(fm) for member in members: member.family_memberships_display = memberships_by_member_id.get(member.pk, []) season = current_season(self.request.club) club_memberships_by_member_id = {} if season is not None: club_memberships = ClubMembership.objects.filter(club=self.request.club, season=season, member__in=members) 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) return context | {"members": members} class MembershipListView(ClubAdminRequiredMixin, ListView): """Who's paid for the current season, and who hasn't -- MemberListView's Status column can only show this one row at a time. Financial data, so admin-only throughout (same line already drawn around the Shop nav section).""" template_name = "management/membership_list.html" context_object_name = "memberships" def get_selected_season(self): season_id = self.request.GET.get("season") if season_id: season = Season.objects.filter(club=self.request.club, pk=season_id).first() if season is not None: return season return current_season(self.request.club) def get_queryset(self): season = self.get_selected_season() if season is None: return ClubMembership.objects.none() memberships = ClubMembership.objects.filter(club=self.request.club, season=season).select_related("member").order_by("member__last_name", "member__first_name") fee_status = self.request.GET.get("fee_status", "not_paid") if fee_status == "not_paid": # Literally "does not have a paid status" -- unpaid, partially paid, and # waived all qualify; the dropdown can narrow to any single one of those. memberships = memberships.exclude(fee_status__in=[ClubMembership.FeeStatus.PAID, ClubMembership.FeeStatus.PARTIALLY_PAID]) elif fee_status and fee_status != "all": memberships = memberships.filter(fee_status=fee_status) status = self.request.GET.get("status", "all") if status and status != "all": memberships = memberships.filter(status=status) team_id = self.request.GET.get("team") if team_id: memberships = memberships.filter(member__team_memberships__team_id=team_id, member__team_memberships__season=season) search = self.request.GET.get("q", "").strip() if search: # Also matches by family -- searching "Smith" finds every member of a # family that has an explicit name of "Smith" or that includes anyone # surnamed Smith, not just a member literally named Smith themself. memberships = ( memberships.filter(member__first_name__icontains=search) | memberships.filter(member__last_name__icontains=search) | memberships.filter(member__email__icontains=search) | memberships.filter(member__user__email__icontains=search) | memberships.filter(member__family_memberships__family__name__icontains=search) | memberships.filter(member__family_memberships__family__memberships__member__last_name__icontains=search) ) return memberships.distinct() def get_context_data(self, **kwargs): club = self.request.club current = current_season(club) counts = {} if current is not None: counts = {row["fee_status"]: row["count"] for row in ClubMembership.objects.filter(club=club, season=current).values("fee_status").annotate(count=Count("id"))} paid = counts.get(ClubMembership.FeeStatus.PAID, 0) partial = counts.get(ClubMembership.FeeStatus.PARTIALLY_PAID, 0) unpaid = counts.get(ClubMembership.FeeStatus.UNPAID, 0) waived = counts.get(ClubMembership.FeeStatus.WAIVED, 0) total = paid + partial + unpaid + waived context = super().get_context_data( current_season=current, selected_season=self.get_selected_season(), seasons=Season.objects.filter(club=club).order_by("-start_date"), teams=Team.objects.filter(club=club).order_by("name"), fee_status_choices=ClubMembership.FeeStatus.choices, status_choices=ClubMembership.StatusChoices.choices, search=self.request.GET.get("q", ""), selected_fee_status=self.request.GET.get("fee_status", "not_paid"), selected_status=self.request.GET.get("status", "all"), selected_team=self.request.GET.get("team", ""), kpi_total=total, kpi_paid=paid, kpi_partial=partial, kpi_unpaid=unpaid, kpi_waived=waived, kpi_paid_rate=round(100 * paid / total) if total else None, **kwargs, ) # Same reasoning as MemberListView: attached in Python from a single query, # never resolved per-row via Family.guardians/.children (which would ignore # this page's own club/season/filter scoping entirely). memberships = list(context["memberships"]) members = [membership.member for membership in memberships] family_memberships = FamilyMembership.objects.filter(member__in=members).select_related("family") family_memberships_by_member_id = {} for fm in family_memberships: family_memberships_by_member_id.setdefault(fm.member_id, []).append(fm) for membership in memberships: membership.member.family_memberships_display = family_memberships_by_member_id.get(membership.member_id, []) membership.remaining_balance_display = remaining_balance(membership) # Nothing to collect on an already-settled or deliberately-exempted row. if membership.fee_status in (ClubMembership.FeeStatus.PAID, ClubMembership.FeeStatus.WAIVED): membership.record_payment_form = None else: membership.record_payment_form = RecordFeePaymentForm() return context | {"memberships": memberships} 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. 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. """ def post(self, request): ids = request.POST.getlist("membership_ids") memberships = ClubMembership.objects.filter(pk__in=ids, club=request.club) count = 0 with transaction.atomic(): for membership in memberships: mark_as_paid(membership, recorded_by=request.user) count += 1 notify(request, f"s|{_('Marked as paid')}|{_('%(count)d membership(s) updated.') % {'count': count} }") 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()): return redirect(next_url) return redirect("management:membership_list") class MembershipMarkFullyPaidView(ClubAdminRequiredMixin, View): """The per-row, one-click version of MembershipMarkPaidView's bulk action -- no confirm modal, matching this app's convention of reserving those for destructive deletes, not state changes.""" 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}}") 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()): return redirect(next_url) return redirect("management:membership_list") class MembershipRecordPaymentView(ClubAdminRequiredMixin, View): """The per-row "Record payment" modal on the Memberships page -- any amount, partial or in full, via club.services.fees.record_payment.""" def post(self, request, pk): membership = get_object_or_404(ClubMembership, pk=pk, club=request.club) form = RecordFeePaymentForm(request.POST) next_url = request.POST.get("next") redirect_url = next_url if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}, require_https=request.is_secure()) else reverse("management:membership_list") if not form.is_valid(): for error in form.errors.values(): notify(request, f"e|{_('Could not record payment')}|{' '.join(error)}") return redirect(redirect_url) record_payment( membership, amount=form.cleaned_data["amount"], method=form.cleaned_data["method"], reference=form.cleaned_data["reference"], note=form.cleaned_data["note"], recorded_by=request.user, ) notify(request, f"s|{_('Payment recorded')}|{_('%(amount)s recorded for “%(member)s”.') % {'amount': form.cleaned_data['amount'], 'member': membership.member}}") return redirect(redirect_url) class MembershipExportPdfView(MembershipListView): """The exact same filtered queryset and KPI numbers as MembershipListView -- "export this page" means exactly that, never a differently-filtered list. Reachable with whatever query string the on-screen list currently has.""" def get(self, request, *args, **kwargs): self.object_list = self.get_queryset() context = self.get_context_data(club=request.club, generated_at=timezone.now()) try: pdf = membership_list_pdf(context) except PDFExportError as error: # The native PDF libraries are missing: say so rather than 500, and # land back on the same filtered list rather than a blank one. notify(request, f"e|{_('PDF unavailable')}|{error}") return redirect(f"{reverse('management:membership_list')}?{request.GET.urlencode()}") season = context["selected_season"] filename = f"memberships-{season}.pdf" if season else "memberships.pdf" response = HttpResponse(pdf, content_type="application/pdf") response["Content-Disposition"] = f'attachment; filename="{filename}"' return response class MemberImportTemplateView(ClubStaffRequiredMixin, View): """Anyone with management access can download the template -- filling it in doesn't grant any authority, only the upload step (admin-only) does.""" def get(self, request): workbook = build_member_import_template() response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") response["Content-Disposition"] = 'attachment; filename="member_import_template.xlsx"' workbook.save(response) return response class MemberImportView(ClubAdminRequiredMixin, 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 session to MemberImportConfirmView, which re-validates it rather than trusting anything a client could tamper with.""" def get(self, request): return render(request, "management/member_import.html", {"form": MemberImportUploadForm()}) def post(self, request): form = MemberImportUploadForm(request.POST, request.FILES) if not form.is_valid(): return render(request, "management/member_import.html", {"form": form}) try: rows = read_member_import_workbook(form.cleaned_data["file"]) except ValueError as exc: form.add_error("file", str(exc)) return render(request, "management/member_import.html", {"form": form}) request.session["member_import_rows"] = rows results = parse_member_import_rows(rows, request.club) return render( request, "management/member_import_preview.html", { "results": results, "valid_count": sum(1 for result in results if result["member"] is not None), "skipped_count": sum(1 for result in results if result["member"] is None), "season": current_season(request.club), }, ) class MemberImportConfirmView(ClubAdminRequiredMixin, 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 the preview showed.""" def post(self, request): rows = request.session.pop("member_import_rows", None) if not rows: notify(request, f"w|{_('Nothing to import')}|{_('Upload a file first.')}") return redirect("management:member_import") results = parse_member_import_rows(rows, request.club) season = current_season(request.club) created = 0 with transaction.atomic(): for result in results: member = result["member"] if member is None: continue member.save() if season is not None: ClubMembership.objects.create(club=request.club, member=member, season=season, signed_up_at=timezone.localdate(), **result["membership_kwargs"]) created += 1 skipped = len(results) - created if season is None and created: title = _("%(count)s member(s) created, but not rostered") % {"count": created} body = _("There's no active season to sign them up for yet.") notify(request, f"w|{title}|{body}") else: body = _("%(created)s created, %(skipped)s skipped.") % {"created": created, "skipped": skipped} notify(request, f"s|{_('Members imported')}|{body}") return redirect("management:member_list") class MemberCreateView(ClubAdminRequiredMixin, CreateView): model = Member form_class = MemberForm template_name = "management/member_form.html" def form_valid(self, form): response = super().form_valid(form) # Adding a member here means adding them to *this* club -- without a # ClubMembership they'd never show up in members_visible_to() again. season = current_season(self.request.club) if season is not None: ClubMembership.objects.create( club=self.request.club, member=self.object, season=season, status=ClubMembership.StatusChoices.ACTIVE, signed_up_at=timezone.localdate(), ) body = _("“%(member)s” added to the club for %(season)s.") % {"member": self.object, "season": season} notify(self.request, f"s|{_('Member added')}|{body}") else: body = _("“%(member)s” was created, but there's no active season to sign them up for yet.") % {"member": self.object} notify(self.request, f"w|{_('Member added, but not rostered')}|{body}") return response def get_success_url(self): return reverse("management:member_detail", args=[self.object.pk]) class MemberUpdateView(ClubAdminRequiredMixin, 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 without one, so this is the only place to see or change it.""" template_name = "management/member_form.html" def get_member(self): return get_object_or_404(members_visible_to(self.request.user, self.request.club), pk=self.kwargs["pk"]) def get_membership(self, member): season = current_season(self.request.club) if season is None: return None return ClubMembership.objects.filter(club=self.request.club, member=member, season=season).first() or ClubMembership( club=self.request.club, member=member, season=season, signed_up_at=timezone.localdate() ) def render_form(self, member, form, membership_form): return render(self.request, self.template_name, {"object": member, "update_view": True, "form": form, "membership_form": membership_form}) def get(self, request, pk): member = self.get_member() membership = self.get_membership(member) membership_form = ClubMembershipForm(instance=membership) if membership else None return self.render_form(member, MemberForm(instance=member), membership_form) def post(self, request, pk): member = self.get_member() membership = self.get_membership(member) form = MemberForm(request.POST, instance=member) membership_form = ClubMembershipForm(request.POST, instance=membership) if membership else None if form.is_valid() and (membership_form is None or membership_form.is_valid()): form.save() if membership_form is not None: membership_form.save() notify(request, f"s|{_('Member updated')}|{_('“%(member)s” updated.') % {'member': member} }") return redirect("management:member_detail", pk=member.pk) return self.render_form(member, form, membership_form) class MemberDetailView(ClubStaffRequiredMixin, DetailView): template_name = "management/member_detail.html" context_object_name = "member" def get_queryset(self): return members_visible_to(self.request.user, self.request.club) def get_context_data(self, **kwargs): visible = members_visible_to(self.request.user, self.request.club) my_family_ids = FamilyMembership.objects.filter(member=self.object).values_list("family_id", flat=True) family_scoped_members = visible.filter(family_memberships__family_id__in=my_family_ids).distinct() family_groups, _ = group_by_family(family_scoped_members) return super().get_context_data( family_groups=family_groups, family_role_choices=FamilyMembership.FamilyRole.choices, 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(), membership_history=ClubMembership.objects.filter(club=self.request.club, member=self.object).select_related("season").order_by("-season__start_date"), **kwargs, ) class MemberAttachToFamilyView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormView): """Reachable only via the "Add to family" modal on a standalone member's page.""" form_class = AttachToFamilyForm http_method_names = ["post"] invalid_redirect_url_name = "management:member_detail" def get_invalid_redirect_kwargs(self): return {"pk": self.kwargs["pk"]} def get_form_kwargs(self): member = get_object_or_404(members_visible_to(self.request.user, self.request.club), pk=self.kwargs["pk"]) return super().get_form_kwargs() | {"club": self.request.club, "member": member} def form_valid(self, form): member = get_object_or_404(members_visible_to(self.request.user, self.request.club), pk=self.kwargs["pk"]) family = attach_to_family(member, role=form.cleaned_data["role"], family=form.cleaned_data["family"]) body = _("“%(member)s” is now part of %(family)s.") % {"member": member, "family": family} notify(self.request, f"s|{_('Added to family')}|{body}") return redirect("management:member_detail", pk=member.pk) class MemberGrantLoginView(ClubAdminRequiredMixin, 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 genuinely free one.""" form_class = GrantLoginForm http_method_names = ["post"] invalid_redirect_url_name = "management:member_detail" def get_invalid_redirect_kwargs(self): return {"pk": self.kwargs["pk"]} def form_valid(self, form): member = get_object_or_404(members_visible_to(self.request.user, self.request.club), pk=self.kwargs["pk"]) if member.user_id is not None: # Already has one -- the row's button shouldn't have been there at all; # a direct POST replay (e.g. a resubmitted form) is the only way here. notify(self.request, f"w|{_('Already has a login')}|{_('“%(member)s” can already sign in.') % {'member': member} }") else: grant_login(member, form.cleaned_data["email"]) notify(self.request, f"s|{_('Login granted')}|{_('“%(member)s” can now sign in.') % {'member': member} }") return redirect("management:member_detail", pk=member.pk) class MemberDetachFromFamilyView(ClubAdminRequiredMixin, View): def post(self, request, pk, family_pk): member = get_object_or_404(members_visible_to(request.user, request.club), pk=pk) family = get_object_or_404(Family, pk=family_pk, memberships__member=member) # detach_from_family may delete `family` itself (left empty) -- str() it first, # since Family.__str__ queries self.memberships, which needs a pk to still exist. family_name = str(family) detach_from_family(member, family) body = _("“%(member)s” is no longer part of %(family)s.") % {"member": member, "family": family_name} notify(request, f"w|{_('Removed from family')}|{body}") return redirect("management:member_detail", pk=member.pk) class FamilyMembershipRoleUpdateView(ClubAdminRequiredMixin, 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 whichever of those two pages the change came from: member_detail.html sends its own URL as `next` so the admin doesn't get bounced off the member they were looking at; family_detail.html sends nothing, since staying there is already correct.""" def post(self, request, family_pk, member_pk): family = get_object_or_404(families_of_club(request.club), pk=family_pk) membership = get_object_or_404(FamilyMembership, family=family, member_id=member_pk) role = request.POST.get("role") if role not in FamilyMembership.FamilyRole.values: title = _("Couldn't update role") notify(request, f"e|{title}|{_('Not a valid role.')}") else: membership.role = role membership.save(update_fields=["role"]) body = _("“%(member)s” is now %(role)s in %(family)s.") % {"member": membership.member, "role": membership.get_role_display(), "family": family} notify(request, f"s|{_('Role updated')}|{body}") 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()): return redirect(next_url) return redirect("management:family_detail", pk=family.pk) class MemberDeleteView(ClubAdminRequiredMixin, View): def post(self, request, pk): member = get_object_or_404(members_visible_to(request.user, request.club), pk=pk) name = str(member) # FamilyMembership cascades away with the member -- note which families # they were in before that happens, so an emptied one can be cleaned up # after, same as detach_from_family does. family_ids = list(Family.objects.filter(memberships__member=member).values_list("pk", flat=True)) try: member.delete() except ProtectedError: title = _("Can't delete") body = _("“%(member)s” is still referenced by orders or invoices, and can't be deleted.") % {"member": name} notify(request, f"e|{title}|{body}") return redirect("management:member_detail", pk=pk) Family.objects.filter(pk__in=family_ids, memberships__isnull=True).delete() body = _("“%(member)s” has been deleted.") % {"member": name} notify(request, f"w|{_('Member deleted')}|{body}") return redirect("management:member_list") # --- Teams (full tier) -------------------------------------------------------------- class TeamListView(ClubStaffRequiredMixin, ListView): template_name = "management/team_list.html" context_object_name = "teams" def get_queryset(self): teams = Team.objects.filter(club=self.request.club) search = self.request.GET.get("q", "").strip() if search: teams = teams.filter(name__icontains=search) return teams def get_context_data(self, **kwargs): return super().get_context_data(search=self.request.GET.get("q", ""), **kwargs) class TeamCreateView(ClubAdminRequiredMixin, CreateView): model = Team form_class = TeamForm template_name = "management/team_form.html" def form_valid(self, form): response = super().form_valid(form) body = _("“%(team)s” created.") % {"team": self.object} notify(self.request, f"s|{_('Team created')}|{body}") return response def get_success_url(self): return reverse("management:team_detail", args=[self.object.pk]) class TeamUpdateView(ClubAdminRequiredMixin, UpdateView): model = Team form_class = TeamForm template_name = "management/team_form.html" def get_queryset(self): return Team.objects.filter(club=self.request.club) def form_valid(self, form): response = super().form_valid(form) body = _("“%(team)s” updated.") % {"team": self.object} notify(self.request, f"s|{_('Team updated')}|{body}") return response def get_success_url(self): return reverse("management:team_detail", args=[self.object.pk]) def get_context_data(self, **kwargs): return super().get_context_data(update_view=True, **kwargs) class TeamDetailView(ClubStaffRequiredMixin, DetailView): template_name = "management/team_detail.html" context_object_name = "team" def get_queryset(self): return Team.objects.filter(club=self.request.club) # --- Club roles (full tier: assign / revoke, no update -- a role isn't edited, just # granted or taken away) ------------------------------------------------------------- #: What each non-default role actually grants -- shown on the roles overview so an #: admin granting one knows what they're handing out. See club/services/access.py. ROLE_DESCRIPTIONS = { ClubRole.Roles.ADMIN: _("Full control over the club: memberships, positions, roles, teams, shop, and every event."), ClubRole.Roles.EDITOR: _("Can create and edit events, but not memberships, positions, roles, or shop settings."), } class ClubRoleListView(ClubAdminRequiredMixin, ListView): """Every non-default role holder, grouped by role. The plain MEMBER role is excluded entirely -- every active club member holds it automatically (club/signals.py), so listing it here would just be noise; this page is for the roles someone was actually *granted*. ClubRole.Roles has few enough values that a section per role reads better than one flat table.""" template_name = "management/role_list.html" context_object_name = "roles" def get_queryset(self): return ClubRole.objects.filter(club=self.request.club).exclude(role=ClubRole.Roles.MEMBER).select_related("member") def get_context_data(self, **kwargs): sections = [(value, label, ROLE_DESCRIPTIONS.get(value, ""), [role for role in self.object_list if role.role == value]) for value, label in ClubRole.Roles.choices if value != ClubRole.Roles.MEMBER] return super().get_context_data(sections=sections, role_form=ClubRoleAssignForm(club=self.request.club), **kwargs) class ClubRoleCreateView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormView): """Reachable only via the "Grant role" modal on the roles overview.""" form_class = ClubRoleAssignForm http_method_names = ["post"] invalid_redirect_url_name = "management:role_list" def get_form_kwargs(self): return super().get_form_kwargs() | {"club": self.request.club} def form_valid(self, form): # A member holds at most one ClubRole per club (the membership-status sync in # club/signals.py already gave any active member an implicit MEMBER role) -- # so granting ADMIN/EDITOR promotes that existing row rather than inserting a # second one, exactly like controlpanel.services.admins.grant_club_admin. member, role = form.cleaned_data["member"], form.cleaned_data["role"] role_obj, created = ClubRole.objects.get_or_create(club=self.request.club, member=member, defaults={"role": role}) if not created and role_obj.role != role: role_obj.role = role role_obj.save(update_fields=["role"]) body = _("“%(member)s” is now %(role)s.") % {"member": member, "role": role_obj.get_role_display()} notify(self.request, f"s|{_('Role granted')}|{body}") return redirect("management:role_list") class ClubRoleRevokeView(ClubAdminRequiredMixin, View): def post(self, request, pk): role = get_object_or_404(ClubRole, pk=pk, club=request.club) member, role_label = role.member, role.get_role_display() role.delete() body = _("“%(member)s” is no longer %(role)s.") % {"member": member, "role": role_label} notify(request, f"w|{_('Role revoked')}|{body}") return redirect("management:role_list") # --- Everything else: correctly scoped and gated, but list-only for now ------------ class StubListMixin: """Shared shape for a placeholder list: proves out the query scoping and the permission gate for an entity that doesn't have full CRUD yet -- the actual create/edit UI is a follow-up, not part of this scaffold.""" template_name = "management/_generic_list.html" page_title = "" def get_context_data(self, **kwargs): return super().get_context_data(page_title=self.page_title, **kwargs) def families_of_club(club): return Family.objects.filter(memberships__member__member_of__club=club).distinct() class FamilyCreateView(ClubAdminRequiredMixin, 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.""" form_class = FamilyCreateForm template_name = "management/family_form.html" def form_valid(self, form): cd = form.cleaned_data season = current_season(self.request.club) self.family = register_family( self.request.club, season, parent_email=cd["parent_email"], parent_first_name=cd["parent_first_name"], parent_last_name=cd["parent_last_name"], child_first_name=cd["child_first_name"], child_last_name=cd["child_last_name"], child_date_of_birth=cd["child_date_of_birth"], ) if season is not None: body = _("%(family)s added to the club for %(season)s.") % {"family": self.family, "season": season} notify(self.request, f"s|{_('Family added')}|{body}") else: body = _("%(family)s was created, but there's no active season to sign them up for yet.") % {"family": self.family} notify(self.request, f"w|{_('Family added, but not rostered')}|{body}") return super().form_valid(form) def get_success_url(self): return reverse("management:family_detail", args=[self.family.pk]) class FamilyDetailView(ClubStaffRequiredMixin, DetailView): """The family overview: everyone in it, plus the add-parent/add-child actions. Reached by clicking a family's name wherever one is shown (the member list's Family column, another member's own Family panel) -- there's no standalone "Families" nav entry; family is something you see through Members.""" template_name = "management/family_detail.html" context_object_name = "family" def get_queryset(self): return families_of_club(self.request.club) def get_context_data(self, **kwargs): visible = members_visible_to(self.request.user, self.request.club) members = visible.filter(family_memberships__family=self.object).distinct() # group_by_family scopes by member, not family -- a member visible here # because they're in *this* family can also belong to another one, in # which case groups has more than one entry. groups[0] would then pick # whichever family happened to sort first, not necessarily this page's own. groups, _ = group_by_family(members) group = next((g for g in groups if g["family"] == self.object), None) or {"family": self.object, "guardians": [], "children": [], "others": [], "all": []} return super().get_context_data( group=group, family_role_choices=FamilyMembership.FamilyRole.choices, add_child_form=AddChildForm(), add_parent_form=AddParentForm(), **kwargs, ) class FamilyAddChildView(ClubAdminRequiredMixin, 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.""" form_class = AddChildForm http_method_names = ["post"] invalid_redirect_url_name = "management:family_detail" def get_invalid_redirect_kwargs(self): return {"pk": self.kwargs["pk"]} def form_valid(self, form): family = get_object_or_404(families_of_club(self.request.club), pk=self.kwargs["pk"]) child = add_child_to_family(self.request.club, current_season(self.request.club), family, **form.cleaned_data) body = _("“%(child)s” added to %(family)s.") % {"child": child, "family": family} notify(self.request, f"s|{_('Child registered')}|{body}") return redirect("management:family_detail", pk=family.pk) class FamilyAddParentView(ClubAdminRequiredMixin, 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.""" form_class = AddParentForm http_method_names = ["post"] invalid_redirect_url_name = "management:family_detail" def get_invalid_redirect_kwargs(self): return {"pk": self.kwargs["pk"]} def form_valid(self, form): family = get_object_or_404(families_of_club(self.request.club), pk=self.kwargs["pk"]) parent = add_parent_to_family(self.request.club, current_season(self.request.club), family, **form.cleaned_data) body = _("“%(parent)s” added to %(family)s.") % {"parent": parent, "family": family} notify(self.request, f"s|{_('Parent registered')}|{body}") return redirect("management:family_detail", pk=family.pk) class PositionListView(ClubAdminRequiredMixin, ListView): template_name = "management/position_list.html" context_object_name = "positions" def get_queryset(self): return Position.objects.filter(club=self.request.club) class PositionCreateView(ClubAdminRequiredMixin, CreateView): model = Position form_class = PositionForm template_name = "management/position_form.html" def form_valid(self, form): form.instance.club = self.request.club response = super().form_valid(form) body = _("“%(position)s” created.") % {"position": self.object} notify(self.request, f"s|{_('Position created')}|{body}") return response def get_success_url(self): return reverse("management:position_list") class PositionUpdateView(ClubAdminRequiredMixin, UpdateView): model = Position form_class = PositionForm template_name = "management/position_form.html" def get_queryset(self): return Position.objects.filter(club=self.request.club) def form_valid(self, form): response = super().form_valid(form) body = _("“%(position)s” updated.") % {"position": self.object} notify(self.request, f"s|{_('Position updated')}|{body}") return response def get_success_url(self): return reverse("management:position_list") def get_context_data(self, **kwargs): return super().get_context_data(update_view=True, **kwargs) class RosterListView(ClubStaffRequiredMixin, StubListMixin, ListView): page_title = _("Roster") def get_queryset(self): return TeamMembership.objects.filter(team__club=self.request.club) class StaffListView(ClubStaffRequiredMixin, StubListMixin, ListView): page_title = _("Staff") def get_queryset(self): return StaffAssignment.objects.filter(team__club=self.request.club) class EventListView(ClubStaffRequiredMixin, StubListMixin, ListView): page_title = _("Events") def get_queryset(self): return Event.objects.filter(club=self.request.club) class EventSeriesListView(ClubStaffRequiredMixin, StubListMixin, ListView): page_title = _("Event series") def get_queryset(self): return EventSeries.objects.filter(club=self.request.club) class LocationListView(ClubStaffRequiredMixin, StubListMixin, ListView): page_title = _("Locations") def get_queryset(self): return Location.objects.filter(club=self.request.club) class OpponentListView(ClubStaffRequiredMixin, StubListMixin, ListView): page_title = _("Opponents") def get_queryset(self): return Opponent.objects.filter(club=self.request.club) class ProductListView(ClubAdminRequiredMixin, StubListMixin, ListView): page_title = _("Products") def get_queryset(self): return Product.objects.filter(club=self.request.club) class OrderListView(ClubAdminRequiredMixin, StubListMixin, ListView): page_title = _("Orders") def get_queryset(self): return Order.objects.filter(club=self.request.club) class DiscountListView(ClubAdminRequiredMixin, StubListMixin, ListView): page_title = _("Discounts") def get_queryset(self): return Discount.objects.filter(club=self.request.club) class InvoiceListView(ClubAdminRequiredMixin, StubListMixin, ListView): page_title = _("Invoices") def get_queryset(self): return Invoice.objects.filter(club=self.request.club) class FormListView(ClubAdminRequiredMixin, StubListMixin, ListView): page_title = _("Forms") def get_queryset(self): return FormBuilderForm.objects.filter(club=self.request.club) class SubmissionListView(ClubAdminRequiredMixin, StubListMixin, ListView): page_title = _("Submissions") def get_queryset(self): return Submission.objects.filter(form__club=self.request.club, form_id=self.kwargs["pk"])