Files
RosterChief/management/views.py
Bernard Siebens 581cc81ba7 Add group/club-wide event audiences and a Resend email backend
Events can now target members.Group audiences alongside teams, or go
club_wide (every ACTIVE ClubMembership member for the event's season)
instead of specific teams/groups -- the two are mutually exclusive,
enforced in EventForm/EventSeriesForm.clean() since an M2M can't be
validated via a DB CheckConstraint or Event.clean() (no PK yet). Attendance
sync (events/signals.py) now reacts to GroupMembership and ClubMembership
changes the same way it already did for TeamMembership. Authorization:
club.services.access.groups_manageable_by mirrors teams_managed_by (all
groups for an ADMIN, else only the ones the user belongs to -- Group has no
manager/owner concept); a non-admin needs at least one managed team or
belonged-to group to create/edit an event, club_wide stays admin-only, and
EventManagerRequiredMixin gained a get_groups() hook so a non-admin who
creates a group-only event isn't immediately locked out of managing it.

Also adds rosterchief.mail.ResendEmailBackend, an HTTP-API-based Django
email backend for Resend (resend.com) using the existing `requests`
dependency -- no new SDK. Opt in via DJANGO_EMAIL_BACKEND and RESEND_API_KEY;
every Django-sent email (allauth's password reset included) follows
whichever EMAIL_BACKEND is configured, so this covers all of them for free.
Resend's own SMTP relay remains a valid code-free alternative, documented
alongside it in .env.production.example.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 12:02:26 +02:00

2807 lines
125 KiB
Python

from datetime import timedelta
from decimal import Decimal
from django.core.exceptions import ValidationError
from django.db import IntegrityError, transaction
from django.db.models import Count, ProtectedError, Q
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.utils.translation import ngettext
from django.views.generic import CreateView, DetailView, FormView, ListView, TemplateView, UpdateView, View
from billing.models import Due
from club.mixins import (
ClubAdminRequiredMixin,
ClubStaffRequiredMixin,
EventManagerRequiredMixin,
FeatureRequiredMixin,
ManagementPositionRequiredMixin,
NewsAuthorRequiredMixin,
NewsEditRequiredMixin,
NewsPublisherRequiredMixin,
TeamManagerRequiredMixin,
)
from club.models import ClubMembership, ClubRole, Season, Sponsor
from club.services.access import 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 controlpanel.messages import notify
from controlpanel.mixins import RedirectOnInvalidMixin
from controlpanel.services.statistics import club_attention, club_charts, club_statistics
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.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
from events.services.referees import RefereeAssignmentError, add_external_referee, assign_referee, conflicting_events, eligible_referees, needs_referee_management, remove_referee, set_referee_fee
from formbuilder.models import Form as FormBuilderForm
from formbuilder.models import Submission
from members.models import Family, FamilyMembership, Group, GroupMembership, Member
from members.services.family import add_child_to_family, add_parent_to_family, attach_to_family, detach_from_family, get_or_create_login_user, grant_login, register_family
from news.models import News, NewsPhoto
from shop.models import Discount, Invoice, Order, Product
from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership, TeamPhoto
from teams.services import eligible_roster_members
from .bulk_import import build_member_import_template, parse_member_import_rows, read_member_import_workbook
from .forms import (
AddChildForm,
AddParentForm,
AttachToFamilyForm,
ClubMembershipForm,
ClubRoleAssignForm,
EventForm,
EventRefereeFeeForm,
EventSeriesForm,
ExternalRefereeForm,
FamilyCreateForm,
GrantLoginForm,
GroupForm,
LocationForm,
MemberForm,
MemberImportUploadForm,
MemberRefereeEligibilityForm,
NewsForm,
NewsPhotoUploadForm,
NewsPublishForm,
OpponentForm,
PositionForm,
RBIHFImportForm,
RecordFeePaymentForm,
RefereeLevelForm,
SponsorForm,
StaffAssignmentForm,
TeamForm,
TeamMembershipForm,
TeamPhotoForm,
)
from .pdf import PDFExportError, event_referee_form_pdf, membership_list_pdf, referee_form_colors
from .recurrence_ui import describe_rrule
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. Published
news is open to everyone; upcoming events are scoped the same way the events
list is (see scoped_to_managed_teams) -- a manager shouldn't see another
team's practice show up here either."""
template_name = "management/home.html"
def get_context_data(self, **kwargs):
club, user = self.request.club, self.request.user
subscription = getattr(club, "subscription", None)
# "Your period ends soon" is a different question from "you owe us money", and only
# worth raising when nothing is owed -- an unpaid club gets the billing notice from
# the context processor instead, which is louder and more urgent.
billing_ends_at = None
if subscription is not None and not club.dues.filter(status__in=Due.OWING).exists():
latest_due = club.dues.exclude(status=Due.Status.CANCELLED).order_by("-period_end").first()
if latest_due is not None and 0 <= (latest_due.period_end - timezone.localdate()).days <= subscription.plan.renewal_lead_days:
billing_ends_at = latest_due.period_end
upcoming_events = scoped_to_managed_teams(Event.objects.filter(club=club, start__gte=timezone.now()), user, club).order_by("start").prefetch_related("teams")[:5]
return super().get_context_data(
attention=club_attention(club),
charts=club_charts(club),
groups=club_statistics(club),
upcoming_events=upcoming_events,
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,
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}
def selected_season_from_request(request, club):
"""Which season a page showing season-scoped data should use: ``?season=<pk>``
if given (and it's actually one of this club's own seasons), else whichever
season covers today. Shared by every page with a season switcher."""
season_id = request.GET.get("season")
if season_id:
season = Season.objects.filter(club=club, pk=season_id).first()
if season is not None:
return season
return current_season(club)
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):
return selected_season_from_request(self.request, 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
families_by_group = {}
with transaction.atomic():
for result in results:
member = result["member"]
if member is None:
continue
family_role = result["family_role"]
if family_role in (FamilyMembership.FamilyRole.PARENT, FamilyMembership.FamilyRole.GUARDIAN) and member.email:
# Give the parent/guardian a login before saving, same as
# registering a family by hand -- get_or_create_login_user
# reuses an existing account for that email rather than
# risking a duplicate.
member.user, _unused = get_or_create_login_user(member.email)
member.save()
family_group = result["family_group"]
if family_group:
family = families_by_group.get(family_group)
if family is None:
family = Family.objects.create()
families_by_group[family_group] = family
FamilyMembership.objects.create(family=family, member=member, role=family_role)
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)
is_admin = is_club_admin(self.request.user, self.request.club)
referee_profile = RefereeProfile.objects.filter(member=self.object).select_related("level").first()
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"),
# 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
# contact numbers at all.
guardians=self.object.guardians,
referee_profile=referee_profile,
referee_eligibility_form=MemberRefereeEligibilityForm(club=self.request.club, member=self.object) if is_admin else None,
**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 MemberRefereeEligibilityUpdateView(ClubAdminRequiredMixin, 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."""
form_class = MemberRefereeEligibilityForm
http_method_names = ["post"]
invalid_redirect_url_name = "management:member_detail"
def get_invalid_redirect_kwargs(self):
return {"pk": self.kwargs["pk"]}
def get_member(self):
return get_object_or_404(members_visible_to(self.request.user, self.request.club), pk=self.kwargs["pk"])
def get_form_kwargs(self):
return super().get_form_kwargs() | {"club": self.request.club, "member": self.get_member()}
def form_valid(self, form):
member = self.get_member()
form.save()
notify(self.request, f"s|{_('Referee eligibility updated')}|" + _("Updated which teams “%(member)s” can referee for.") % {"member": member})
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):
"""ADMIN sees every team; everyone else (coach, manager, other staff) only
the teams they're staffed on this season -- same visibility rule as
``members_visible_to``, not the narrower management-only ``teams_managed_by``."""
template_name = "management/team_list.html"
context_object_name = "teams"
def get_queryset(self):
club = self.request.club
teams = Team.objects.filter(club=club) if is_club_admin(self.request.user, club) else teams_staffed_by(self.request.user, club)
search = self.request.GET.get("q", "").strip()
if search:
teams = teams.filter(name__icontains=search)
season = current_season(club)
return teams.annotate(
player_count=Count("roster", filter=Q(roster__season=season), distinct=True),
staff_count=Count("staff_assignments", filter=Q(staff_assignments__season=season), distinct=True),
)
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 TeamDeleteView(ClubAdminRequiredMixin, View):
def post(self, request, pk):
team = get_object_or_404(Team.objects.filter(club=request.club), pk=pk)
name = str(team)
# TeamMembership/StaffAssignment cascade away with the team -- no ProtectedError
# to catch, unlike a Member (which orders/invoices can still reference).
team.delete()
body = _("%(team)s” has been deleted.") % {"team": name}
notify(request, f"w|{_('Team deleted')}|{body}")
return redirect("management:team_list")
class TeamDetailView(ClubStaffRequiredMixin, DetailView):
"""Team, roster and staff for one season, all in one place -- viewing is open
to any staff (visibility, not authority); managing the roster/staff of *this*
team is gated by can_manage (TeamManagerRequiredMixin's own rule, computed
here too since the template needs it to show/hide the add/edit/remove UI)."""
template_name = "management/team_detail.html"
context_object_name = "team"
def get_queryset(self):
return Team.objects.filter(club=self.request.club)
def get_context_data(self, **kwargs):
club = self.request.club
team = self.object
season = selected_season_from_request(self.request, club)
can_manage = is_club_admin(self.request.user, club) or teams_managed_by(self.request.user, club).filter(pk=team.pk).exists()
roster = TeamMembership.objects.none()
staff = StaffAssignment.objects.none()
attendance_rate = None
top_attenders, bottom_attenders = [], []
missed_practices = Member.objects.none()
no_shows = []
team_photo = TeamPhoto.objects.filter(team=team, season=season).first() if season is not None else None
if season is not None:
roster = list(TeamMembership.objects.filter(team=team, season=season).select_related("member", "position").order_by("position__ordering", "member__last_name"))
staff = list(StaffAssignment.objects.filter(team=team, season=season).select_related("member", "position").order_by("position__ordering", "member__last_name"))
if can_manage:
for membership in roster:
membership.edit_form = TeamMembershipForm(instance=membership, club=club, team=team, season=season)
for assignment in staff:
assignment.edit_form = StaffAssignmentForm(instance=assignment, club=club, team=team, season=season)
attendance_rate = team_attendance_rate(team, season)
rankings = player_attendance_rankings(team, season)
top_attenders = rankings[:5]
bottom_attenders = list(reversed(rankings))[:5]
missed_practices = players_who_missed_recent_practices(team, season)
no_shows = team_no_shows(team, season)[:10]
return super().get_context_data(
seasons=Season.objects.filter(club=club).order_by("-start_date"),
selected_season=season,
roster=roster,
staff=staff,
can_manage=can_manage,
roster_form=TeamMembershipForm(club=club, team=team, season=season) if can_manage and season else None,
staff_form=StaffAssignmentForm(club=club, team=team, season=season) if can_manage and season else None,
team_photo=team_photo,
team_photo_form=TeamPhotoForm(instance=team_photo) if can_manage and season else None,
attendance_rate=attendance_rate,
top_attenders=top_attenders,
bottom_attenders=bottom_attenders,
missed_practices=missed_practices,
no_shows=no_shows,
# None (not an empty queryset) signals "federation-managed" to the
# template, distinct from "club-managed, nobody eligible yet".
eligible_referees=(Member.objects.filter(referee_profile__level__teams=team, referee_profile__valid_until__gte=timezone.localdate()).order_by("last_name", "first_name") if team.referee_management == Team.RefereeManagement.CLUB else None),
**kwargs,
)
class TeamRosterAddView(TeamManagerRequiredMixin, FormView):
"""Reachable only via the "Add player" modal on the team page. Not
RedirectOnInvalidMixin: that can't carry ?season= through a plain
redirect(view_name, **kwargs), and losing the season on a failed add would
land the admin back looking at a different one than they were editing."""
form_class = TeamMembershipForm
http_method_names = ["post"]
def get_team(self):
return get_object_or_404(Team.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def get_season(self):
return get_object_or_404(Season.objects.filter(club=self.request.club), pk=self.kwargs["season_pk"])
def get_form_kwargs(self):
# instance carries team/season *before* validation runs -- TeamMembership.clean()
# (validate_club_scope) needs self.team_id set to check season/position are the
# same club's, and form_valid() runs only after that validation already passed.
return super().get_form_kwargs() | {"club": self.request.club, "team": self.get_team(), "season": self.get_season(), "instance": TeamMembership(team=self.get_team(), season=self.get_season())}
def team_detail_url(self):
return f"{reverse('management:team_detail', args=[self.kwargs['pk']])}?season={self.kwargs['season_pk']}"
def form_invalid(self, form):
for error in form.errors.values():
notify(self.request, f"e|{_('Could not add player')}|{' '.join(error)}")
return redirect(self.team_detail_url())
def form_valid(self, form):
try:
form.save()
except IntegrityError:
# Belt and braces: form.clean() already checks jersey-number and
# member uniqueness by hand (team/season aren't form fields, so
# Django's own validate_unique() can't see those constraints) --
# this is the backstop for whatever that doesn't catch.
notify(self.request, f"e|{_('Could not add player')}|{_('That player could not be added -- please check the details and try again.')}")
return redirect(self.team_detail_url())
body = _("%(member)s” added to the roster.") % {"member": form.instance.member}
notify(self.request, f"s|{_('Player added')}|{body}")
return redirect(self.team_detail_url())
class TeamRosterUpdateView(TeamManagerRequiredMixin, FormView):
form_class = TeamMembershipForm
http_method_names = ["post"]
def get_object(self):
return get_object_or_404(TeamMembership.objects.filter(team__club=self.request.club, team__pk=self.kwargs["pk"]), pk=self.kwargs["membership_pk"])
def get_team(self):
return self.get_object().team
def get_form_kwargs(self):
membership = self.get_object()
return super().get_form_kwargs() | {"instance": membership, "club": self.request.club, "team": membership.team, "season": membership.season}
def team_detail_url(self):
return f"{reverse('management:team_detail', args=[self.kwargs['pk']])}?season={self.get_object().season_id}"
def form_invalid(self, form):
for error in form.errors.values():
notify(self.request, f"e|{_('Could not update player')}|{' '.join(error)}")
return redirect(self.team_detail_url())
def form_valid(self, form):
try:
form.save()
except IntegrityError:
notify(self.request, f"e|{_('Could not update player')}|{_('That change could not be saved -- please check the details and try again.')}")
return redirect(self.team_detail_url())
body = _("%(member)s” updated.") % {"member": form.instance.member}
notify(self.request, f"s|{_('Player updated')}|{body}")
return redirect(self.team_detail_url())
class TeamRosterRemoveView(TeamManagerRequiredMixin, View):
def get_team(self):
return get_object_or_404(Team.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def post(self, request, pk, membership_pk):
membership = get_object_or_404(TeamMembership.objects.filter(team__club=request.club, team__pk=pk), pk=membership_pk)
season_id, member = membership.season_id, membership.member
membership.delete()
body = _("%(member)s” removed from the roster.") % {"member": member}
notify(request, f"w|{_('Player removed')}|{body}")
return redirect(f"{reverse('management:team_detail', args=[pk])}?season={season_id}")
class TeamStaffAddView(TeamManagerRequiredMixin, FormView):
form_class = StaffAssignmentForm
http_method_names = ["post"]
def get_team(self):
return get_object_or_404(Team.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def get_season(self):
return get_object_or_404(Season.objects.filter(club=self.request.club), pk=self.kwargs["season_pk"])
def get_form_kwargs(self):
# See TeamRosterAddView -- StaffAssignment.clean() needs team_id set before
# validation runs, not after (form_valid() only runs once already valid).
return super().get_form_kwargs() | {"club": self.request.club, "team": self.get_team(), "season": self.get_season(), "instance": StaffAssignment(team=self.get_team(), season=self.get_season())}
def team_detail_url(self):
return f"{reverse('management:team_detail', args=[self.kwargs['pk']])}?season={self.kwargs['season_pk']}"
def form_invalid(self, form):
for error in form.errors.values():
notify(self.request, f"e|{_('Could not assign staff')}|{' '.join(error)}")
return redirect(self.team_detail_url())
def form_valid(self, form):
try:
form.save()
except IntegrityError:
notify(self.request, f"e|{_('Could not assign staff')}|{_('That assignment could not be saved -- please check the details and try again.')}")
return redirect(self.team_detail_url())
body = _("%(member)s” assigned as staff.") % {"member": form.instance.member}
notify(self.request, f"s|{_('Staff assigned')}|{body}")
return redirect(self.team_detail_url())
class TeamStaffUpdateView(TeamManagerRequiredMixin, FormView):
form_class = StaffAssignmentForm
http_method_names = ["post"]
def get_object(self):
return get_object_or_404(StaffAssignment.objects.filter(team__club=self.request.club, team__pk=self.kwargs["pk"]), pk=self.kwargs["assignment_pk"])
def get_team(self):
return self.get_object().team
def get_form_kwargs(self):
assignment = self.get_object()
return super().get_form_kwargs() | {"instance": assignment, "club": self.request.club, "team": assignment.team, "season": assignment.season}
def team_detail_url(self):
return f"{reverse('management:team_detail', args=[self.kwargs['pk']])}?season={self.get_object().season_id}"
def form_invalid(self, form):
for error in form.errors.values():
notify(self.request, f"e|{_('Could not update staff assignment')}|{' '.join(error)}")
return redirect(self.team_detail_url())
def form_valid(self, form):
try:
form.save()
except IntegrityError:
notify(self.request, f"e|{_('Could not update staff assignment')}|{_('That change could not be saved -- please check the details and try again.')}")
return redirect(self.team_detail_url())
body = _("%(member)s” updated.") % {"member": form.instance.member}
notify(self.request, f"s|{_('Staff assignment updated')}|{body}")
return redirect(self.team_detail_url())
class TeamStaffRemoveView(TeamManagerRequiredMixin, View):
def get_team(self):
return get_object_or_404(Team.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def post(self, request, pk, assignment_pk):
assignment = get_object_or_404(StaffAssignment.objects.filter(team__club=request.club, team__pk=pk), pk=assignment_pk)
season_id, member = assignment.season_id, assignment.member
assignment.delete()
body = _("%(member)s” removed from staff.") % {"member": member}
notify(request, f"w|{_('Staff removed')}|{body}")
return redirect(f"{reverse('management:team_detail', args=[pk])}?season={season_id}")
class TeamBulkAddView(TeamManagerRequiredMixin, View):
"""Add many people to a team's roster and/or staff in one go -- the one-by-one
modals (TeamRosterAddView / TeamStaffAddView) don't scale past a handful of
names. Every eligible member gets an independent "add as player" and "add as
staff" pair of controls, so one person can be added as both in a single submit
(a playing coach, most often).
Same eligibility rule as the single-add forms (eligible_roster_members: active
-- i.e. paid -- for this club this season or next), enforced server-side
regardless of what the client submits.
"""
template_name = "management/team_bulk_add.html"
def get_team(self):
return get_object_or_404(Team.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def get_season(self):
return get_object_or_404(Season.objects.filter(club=self.request.club), pk=self.kwargs["season_pk"])
def get_context_data(self, **kwargs):
team = self.get_team()
season = self.get_season()
club = self.request.club
members = eligible_roster_members(club).order_by("last_name", "first_name")
search = self.request.GET.get("q", "").strip()
if search:
members = members.filter(Q(first_name__icontains=search) | Q(last_name__icontains=search))
rostered = set(TeamMembership.objects.filter(team=team, season=season).values_list("member_id", flat=True))
staffed = set(StaffAssignment.objects.filter(team=team, season=season).values_list("member_id", flat=True))
members = list(members)
for member in members:
member.already_player = member.pk in rostered
member.already_staff = member.pk in staffed
return {
"team": team,
"season": season,
"members": members,
"search": search,
"player_positions": Position.objects.filter(club=club, staff_position=False),
"staff_positions": Position.objects.filter(club=club, staff_position=True),
**kwargs,
}
def get(self, request, *args, **kwargs):
return render(request, self.template_name, self.get_context_data())
def post(self, request, *args, **kwargs):
team = self.get_team()
season = self.get_season()
club = self.request.club
rostered = set(TeamMembership.objects.filter(team=team, season=season).values_list("member_id", flat=True))
staffed = set(StaffAssignment.objects.filter(team=team, season=season).values_list("member_id", flat=True))
used_jerseys = set(TeamMembership.objects.filter(team=team, season=season, jersey_number__isnull=False).values_list("jersey_number", flat=True))
player_positions = {str(position.pk): position for position in Position.objects.filter(club=club, staff_position=False)}
staff_positions = {str(position.pk): position for position in Position.objects.filter(club=club, staff_position=True)}
players_added = staff_added = 0
errors = []
# Recomputed server-side from eligible_roster_members, never from client
# input -- a submitted member id that isn't actually eligible (lapsed
# between page load and submit, say) is silently skipped rather than
# trusted.
for member in eligible_roster_members(club):
key = str(member.pk)
if member.pk not in rostered and request.POST.get(f"player_{key}"):
position = player_positions.get(request.POST.get(f"player_position_{key}", ""))
if position is None:
errors.append(_("%(member)s: choose a position to add them as a player.") % {"member": member})
else:
jersey_raw = request.POST.get(f"jersey_{key}", "").strip()
jersey_number, jersey_error = None, False
if jersey_raw:
try:
jersey_number = int(jersey_raw)
except ValueError:
jersey_error = True
errors.append(_("%(member)s: jersey number must be a whole number.") % {"member": member})
if not jersey_error:
if jersey_number is not None and jersey_number in used_jerseys:
errors.append(_("%(member)s: jersey #%(number)s is already taken this season.") % {"member": member, "number": jersey_number})
else:
membership = TeamMembership(team=team, season=season, member=member, position=position, jersey_number=jersey_number)
try:
membership.full_clean()
membership.save()
except ValidationError, IntegrityError:
errors.append(_("%(member)s: could not be added as a player -- please check the details and try again.") % {"member": member})
else:
players_added += 1
if jersey_number is not None:
used_jerseys.add(jersey_number)
if member.pk not in staffed and request.POST.get(f"staff_{key}"):
position = staff_positions.get(request.POST.get(f"staff_position_{key}", ""))
if position is None:
errors.append(_("%(member)s: choose a position to add them as staff.") % {"member": member})
else:
assignment = StaffAssignment(team=team, season=season, member=member, position=position)
try:
assignment.full_clean()
assignment.save()
except ValidationError, IntegrityError:
errors.append(_("%(member)s: could not be assigned as staff -- please check the details and try again.") % {"member": member})
else:
staff_added += 1
if players_added or staff_added:
body = _("%(players)s added as player(s), %(staff)s added as staff.") % {"players": players_added, "staff": staff_added}
notify(request, f"s|{_('Team updated')}|{body}")
for error in errors:
notify(request, f"e|{_('Could not add')}|{error}")
if not players_added and not staff_added and not errors:
notify(request, f"i|{_('Nothing to add')}|{_('No one was selected.')}")
return redirect(f"{reverse('management:team_detail', args=[team.pk])}?season={season.pk}")
class TeamPhotoSetView(TeamManagerRequiredMixin, FormView):
"""Reachable only via the "Upload"/"Replace" modal on the team page. Binds
to the existing TeamPhoto for this team+season (if any) so re-uploading
replaces it in place -- same "create or update the one row for this
scope" pattern as controlpanel.views.ClubHomeLocationSetView. Not
RedirectOnInvalidMixin, same reasoning as TeamRosterAddView: that can't
carry ?season= through a plain redirect(view_name, **kwargs)."""
form_class = TeamPhotoForm
http_method_names = ["post"]
def get_team(self):
return get_object_or_404(Team.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def get_season(self):
return get_object_or_404(Season.objects.filter(club=self.request.club), pk=self.kwargs["season_pk"])
def get_form_kwargs(self):
return super().get_form_kwargs() | {"instance": TeamPhoto.objects.filter(team=self.get_team(), season=self.get_season()).first()}
def team_detail_url(self):
return f"{reverse('management:team_detail', args=[self.kwargs['pk']])}?season={self.kwargs['season_pk']}"
def form_invalid(self, form):
for error in form.errors.values():
notify(self.request, f"e|{_('Could not upload photo')}|{' '.join(error)}")
return redirect(self.team_detail_url())
def form_valid(self, form):
photo = form.save(commit=False)
photo.team = self.get_team()
photo.season = self.get_season()
photo.save()
notify(self.request, f"s|{_('Photo uploaded')}|{_('Team photo updated.')}")
return redirect(self.team_detail_url())
class TeamPhotoDeleteView(TeamManagerRequiredMixin, View):
def get_team(self):
return get_object_or_404(Team.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def post(self, request, pk, season_pk):
team = self.get_team()
TeamPhoto.objects.filter(team=team, season_id=season_pk).delete()
notify(request, f"w|{_('Photo removed')}|{_('Team photo removed.')}")
return redirect(f"{reverse('management:team_detail', args=[pk])}?season={season_pk}")
# --- 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, every event, and news."),
ClubRole.Roles.EDITOR: _("Can create and edit events, and publish news items, 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(ClubStaffRequiredMixin, ListView):
"""Visible to any staff (coaches need to see positions to make sense of a
roster); creating/editing positions is still ADMIN-only, gated in the
template and on PositionCreateView/PositionUpdateView themselves."""
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 RefereeLevelListView(ClubStaffRequiredMixin, ListView):
"""Visible to any staff, same reasoning as PositionListView; creating/
editing a level is admin-only."""
template_name = "management/referee_level_list.html"
context_object_name = "levels"
def get_queryset(self):
return RefereeLevel.objects.filter(club=self.request.club).prefetch_related("teams")
class RefereeLevelCreateView(ClubAdminRequiredMixin, CreateView):
model = RefereeLevel
form_class = RefereeLevelForm
template_name = "management/referee_level_form.html"
def get_form_kwargs(self):
return super().get_form_kwargs() | {"club": self.request.club}
def form_valid(self, form):
form.instance.club = self.request.club
response = super().form_valid(form)
body = _("%(level)s” created.") % {"level": self.object}
notify(self.request, f"s|{_('Referee level created')}|{body}")
return response
def get_success_url(self):
return reverse("management:referee_level_list")
class RefereeLevelUpdateView(ClubAdminRequiredMixin, UpdateView):
model = RefereeLevel
form_class = RefereeLevelForm
template_name = "management/referee_level_form.html"
def get_queryset(self):
return RefereeLevel.objects.filter(club=self.request.club)
def get_form_kwargs(self):
return super().get_form_kwargs() | {"club": self.request.club}
def form_valid(self, form):
response = super().form_valid(form)
body = _("%(level)s” updated.") % {"level": self.object}
notify(self.request, f"s|{_('Referee level updated')}|{body}")
return response
def get_success_url(self):
return reverse("management:referee_level_list")
def get_context_data(self, **kwargs):
return super().get_context_data(update_view=True, **kwargs)
class RefereeListView(ClubStaffRequiredMixin, ListView):
"""Every referee in the club, at a glance: level, eligible teams, validity
-- see teams.RefereeProfile. Read-only; editing happens on the member's
own page (MemberRefereeEligibilityUpdateView)."""
template_name = "management/referee_list.html"
context_object_name = "referees"
def get_queryset(self):
members = members_visible_to(self.request.user, self.request.club).filter(referee_profile__isnull=False)
return members.select_related("referee_profile", "referee_profile__level").prefetch_related("referee_profile__level__teams").order_by("last_name", "first_name")
# --- Groups: a generic named collection of members (all coaches, all team managers,
# a referee pool, ...) -- admin-only, like Positions/Roles above -------------------
class GroupListView(ClubAdminRequiredMixin, ListView):
template_name = "management/group_list.html"
context_object_name = "groups"
def get_queryset(self):
return Group.objects.filter(club=self.request.club).annotate(member_count=Count("memberships", distinct=True))
class GroupCreateView(ClubAdminRequiredMixin, CreateView):
model = Group
form_class = GroupForm
template_name = "management/group_form.html"
def form_valid(self, form):
form.instance.club = self.request.club
response = super().form_valid(form)
body = _("%(group)s” created.") % {"group": self.object}
notify(self.request, f"s|{_('Group created')}|{body}")
return response
def get_success_url(self):
return reverse("management:group_detail", args=[self.object.pk])
class GroupUpdateView(ClubAdminRequiredMixin, UpdateView):
model = Group
form_class = GroupForm
template_name = "management/group_form.html"
def get_queryset(self):
return Group.objects.filter(club=self.request.club)
def form_valid(self, form):
response = super().form_valid(form)
body = _("%(group)s” updated.") % {"group": self.object}
notify(self.request, f"s|{_('Group updated')}|{body}")
return response
def get_success_url(self):
return reverse("management:group_detail", args=[self.object.pk])
def get_context_data(self, **kwargs):
return super().get_context_data(update_view=True, **kwargs)
class GroupDeleteView(ClubAdminRequiredMixin, View):
def post(self, request, pk):
group = get_object_or_404(Group.objects.filter(club=request.club), pk=pk)
name = str(group)
group.delete()
notify(request, f"w|{_('Group deleted')}|" + _("%(group)s” deleted.") % {"group": name})
return redirect("management:group_list")
class GroupDetailView(ClubAdminRequiredMixin, DetailView):
template_name = "management/group_detail.html"
context_object_name = "group"
def get_queryset(self):
return Group.objects.filter(club=self.request.club)
def get_context_data(self, **kwargs):
return super().get_context_data(
memberships=GroupMembership.objects.filter(group=self.object).select_related("member"),
**kwargs,
)
class GroupBulkAddView(ClubAdminRequiredMixin, View):
"""Add many members to a group in one go -- mirrors TeamBulkAddView's
checkbox-table pattern, minus the player/staff split (group membership has
no per-member attributes)."""
template_name = "management/group_bulk_add.html"
def get_group(self):
return get_object_or_404(Group.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def get_context_data(self, **kwargs):
group = self.get_group()
members = members_visible_to(self.request.user, self.request.club).order_by("last_name", "first_name")
search = self.request.GET.get("q", "").strip()
if search:
members = members.filter(Q(first_name__icontains=search) | Q(last_name__icontains=search))
existing_ids = set(GroupMembership.objects.filter(group=group).values_list("member_id", flat=True))
members = list(members)
for member in members:
member.already_in_group = member.pk in existing_ids
return {"group": group, "members": members, "search": search, **kwargs}
def get(self, request, *args, **kwargs):
return render(request, self.template_name, self.get_context_data())
def post(self, request, *args, **kwargs):
group = self.get_group()
existing_ids = set(GroupMembership.objects.filter(group=group).values_list("member_id", flat=True))
added = 0
for member in members_visible_to(request.user, request.club):
if member.pk not in existing_ids and request.POST.get(f"member_{member.pk}"):
GroupMembership.objects.create(group=group, member=member)
added += 1
if added:
notify(request, f"s|{_('Group updated')}|" + _("%(count)s member(s) added to “%(group)s”.") % {"count": added, "group": group})
else:
notify(request, f"i|{_('Nothing to add')}|{_('No one was selected.')}")
return redirect("management:group_detail", pk=group.pk)
class GroupMemberRemoveView(ClubAdminRequiredMixin, 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)
member = membership.member
membership.delete()
notify(request, f"w|{_('Removed from group')}|" + _("%(member)s” removed from “%(group)s”.") % {"member": member, "group": group})
return redirect("management:group_detail", pk=group.pk)
# --- News: draft/edit is broad (any coach_manager/editor/admin), but only EDITOR/ADMIN
# may publish -- the release flow the news app exists for ---------------------------
class NewsListView(ClubStaffRequiredMixin, ListView):
template_name = "management/news_list.html"
context_object_name = "news_items"
def get_queryset(self):
return News.objects.filter(club=self.request.club).prefetch_related("teams")
def get_context_data(self, **kwargs):
for news_item in self.object_list:
news_item.can_edit = can_edit_news(self.request.user, news_item)
return super().get_context_data(**kwargs)
class NewsCreateView(NewsAuthorRequiredMixin, CreateView):
model = News
form_class = NewsForm
template_name = "management/news_form.html"
def get_form_kwargs(self):
return super().get_form_kwargs() | {"club": self.request.club}
def form_valid(self, form):
form.instance.club = self.request.club
form.instance.created_by = Member.objects.filter(user=self.request.user).first()
response = super().form_valid(form)
body = _("%(news)s” created.") % {"news": self.object}
notify(self.request, f"s|{_('News item created')}|{body}")
return response
def get_success_url(self):
return reverse("management:news_detail", args=[self.object.pk])
class NewsDetailView(ClubStaffRequiredMixin, DetailView):
template_name = "management/news_detail.html"
context_object_name = "news_item"
def get_queryset(self):
return News.objects.filter(club=self.request.club).prefetch_related("teams", "photos")
def get_context_data(self, **kwargs):
return super().get_context_data(
can_edit=can_edit_news(self.request.user, self.object),
can_publish=can_publish_news(self.request.user, self.request.club),
publish_form=NewsPublishForm(),
photo_upload_form=NewsPhotoUploadForm(),
**kwargs,
)
class NewsUpdateView(NewsEditRequiredMixin, UpdateView):
model = News
form_class = NewsForm
template_name = "management/news_form.html"
def get_news_item(self):
return get_object_or_404(News.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def get_queryset(self):
return News.objects.filter(club=self.request.club)
def get_form_kwargs(self):
return super().get_form_kwargs() | {"club": self.request.club}
def form_valid(self, form):
response = super().form_valid(form)
body = _("%(news)s” updated.") % {"news": self.object}
notify(self.request, f"s|{_('News item updated')}|{body}")
return response
def get_success_url(self):
return reverse("management:news_detail", args=[self.object.pk])
def get_context_data(self, **kwargs):
return super().get_context_data(update_view=True, **kwargs)
class NewsDeleteView(NewsEditRequiredMixin, View):
def get_news_item(self):
return get_object_or_404(News.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def post(self, request, pk):
news_item = self.get_news_item()
title = str(news_item)
news_item.delete()
body = _("%(news)s” has been deleted.") % {"news": title}
notify(request, f"w|{_('News item deleted')}|{body}")
return redirect("management:news_list")
class NewsPublishView(NewsPublisherRequiredMixin, RedirectOnInvalidMixin, FormView):
form_class = NewsPublishForm
http_method_names = ["post"]
invalid_redirect_url_name = "management:news_detail"
def get_invalid_redirect_kwargs(self):
return {"pk": self.kwargs["pk"]}
def form_valid(self, form):
news_item = get_object_or_404(News.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
news_item.publish(at=form.cleaned_data["published_at"])
if news_item.is_scheduled:
body = _("%(news)s” is scheduled to go live on %(date)s.") % {"news": news_item, "date": news_item.published_at}
else:
body = _("%(news)s” is now live.") % {"news": news_item}
notify(self.request, f"s|{_('News item published')}|{body}")
return redirect("management:news_detail", pk=news_item.pk)
class NewsUnpublishView(NewsPublisherRequiredMixin, View):
def post(self, request, pk):
news_item = get_object_or_404(News.objects.filter(club=request.club), pk=pk)
news_item.unpublish()
body = _("%(news)s” is back to a draft.") % {"news": news_item}
notify(request, f"w|{_('News item unpublished')}|{body}")
return redirect("management:news_detail", pk=news_item.pk)
class NewsPhotoUploadView(NewsEditRequiredMixin, RedirectOnInvalidMixin, FormView):
"""One NewsPhoto per uploaded file; if the item had none yet, the first one
in this batch is auto-flagged main so there's always one once any photo exists."""
form_class = NewsPhotoUploadForm
http_method_names = ["post"]
invalid_redirect_url_name = "management:news_detail"
def get_news_item(self):
return get_object_or_404(News.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def get_invalid_redirect_kwargs(self):
return {"pk": self.kwargs["pk"]}
def form_valid(self, form):
news_item = self.get_news_item()
has_main = news_item.photos.filter(is_main=True).exists()
count = 0
for image in form.cleaned_data["images"]:
NewsPhoto.objects.create(news_item=news_item, image=image, is_main=not has_main)
has_main = True
count += 1
body = ngettext("%(count)d photo added.", "%(count)d photos added.", count) % {"count": count}
notify(self.request, f"s|{_('Photos added')}|{body}")
return redirect("management:news_detail", pk=news_item.pk)
class NewsPhotoSetMainView(NewsEditRequiredMixin, View):
def get_news_item(self):
return get_object_or_404(News.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def post(self, request, pk, photo_pk):
news_item = self.get_news_item()
photo = get_object_or_404(NewsPhoto, pk=photo_pk, news_item=news_item)
with transaction.atomic():
NewsPhoto.objects.filter(news_item=news_item).update(is_main=False)
photo.is_main = True
photo.save(update_fields=["is_main"])
return redirect("management:news_detail", pk=news_item.pk)
class NewsPhotoDeleteView(NewsEditRequiredMixin, View):
def get_news_item(self):
return get_object_or_404(News.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def post(self, request, pk, photo_pk):
news_item = self.get_news_item()
photo = get_object_or_404(NewsPhoto, pk=photo_pk, news_item=news_item)
was_main = photo.is_main
with transaction.atomic():
photo.delete()
if was_main:
replacement = news_item.photos.first()
if replacement is not None:
replacement.is_main = True
replacement.save(update_fields=["is_main"])
notify(request, f"w|{_('Photo removed')}|{_('The photo was removed.')}")
return redirect("management:news_detail", pk=news_item.pk)
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
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()
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."""
template_name = "management/event_list.html"
context_object_name = "events"
def get_queryset(self):
club = self.request.club
user = 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)
events = scoped_to_managed_teams(events, user, club)
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"))
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).
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 get_context_data(self, **kwargs):
club, user = self.request.club, self.request.user
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_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(),
**kwargs,
)
class EventDetailView(ClubStaffRequiredMixin, DetailView):
template_name = "management/event_detail.html"
context_object_name = "event"
def get_queryset(self):
events = Event.objects.filter(club=self.request.club).select_related("series", "location", "opponent", "season").prefetch_related("teams", "invited_members", "excluded_members")
return scoped_to_managed_teams(events, self.request.user, self.request.club)
def get_context_data(self, **kwargs):
club, user, event = self.request.club, self.request.user, self.object
can_manage = is_club_admin(user, club) or teams_managed_by(user, club).filter(pk__in=event.teams.values_list("pk", flat=True)).exists()
rows_by_status = {}
for row in event.attendances.select_related("member").order_by("member__last_name", "member__first_name"):
rows_by_status.setdefault(row.status, []).append(row)
# Every status, in its declared order -- not just the ones that happen to have
# a response yet, or "0 people excused" silently disappears instead of reading
# as good news. Same grouping backs both the breakdown counts and the modal's
# per-status sections, so there's exactly one query, not two.
attendance_groups = [{"value": value, "label": label, "rows": rows_by_status.get(value, [])} for value, label in Attendance.AttendanceStatus.choices]
# Admin-only for now, unlike can_manage's other actions (edit, fetch info, ...) --
# a team manager/coach still sees the Referees panel (who's assigned, capacity),
# just not the assign/remove controls. See EventRefereeAssignView/RemoveView.
can_manage_referees = is_club_admin(user, club)
referee_management_needed = needs_referee_management(event)
referees = []
referee_candidates = []
referees_full = False
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)
candidate.has_conflict = bool(conflicts)
candidate.conflict_titles = ", ".join(conflict.title for conflict in conflicts)
referee_candidates.append(candidate)
return super().get_context_data(
can_manage=can_manage,
can_manage_referees=can_manage_referees,
referee_management_needed=referee_management_needed,
attendance_groups=attendance_groups,
has_attendance_rows=any(group["rows"] for group in attendance_groups),
referees=referees,
referee_candidates=referee_candidates,
referees_full=referees_full,
**kwargs,
)
def _redirect_next_or(request, fallback_url):
"""`next` (POST body, or the query string -- the fee-edit modal posts to a
plain action_url with no room to inject a hidden field, so it carries
`next` there instead) if it's safe to redirect to, else `fallback_url`.
Lets the same assign/remove/fee endpoints be posted to from more than one
page (the event detail page, and the referee management dashboard) and
return the visitor to wherever they actually came from."""
next_url = request.POST.get("next") or request.GET.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(fallback_url)
class EventRefereeAssignView(ClubAdminRequiredMixin, View):
"""Reachable via the assign control on the event detail page's Referees
panel, or the referee management dashboard -- POST-only, no standalone
template. Admin-only for now (unlike most event actions, which a team
manager can also do) -- see EventDetailView's can_manage_referees; team
managers/coaches still see the panel, just not the assign/remove
controls."""
def get_event(self):
return get_object_or_404(Event.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def post(self, request, pk):
event = self.get_event()
member = get_object_or_404(eligible_referees(event), pk=request.POST.get("member"))
assigned_by = Member.objects.filter(user=request.user).first()
try:
assign_referee(event, member, assigned_by=assigned_by)
except RefereeAssignmentError as error:
notify(request, f"e|{_('Could not assign referee')}|{error}")
else:
notify(request, f"s|{_('Referee assigned')}|" + _("%(member)s” will referee this game.") % {"member": member})
return _redirect_next_or(request, reverse("management:event_detail", args=[event.pk]))
class EventRefereeRemoveView(ClubAdminRequiredMixin, View):
def get_event(self):
return get_object_or_404(Event.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def post(self, request, pk, referee_pk):
event = self.get_event()
referee = get_object_or_404(EventReferee.objects.filter(event=event), pk=referee_pk)
name = referee.display_name
remove_referee(referee)
notify(request, f"w|{_('Referee removed')}|" + _("%(name)s” is no longer refereeing this game.") % {"name": name})
return _redirect_next_or(request, reverse("management:event_detail", args=[event.pk]))
class EventRefereeAddExternalView(ClubAdminRequiredMixin, View):
"""Log a non-member referee (federation-appointed, most often) against a
game -- reachable from the same Referees panel as EventRefereeAssignView,
on both the event detail page and the referee management dashboard."""
def get_event(self):
return get_object_or_404(Event.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def post(self, request, pk):
event = self.get_event()
form = ExternalRefereeForm(request.POST)
fallback = _redirect_next_or(request, reverse("management:event_detail", args=[event.pk]))
if not form.is_valid():
notify(request, f"e|{_('Could not add referee')}|{_('A name is required.')}")
return fallback
assigned_by = Member.objects.filter(user=request.user).first()
try:
add_external_referee(event, form.cleaned_data["name"], assigned_by=assigned_by)
except RefereeAssignmentError as error:
notify(request, f"e|{_('Could not add referee')}|{error}")
else:
notify(request, f"s|{_('Referee added')}|" + _("%(name)s” will referee this game.") % {"name": form.cleaned_data["name"]})
return fallback
class EventRefereeFeeUpdateView(ClubAdminRequiredMixin, FormView):
"""Set one referee assignment's fee/km/rate -- reachable via the "Fee"
modal on the Referees panel. POST-only, no standalone template. Not
RedirectOnInvalidMixin: that redirects to a fixed url name, but this view
(like the assign/remove ones) needs to honour the next-aware fallback so
it works from both the event detail page and the dashboard."""
form_class = EventRefereeFeeForm
http_method_names = ["post"]
def get_event(self):
return get_object_or_404(Event.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def get_referee(self):
return get_object_or_404(EventReferee.objects.filter(event=self.get_event()), pk=self.kwargs["referee_pk"])
def get_fallback(self):
return _redirect_next_or(self.request, reverse("management:event_detail", args=[self.kwargs["pk"]]))
def get_form_kwargs(self):
return super().get_form_kwargs() | {"instance": self.get_referee()}
def form_invalid(self, form):
for error in form.errors.values():
notify(self.request, f"e|{_('Could not update fee')}|{' '.join(error)}")
return self.get_fallback()
def form_valid(self, form):
referee = self.get_referee()
set_referee_fee(referee, fee=form.cleaned_data["fee"], km=form.cleaned_data["km"], km_rate=form.cleaned_data["km_rate"])
notify(self.request, f"s|{_('Fee updated')}|" + _("Updated the fee for “%(name)s”.") % {"name": referee.display_name})
return self.get_fallback()
class EventRefereeFormPdfView(ClubAdminRequiredMixin, View):
"""Downloadable PDF of the referee payment form for one game, modeled on
the club's existing paper form -- club header (legal name if set, else
plain name; address from the club's home Location, not this specific
event's, so the form still reads right even if called from a page where
the event's own location happens to be blank) plus this game's details,
referees and their fee/km breakdown, and blank signature lines."""
def get(self, request, pk):
event = get_object_or_404(Event.objects.filter(club=request.club).prefetch_related("teams", "referees__member"), pk=pk)
home_location = Location.objects.filter(club=request.club, is_home=True).first()
referees = list(event.referees.all())
context = {"club": request.club, "event": event, "referees": referees, "home_location": home_location, "grand_total": sum((referee.total_payable for referee in referees), Decimal("0"))} | referee_form_colors(request.club)
try:
pdf = event_referee_form_pdf(context)
except PDFExportError as error:
notify(request, f"e|{_('PDF unavailable')}|{error}")
return redirect(reverse("management:event_detail", args=[event.pk]))
response = HttpResponse(pdf, content_type="application/pdf")
response["Content-Disposition"] = f'attachment; filename="referee-form-{event.pk}.pdf"'
return response
class RefereeManagementDashboardView(ClubAdminRequiredMixin, 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
assign/remove -- posts to the same EventRefereeAssignView/RemoveView the
event detail page uses, via the shared _referee_assignment_panel include,
and returns here afterwards rather than to the event detail page.
The `range` GET param picks either a calendar window ("week"/"two_weeks",
both anchored on the ISO week so "this week" always means Mon-Sun of the
current week regardless of what weekday it is today) or a flat count of
upcoming games -- buttons in the template, not a dropdown, since there
are only a handful of sensible choices."""
template_name = "management/referee_management.html"
RANGE_CHOICES = ["week", "two_weeks", "10", "25", "50"]
DEFAULT_RANGE = "10"
def get_range(self):
value = self.request.GET.get("range", self.DEFAULT_RANGE)
return value if value in self.RANGE_CHOICES else self.DEFAULT_RANGE
def get_context_data(self, **kwargs):
club = self.request.club
range_choice = self.get_range()
queryset = (
Event.objects.filter(club=club, kind=Event.EventKind.GAME, cancelled=False, location__is_home=True, start__gte=timezone.now(), teams__referee_management=Team.RefereeManagement.CLUB)
.distinct()
.select_related("location", "opponent")
.prefetch_related("teams", "referees__member", "referees__assigned_by")
.order_by("start")
)
if range_choice in ("week", "two_weeks"):
today = timezone.localdate()
end_of_this_week = today + timedelta(days=6 - today.weekday())
end_date = end_of_this_week + timedelta(days=7) if range_choice == "two_weeks" else end_of_this_week
games = list(queryset.filter(start__date__lte=end_date))
else:
games = list(queryset[: int(range_choice)])
kpi_no_referee = 0
kpi_understaffed = 0
kpi_fully_staffed = 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 = []
if not game.referee_rows:
kpi_no_referee += 1
elif not game.referees_full:
kpi_understaffed += 1
else:
kpi_fully_staffed += 1
if not game.referees_full:
for candidate in eligible_referees(game):
conflicts = conflicting_events(candidate, game)
candidate.has_conflict = bool(conflicts)
candidate.conflict_titles = ", ".join(conflict.title for conflict in conflicts)
game.referee_candidates.append(candidate)
return super().get_context_data(
games=games,
range_choice=range_choice,
kpi_total=len(games),
kpi_no_referee=kpi_no_referee,
kpi_understaffed=kpi_understaffed,
kpi_fully_staffed=kpi_fully_staffed,
**kwargs,
)
class EventCreateView(ClubStaffRequiredMixin, CreateView):
"""Broader than EventManagerRequiredMixin's own gate (no object yet to check
teams/groups against): anyone managing at least one team, belonging to at
least one group, or an admin. EventForm itself then restricts *which*
teams/groups a non-admin can pick and requires at least one, so a
club-wide event stays admin-only."""
model = Event
form_class = EventForm
template_name = "management/event_form.html"
def test_func(self):
user, club = self.request.user, self.request.club
return is_club_admin(user, club) or teams_managed_by(user, club).exists() or groups_manageable_by(user, club).exists()
def get_form_kwargs(self):
# Event.clean() rejects a location/opponent from another club by comparing
# against self.club_id -- on a brand-new instance that's still None until
# ClubScopedModel.save() auto-assigns it, which only happens *after*
# validation. Set it here so full_clean() sees the real club, not None.
return super().get_form_kwargs() | {"club": self.request.club, "user": self.request.user, "instance": Event(club=self.request.club)}
def form_valid(self, form):
response = super().form_valid(form)
body = _("%(event)s” created.") % {"event": self.object}
notify(self.request, f"s|{_('Event created')}|{body}")
return response
def get_success_url(self):
return reverse("management:event_detail", args=[self.object.pk])
class EventUpdateView(EventManagerRequiredMixin, UpdateView):
model = Event
form_class = EventForm
template_name = "management/event_form.html"
def get_queryset(self):
return Event.objects.filter(club=self.request.club)
def get_teams(self):
return get_object_or_404(Event.objects.filter(club=self.request.club), pk=self.kwargs["pk"]).teams.all()
def get_groups(self):
return get_object_or_404(Event.objects.filter(club=self.request.club), pk=self.kwargs["pk"]).groups.all()
def get_form_kwargs(self):
return super().get_form_kwargs() | {"club": self.request.club, "user": self.request.user, "editing": True}
def form_valid(self, form):
response = super().form_valid(form)
body = _("%(event)s” updated.") % {"event": self.object}
notify(self.request, f"s|{_('Event updated')}|{body}")
return response
def get_success_url(self):
return reverse("management:event_detail", args=[self.object.pk])
def get_context_data(self, **kwargs):
return super().get_context_data(update_view=True, **kwargs)
class EventDeleteView(EventManagerRequiredMixin, View):
"""A series occurrence is cancelled (keeps the series' excluded_dates in
sync -- see cancel_occurrence), never just deleted outright; a one-off
event is deleted outright. One button, the branch is internal."""
def get_event(self):
return get_object_or_404(Event.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def get_teams(self):
return self.get_event().teams.all()
def get_groups(self):
return self.get_event().groups.all()
def post(self, request, pk):
event = self.get_event()
title = str(event)
if event.series_id:
cancel_occurrence(event, hard_delete=request.POST.get("keep_record") != "on")
notify(request, f"w|{_('Occurrence cancelled')}|{_('%(event)s” was cancelled.') % {'event': title}}")
else:
event.delete()
notify(request, f"w|{_('Event deleted')}|{_('%(event)s” has been deleted.') % {'event': title}}")
return redirect("management:event_list")
class EventDetachView(EventManagerRequiredMixin, View):
"""Stop this occurrence from being touched by future series-wide edits --
see detach_occurrence. Editing a still-attached occurrence directly would
otherwise be silently overwritten by the next propagate_series() call."""
def get_event(self):
return get_object_or_404(Event.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def get_teams(self):
return self.get_event().teams.all()
def get_groups(self):
return self.get_event().groups.all()
def post(self, request, pk):
event = self.get_event()
detach_occurrence(event)
notify(request, f"s|{_('Detached from series')}|{_('%(event)s” is now edited independently and will not be touched by future series-wide changes.') % {'event': event}}")
return redirect("management:event_detail", pk=event.pk)
class EventFetchGameInfoView(EventManagerRequiredMixin, View):
"""Refresh a game's score/status from its competition -- see
events.services.competitions.fetch_game_info, which gates on the
competition's feature flag being active for this club and otherwise
no-ops. No data source is wired up yet either way, so an actual fetch
attempt always reports the same honest "not configured" error; the
button/view exist so a real integration only has to replace that function."""
def get_event(self):
return get_object_or_404(Event.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def get_teams(self):
return self.get_event().teams.all()
def get_groups(self):
return self.get_event().groups.all()
def post(self, request, pk):
event = self.get_event()
try:
if fetch_game_info(event):
notify(request, f"s|{_('Game info updated')}|{_('%(event)s” was refreshed from its competition.') % {'event': event}}")
else:
notify(request, f"i|{_('Nothing to fetch')}|{_('%(competition)s” is not enabled for this club.') % {'competition': event.competition}}")
except CompetitionFetchError as error:
notify(request, f"e|{_('Could not fetch game info')}|{error}")
return redirect("management:event_detail", pk=event.pk)
class RBIHFImportView(FeatureRequiredMixin, View):
"""Step 1: paste an RBIHF team page URL, pick which of the club's teams it's
for. Fetches and parses the page server-side, stashes the raw HTML (not
client-trusted parsed data) in the session, and renders a create/update/
delete preview -- see events.services.rbihf_import and
RBIHFImportConfirmView, which mirrors MemberImportView/
MemberImportConfirmView's session-stash-and-reparse shape."""
feature_flag = "RBIHF"
def get(self, request):
return render(request, "management/rbihf_import_form.html", {"form": RBIHFImportForm(club=request.club)})
def post(self, request):
form = RBIHFImportForm(request.POST, club=request.club)
if not form.is_valid():
return render(request, "management/rbihf_import_form.html", {"form": form})
url = form.cleaned_data["url"]
team = form.cleaned_data["team"]
rbihf_team_id = extract_team_id(url)
try:
html = fetch_html(url)
plan = build_plan(request.club, team, rbihf_team_id, html)
except RBIHFImportError as error:
form.add_error("url", str(error))
return render(request, "management/rbihf_import_form.html", {"form": form})
request.session["rbihf_import_html"] = html
request.session["rbihf_import_team_id"] = str(team.pk)
request.session["rbihf_import_rbihf_team_id"] = rbihf_team_id
return render(request, "management/rbihf_import_preview.html", {"plan": plan})
class RBIHFImportConfirmView(FeatureRequiredMixin, View):
"""Step 2: re-parses and re-diffs the HTML stashed by RBIHFImportView
against the *current* DB state (catching anything that changed since the
preview was shown), reads each row's chosen location/opponent back from
the preview form, and applies the result in one transaction."""
feature_flag = "RBIHF"
def post(self, request):
html = request.session.pop("rbihf_import_html", None)
team_id = request.session.pop("rbihf_import_team_id", None)
rbihf_team_id = request.session.pop("rbihf_import_rbihf_team_id", None)
if not html or not team_id or not rbihf_team_id:
notify(request, f"w|{_('Nothing to import')}|{_('Start over by pasting the RBIHF team URL again.')}")
return redirect("management:rbihf_import")
team = get_object_or_404(Team.objects.filter(club=request.club), pk=team_id)
try:
plan = build_plan(request.club, team, rbihf_team_id, html)
except RBIHFImportError:
notify(request, f"e|{_('Could not import')}|{_('Something went wrong re-reading the fetched page. Try again.')}")
return redirect("management:rbihf_import")
locations_by_game_id = {}
opponents_by_game_id = {}
for planned in [*plan.to_create, *plan.to_update]:
game_id = planned.fixture.external_game_id
locations_by_game_id[game_id] = request.POST.get(f"location_{game_id}", "")
opponents_by_game_id[game_id] = request.POST.get(f"opponent_{game_id}", "")
result = apply_plan(plan, locations_by_game_id, opponents_by_game_id)
body = _("%(created)s created, %(updated)s updated, %(deleted)s deleted.") % result
notify(request, f"s|{_('Fixtures imported')}|{body}")
return redirect("management:event_list")
class EventSeriesDetailView(ClubStaffRequiredMixin, DetailView):
template_name = "management/event_series_detail.html"
context_object_name = "series"
def get_queryset(self):
series = EventSeries.objects.filter(club=self.request.club).select_related("location", "opponent").prefetch_related("teams", "invited_members", "excluded_members")
return scoped_to_managed_teams(series, self.request.user, self.request.club)
def get_context_data(self, **kwargs):
club, user, series = self.request.club, self.request.user, self.object
can_manage = is_club_admin(user, club) or teams_managed_by(user, club).filter(pk__in=series.teams.values_list("pk", flat=True)).exists()
now = timezone.now()
occurrences = list(series.occurrences.order_by("start"))
for occurrence in occurrences:
occurrence.is_past = occurrence.start < now
return super().get_context_data(
can_manage=can_manage,
recurrence_summary=describe_rrule(series.rrule),
occurrences=occurrences,
**kwargs,
)
class EventSeriesCreateView(ClubStaffRequiredMixin, CreateView):
model = EventSeries
form_class = EventSeriesForm
template_name = "management/event_series_form.html"
def test_func(self):
user, club = self.request.user, self.request.club
return is_club_admin(user, club) or teams_managed_by(user, club).exists() or groups_manageable_by(user, club).exists()
def get_form_kwargs(self):
# Same reasoning as EventCreateView: EventSeries.clean() needs a real
# club_id on the instance before full_clean() runs, not the None a
# brand-new instance starts with.
return super().get_form_kwargs() | {"club": self.request.club, "user": self.request.user, "instance": EventSeries(club=self.request.club)}
def form_valid(self, form):
response = super().form_valid(form)
# Not automatic on save -- without this the series would exist with zero
# occurrences until the extend_event_series cron command next runs.
created = generate_occurrences(self.object)
body = ngettext("%(series)s” created, with %(count)d occurrence scheduled.", "%(series)s” created, with %(count)d occurrences scheduled.", len(created)) % {"series": self.object, "count": len(created)}
notify(self.request, f"s|{_('Series created')}|{body}")
return response
def get_success_url(self):
return reverse("management:event_series_detail", args=[self.object.pk])
class EventSeriesUpdateView(EventManagerRequiredMixin, UpdateView):
model = EventSeries
form_class = EventSeriesForm
template_name = "management/event_series_form.html"
def get_queryset(self):
return EventSeries.objects.filter(club=self.request.club)
def get_teams(self):
return get_object_or_404(EventSeries.objects.filter(club=self.request.club), pk=self.kwargs["pk"]).teams.all()
def get_groups(self):
return get_object_or_404(EventSeries.objects.filter(club=self.request.club), pk=self.kwargs["pk"]).groups.all()
def get_form_kwargs(self):
return super().get_form_kwargs() | {"club": self.request.club, "user": self.request.user}
def form_valid(self, form):
response = super().form_valid(form)
# Push the template change to future, non-detached occurrences, then fill
# in any further-out dates the (possibly changed) pattern now implies.
# Occurrences that no longer match a changed pattern are NOT auto-removed
# -- reconciling that is ambiguous (which to drop vs. keep attendance
# history for) and is left as a manual "Cancel" per stale occurrence.
propagate_series(self.object)
generate_occurrences(self.object)
body = _("%(series)s” updated.") % {"series": self.object}
notify(self.request, f"s|{_('Series updated')}|{body}")
return response
def get_success_url(self):
return reverse("management:event_series_detail", args=[self.object.pk])
def get_context_data(self, **kwargs):
return super().get_context_data(update_view=True, **kwargs)
class EventSeriesDeleteView(EventManagerRequiredMixin, View):
"""series is on_delete=CASCADE -- this also deletes every occurrence and its
attendance history. The confirm modal must say so; "Stop repeating"
(EventSeriesStopView) is the non-destructive alternative."""
def get_series(self):
return get_object_or_404(EventSeries.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def get_teams(self):
return self.get_series().teams.all()
def get_groups(self):
return self.get_series().groups.all()
def post(self, request, pk):
series = self.get_series()
title = str(series)
series.delete()
notify(request, f"w|{_('Series deleted')}|{_('%(series)s” and all of its occurrences have been deleted.') % {'series': title}}")
return redirect("management:event_list")
class EventSeriesStopView(EventManagerRequiredMixin, View):
"""Stop future generation without touching any existing occurrence or its
attendance history -- the non-destructive alternative to deleting the
series outright."""
def get_series(self):
return get_object_or_404(EventSeries.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def get_teams(self):
return self.get_series().teams.all()
def get_groups(self):
return self.get_series().groups.all()
def post(self, request, pk):
series = self.get_series()
series.until = timezone.now()
series.save(update_fields=["until"])
notify(request, f"s|{_('Series stopped')}|{_('%(series)s” will no longer generate new occurrences. Existing ones are untouched.') % {'series': series}}")
return redirect("management:event_series_detail", pk=series.pk)
class EventSeriesGenerateView(EventManagerRequiredMixin, View):
def get_series(self):
return get_object_or_404(EventSeries.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def get_teams(self):
return self.get_series().teams.all()
def get_groups(self):
return self.get_series().groups.all()
def post(self, request, pk):
series = self.get_series()
created = generate_occurrences(series)
body = ngettext("%(count)d new occurrence generated.", "%(count)d new occurrences generated.", len(created)) % {"count": len(created)}
notify(request, f"s|{_('Occurrences generated')}|{body}")
return redirect("management:event_series_detail", pk=series.pk)
class LocationListView(ManagementPositionRequiredMixin, ListView):
template_name = "management/location_list.html"
context_object_name = "locations"
def get_queryset(self):
return Location.objects.filter(club=self.request.club)
class LocationCreateView(ManagementPositionRequiredMixin, CreateView):
model = Location
form_class = LocationForm
template_name = "management/location_form.html"
def form_valid(self, form):
response = super().form_valid(form)
body = _("%(location)s” created.") % {"location": self.object}
notify(self.request, f"s|{_('Location created')}|{body}")
return response
def get_success_url(self):
return reverse("management:location_list")
class LocationUpdateView(ManagementPositionRequiredMixin, UpdateView):
model = Location
form_class = LocationForm
template_name = "management/location_form.html"
def get_queryset(self):
return Location.objects.filter(club=self.request.club)
def form_valid(self, form):
response = super().form_valid(form)
body = _("%(location)s” updated.") % {"location": self.object}
notify(self.request, f"s|{_('Location updated')}|{body}")
return response
def get_success_url(self):
return reverse("management:location_list")
def get_context_data(self, **kwargs):
return super().get_context_data(update_view=True, **kwargs)
class LocationDeleteView(ManagementPositionRequiredMixin, View):
def post(self, request, pk):
location = get_object_or_404(Location.objects.filter(club=request.club), pk=pk)
name = str(location)
# Event/EventSeries.location is SET_NULL -- no ProtectedError to catch.
location.delete()
body = _("%(location)s” has been deleted.") % {"location": name}
notify(request, f"w|{_('Location deleted')}|{body}")
return redirect("management:location_list")
class OpponentListView(ManagementPositionRequiredMixin, ListView):
template_name = "management/opponent_list.html"
context_object_name = "opponents"
def get_queryset(self):
return Opponent.objects.filter(club=self.request.club)
class OpponentCreateView(ManagementPositionRequiredMixin, CreateView):
model = Opponent
form_class = OpponentForm
template_name = "management/opponent_form.html"
def form_valid(self, form):
response = super().form_valid(form)
body = _("%(opponent)s” created.") % {"opponent": self.object}
notify(self.request, f"s|{_('Opponent created')}|{body}")
return response
def get_success_url(self):
return reverse("management:opponent_list")
class OpponentUpdateView(ManagementPositionRequiredMixin, UpdateView):
model = Opponent
form_class = OpponentForm
template_name = "management/opponent_form.html"
def get_queryset(self):
return Opponent.objects.filter(club=self.request.club)
def form_valid(self, form):
response = super().form_valid(form)
body = _("%(opponent)s” updated.") % {"opponent": self.object}
notify(self.request, f"s|{_('Opponent updated')}|{body}")
return response
def get_success_url(self):
return reverse("management:opponent_list")
def get_context_data(self, **kwargs):
return super().get_context_data(update_view=True, **kwargs)
class OpponentDeleteView(ManagementPositionRequiredMixin, View):
def post(self, request, pk):
opponent = get_object_or_404(Opponent.objects.filter(club=request.club), pk=pk)
name = str(opponent)
# Event/EventSeries.opponent is SET_NULL -- no ProtectedError to catch.
opponent.delete()
body = _("%(opponent)s” has been deleted.") % {"opponent": name}
notify(request, f"w|{_('Opponent deleted')}|{body}")
return redirect("management:opponent_list")
class SponsorListView(ClubAdminRequiredMixin, ListView):
"""Sponsors are a business/revenue relationship, same bucket as
memberships/roles/shop -- admin-only, unlike Location/Opponent which any
management position can maintain."""
template_name = "management/sponsor_list.html"
context_object_name = "sponsors"
def get_queryset(self):
return Sponsor.objects.filter(club=self.request.club)
class SponsorCreateView(ClubAdminRequiredMixin, CreateView):
model = Sponsor
form_class = SponsorForm
template_name = "management/sponsor_form.html"
def form_valid(self, form):
response = super().form_valid(form)
body = _("%(sponsor)s” created.") % {"sponsor": self.object}
notify(self.request, f"s|{_('Sponsor created')}|{body}")
return response
def get_success_url(self):
return reverse("management:sponsor_list")
class SponsorUpdateView(ClubAdminRequiredMixin, UpdateView):
model = Sponsor
form_class = SponsorForm
template_name = "management/sponsor_form.html"
def get_queryset(self):
return Sponsor.objects.filter(club=self.request.club)
def form_valid(self, form):
response = super().form_valid(form)
body = _("%(sponsor)s” updated.") % {"sponsor": self.object}
notify(self.request, f"s|{_('Sponsor updated')}|{body}")
return response
def get_success_url(self):
return reverse("management:sponsor_list")
def get_context_data(self, **kwargs):
return super().get_context_data(update_view=True, **kwargs)
class SponsorDeleteView(ClubAdminRequiredMixin, View):
def post(self, request, pk):
sponsor = get_object_or_404(Sponsor.objects.filter(club=request.club), pk=pk)
name = str(sponsor)
sponsor.delete()
body = _("%(sponsor)s” has been deleted.") % {"sponsor": name}
notify(request, f"w|{_('Sponsor deleted')}|{body}")
return redirect("management:sponsor_list")
class ProductListView(FeatureRequiredMixin, StubListMixin, ListView):
feature_flag = "shop"
page_title = _("Products")
def get_queryset(self):
return Product.objects.filter(club=self.request.club)
class OrderListView(FeatureRequiredMixin, StubListMixin, ListView):
feature_flag = "shop"
page_title = _("Orders")
def get_queryset(self):
return Order.objects.filter(club=self.request.club)
class DiscountListView(FeatureRequiredMixin, StubListMixin, ListView):
feature_flag = "shop"
page_title = _("Discounts")
def get_queryset(self):
return Discount.objects.filter(club=self.request.club)
class InvoiceListView(FeatureRequiredMixin, StubListMixin, ListView):
feature_flag = "shop"
page_title = _("Invoices")
def get_queryset(self):
return Invoice.objects.filter(club=self.request.club)
class FormListView(FeatureRequiredMixin, StubListMixin, ListView):
feature_flag = "formbuilder"
page_title = _("Forms")
def get_queryset(self):
return FormBuilderForm.objects.filter(club=self.request.club)
class SubmissionListView(FeatureRequiredMixin, StubListMixin, ListView):
feature_flag = "formbuilder"
page_title = _("Submissions")
def get_queryset(self):
return Submission.objects.filter(form__club=self.request.club, form_id=self.kwargs["pk"])