Add the management app: club-facing UI + real fee-payment tracking

Gives clubs a self-service /manage/ area for members, families, teams,
roles, and season memberships, alongside real fee-payment tracking
(FeePayment, record_payment/mark_as_paid/remaining_balance) so a
membership's paid status reflects actual money received instead of a
single manually-set flag.
This commit is contained in:
2026-08-03 17:01:15 +02:00
parent 5bea8a4a6c
commit 062da00bb9
44 changed files with 5095 additions and 11 deletions

0
management/__init__.py Normal file
View File

5
management/apps.py Normal file
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class ManagementConfig(AppConfig):
name = "management"

163
management/bulk_import.py Normal file
View File

@@ -0,0 +1,163 @@
"""Mass-uploading members from an Excel template: extracting a workbook into plain
row data, and validating that data into what will be created.
Kept as two separate functions so binary parsing happens exactly once (at upload
time) while validation -- the part that must behave identically whether it's
building the preview or actually creating records -- runs against plain data both
times (see MemberImportView / MemberImportConfirmView in management/views.py).
"""
from datetime import date, datetime
import openpyxl
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from openpyxl.worksheet.datavalidation import DataValidation
from club.models import ClubMembership
from members.models import Member
from .forms import MemberForm
TEMPLATE_COLUMNS = ["first_name", "last_name", "date_of_birth", "email", "phone", "emergency_phone", "license", "status", "fee_status"]
REQUIRED_HEADER_COLUMNS = {"first_name", "last_name"}
TEMPLATE_EXAMPLE_ROW = ["Alex", "Morgan", date(2012, 5, 14), "alex.morgan@example.com", "+32470123456", "+32470654321", "", ClubMembership.StatusChoices.ACTIVE, ClubMembership.FeeStatus.UNPAID]
def build_member_import_template():
"""The downloadable .xlsx: header row, one example row, and a dropdown on the
status/fee_status columns so a cell can't be typo'd into an invalid value."""
workbook = openpyxl.Workbook()
sheet = workbook.active
sheet.title = "Members"
sheet.append(TEMPLATE_COLUMNS)
for cell in sheet[1]:
cell.font = openpyxl.styles.Font(bold=True)
sheet.freeze_panes = "A2"
sheet.append(TEMPLATE_EXAMPLE_ROW)
for column_name, choices in (("status", ClubMembership.StatusChoices), ("fee_status", ClubMembership.FeeStatus)):
column_index = TEMPLATE_COLUMNS.index(column_name) + 1
column_letter = sheet.cell(row=1, column=column_index).column_letter
options = ",".join(choices.values)
validation = DataValidation(type="list", formula1=f'"{options}"', allow_blank=True)
sheet.add_data_validation(validation)
validation.add(f"{column_letter}2:{column_letter}1000")
for column_index, column_name in enumerate(TEMPLATE_COLUMNS, start=1):
sheet.column_dimensions[sheet.cell(row=1, column=column_index).column_letter].width = max(12, len(column_name) + 2)
return workbook
def read_member_import_workbook(file):
"""Uploaded .xlsx -> list[dict], one dict per row keyed by TEMPLATE_COLUMNS.
Normalizes every cell to a plain str/None here: openpyxl returns typed cells
(a date-formatted cell comes back as a datetime.date, a phone number typed as
digits-only can come back as a number), so this is the one place that has to
deal with that -- everything downstream, including the session, only ever
sees plain strings.
"""
workbook = openpyxl.load_workbook(file, data_only=True)
sheet = workbook.active
header = [str(cell.value).strip() if cell.value is not None else "" for cell in next(sheet.iter_rows(min_row=1, max_row=1))]
if not REQUIRED_HEADER_COLUMNS.issubset(header):
missing = REQUIRED_HEADER_COLUMNS - set(header)
raise ValueError(_("This doesn't look like the template — missing column(s): %(columns)s.") % {"columns": ", ".join(sorted(missing))})
rows = []
for excel_row in sheet.iter_rows(min_row=2):
values = {}
for column_name, cell in zip(header, excel_row, strict=False):
if column_name not in TEMPLATE_COLUMNS:
continue
values[column_name] = _cell_to_str(cell.value)
if not any(values.values()):
continue # a fully blank row (trailing spreadsheet padding) isn't a row to import
rows.append(values)
return rows
def _cell_to_str(value):
if value is None:
return ""
if isinstance(value, datetime):
return value.date().isoformat()
if isinstance(value, date):
return value.isoformat()
if isinstance(value, float) and value.is_integer():
return str(int(value))
return str(value).strip()
def parse_member_import_rows(rows, club):
"""list[dict] (as returned by read_member_import_workbook) -> one result per
row: {line_number, raw, member, membership_kwargs, errors}. `member` is an
unsaved Member instance (ready to .save()) when the row is valid, else None."""
results = []
seen_emails = set()
for line_number, raw in enumerate(rows, start=2):
errors = []
member_fields = {key: raw.get(key, "") for key in ("first_name", "last_name", "date_of_birth", "email", "phone", "emergency_phone")}
form = MemberForm(data=member_fields)
member = None
if form.is_valid():
member = form.save(commit=False)
else:
for field_errors in form.errors.values():
errors.extend(field_errors)
email = member_fields["email"].strip()
if email:
if email.lower() in seen_emails:
errors.append(_("Duplicate email in this file."))
seen_emails.add(email.lower())
already_in_club = Member.objects.filter(member_of__club=club).filter(Q(email__iexact=email) | Q(user__email__iexact=email)).exists()
if already_in_club:
errors.append(_("Already a member of this club."))
membership_kwargs, status_fee_errors = _parse_membership_fields(raw)
errors.extend(status_fee_errors)
results.append({"line_number": line_number, "raw": raw, "member": member if not errors else None, "membership_kwargs": membership_kwargs, "errors": errors})
return results
def _parse_membership_fields(raw):
errors = []
license_number = raw.get("license", "").strip()
status = raw.get("status", "").strip()
if status:
status_value = _match_choice(status, ClubMembership.StatusChoices)
if status_value is None:
errors.append(_("Invalid status '%(value)s'.") % {"value": status})
else:
status_value = ClubMembership.StatusChoices.ACTIVE
fee_status = raw.get("fee_status", "").strip()
if fee_status:
fee_status_value = _match_choice(fee_status, ClubMembership.FeeStatus)
if fee_status_value is None:
errors.append(_("Invalid fee status '%(value)s'.") % {"value": fee_status})
else:
fee_status_value = ClubMembership.FeeStatus.UNPAID
return {"license": license_number, "status": status_value, "fee_status": fee_status_value}, errors
def _match_choice(value, choices):
for choice_value in choices.values:
if choice_value.lower() == value.lower():
return choice_value
return None

View File

@@ -0,0 +1,88 @@
"""Whether the signed-in user is a club ADMIN, for the management nav to hide
admin-only sections (seasons, positions, roles, shop, forms) from plain staff.
The underlying views are gated regardless (``ClubAdminRequiredMixin``) -- this is
purely so the nav doesn't show a link a coach or manager can't actually follow.
"""
from club.services.access import has_management_access, is_club_admin
#: Every management URL name, mapped to the nav item it should light up --
#: management/templates/management/_nav_items.html compares against this.
#: One dict here beats threading `nav=` through every view in views.py, and
#: unlike that, a new page can't silently be forgotten (it just renders with
#: no active item until added below, rather than needing every view touched).
_NAV_SECTIONS = {
"home": "home",
"member_list": "member_list",
"member_create": "member_list",
"member_import_template": "member_list",
"member_import": "member_list",
"member_import_confirm": "member_list",
"member_detail": "member_list",
"member_update": "member_list",
"member_delete": "member_list",
"member_attach_family": "member_list",
"member_grant_login": "member_list",
"member_detach_family": "member_list",
"family_create": "member_list",
"family_detail": "member_list",
"family_add_child": "member_list",
"family_add_parent": "member_list",
"family_membership_role_update": "member_list",
"membership_list": "membership_list",
"membership_mark_paid": "membership_list",
"membership_export_pdf": "membership_list",
"membership_mark_fully_paid": "membership_list",
"membership_record_payment": "membership_list",
"position_list": "position_list",
"role_list": "role_list",
"role_create": "role_list",
"role_revoke": "role_list",
"team_list": "team_list",
"team_create": "team_list",
"team_update": "team_list",
"team_detail": "team_list",
"roster_list": "roster_list",
"staff_list": "staff_list",
"event_list": "event_list",
"event_series_list": "event_series_list",
"location_list": "location_list",
"opponent_list": "opponent_list",
"product_list": "product_list",
"order_list": "order_list",
"discount_list": "discount_list",
"invoice_list": "invoice_list",
"form_list": "form_list",
"submission_list": "form_list",
}
def active_nav_section(request):
"""Which management nav item is currently active, derived from the resolved
URL name. Guarded on the "management" namespace so a same-named url_name in
some other app can never leak into this."""
match = request.resolver_match
if match is None or match.namespace != "management":
return {"nav": None}
return {"nav": _NAV_SECTIONS.get(match.url_name)}
def is_admin(request):
club = getattr(request, "club", None)
if club is None or not request.user.is_authenticated:
return {"is_club_admin": False}
return {"is_club_admin": is_club_admin(request.user, club)}
def management_link(request):
"""Whether to show a "Management" link in the global navbar (templates/_base.html),
next to the Django admin one -- only on a club subdomain, and only for someone with
real authority there (see has_management_access)."""
club = getattr(request, "club", None)
if club is None or not request.user.is_authenticated:
return {"has_management_access": False}
return {"has_management_access": has_management_access(request.user, club)}

144
management/forms.py Normal file
View File

@@ -0,0 +1,144 @@
from decimal import Decimal
from django import forms
from django.contrib.auth import get_user_model
from django.utils.translation import gettext_lazy as _
from club.models import ClubMembership, ClubRole, FeePayment
from members.models import Family, FamilyMembership, Member
from members.services.family import find_member_by_email
from teams.models import Team
User = get_user_model()
class MemberForm(forms.ModelForm):
class Meta:
model = Member
fields = ["first_name", "last_name", "date_of_birth", "email", "phone", "emergency_phone"]
widgets = {"date_of_birth": forms.DateInput(attrs={"type": "date"})}
class TeamForm(forms.ModelForm):
class Meta:
model = Team
fields = ["name", "short_name"]
class ClubRoleAssignForm(forms.ModelForm):
"""Grant a club-wide role to a member already affiliated with this club."""
class Meta:
model = ClubRole
fields = ["member", "role"]
def __init__(self, *args, club=None, **kwargs):
super().__init__(*args, **kwargs)
# Never list members of other clubs -- this isn't a platform-wide picker.
self.fields["member"].queryset = Member.objects.filter(member_of__club=club).distinct()
class FamilyCreateForm(forms.Form):
"""One new family in one go: a parent (who gets a login) and a child (who
doesn't). See members.services.family.register_family."""
parent_first_name = forms.CharField(label=_("Parent first name"))
parent_last_name = forms.CharField(label=_("Parent last name"))
parent_email = forms.EmailField(label=_("Parent email"), help_text=_("If this email has no account yet, one is created and they set a password via the reset link."))
child_first_name = forms.CharField(label=_("Child first name"))
child_last_name = forms.CharField(label=_("Child last name"))
child_date_of_birth = forms.DateField(label=_("Child date of birth"), required=False, widget=forms.DateInput(attrs={"type": "date"}))
class AddChildForm(forms.Form):
"""A family that needs one more child registered -- see
members.services.family.add_child_to_family."""
first_name = forms.CharField(label=_("First name"))
last_name = forms.CharField(label=_("Last name"))
date_of_birth = forms.DateField(label=_("Date of birth"), required=False, widget=forms.DateInput(attrs={"type": "date"}))
class AddParentForm(forms.Form):
"""A family that needs one more parent/guardian registered -- see
members.services.family.add_parent_to_family."""
email = forms.EmailField(label=_("Email address"), help_text=_("If this email has no account yet, one is created and they set a password via the reset link."))
first_name = forms.CharField(label=_("First name"), required=False)
last_name = forms.CharField(label=_("Last name"), required=False)
def clean(self):
cleaned = super().clean()
email = cleaned.get("email")
# Only a brand-new person needs a name; an existing member already has one.
if email and find_member_by_email(email) is None:
for field in ("first_name", "last_name"):
if not cleaned.get(field):
self.add_error(field, _("Required: this email has no account yet."))
return cleaned
class AttachToFamilyForm(forms.Form):
"""Link a standalone member into a family -- a new one, or an existing one they
turn out to belong to. See members.services.family.attach_to_family."""
role = forms.ChoiceField(label=_("Role"), choices=FamilyMembership.FamilyRole.choices)
family = forms.ModelChoiceField(label=_("Family"), queryset=Family.objects.none(), required=False, empty_label=_("— start a new family —"))
def __init__(self, *args, club=None, member=None, **kwargs):
super().__init__(*args, **kwargs)
# Same scoping query as management.views.families_of_club -- inlined rather
# than imported, since that function lives in views.py, which imports this
# module (a module-level import back here would be circular).
queryset = Family.objects.filter(memberships__member__member_of__club=club).distinct()
if member is not None:
# Already a member of it -- offering it again would be a no-op re-add.
queryset = queryset.exclude(memberships__member=member)
self.fields["family"].queryset = queryset
class MemberImportUploadForm(forms.Form):
"""The mass-upload entry point -- one .xlsx file, built from the downloadable
template. See management.bulk_import.read_member_import_workbook."""
file = forms.FileField(label=_("Excel file"), help_text=_("Use the downloaded template — one row per member."))
class GrantLoginForm(forms.Form):
"""A login-less family member (a child, typically) getting their own account --
see members.services.family.grant_login. Pre-filled from the member's contact
email where one is already on file; still editable, and required either way."""
email = forms.EmailField(label=_("Email"), help_text=_("They'll set a password via the reset link the first time they sign in."))
def clean_email(self):
email = self.cleaned_data["email"]
if User.objects.filter(email__iexact=email).exists():
raise forms.ValidationError(_("This email is already in use."))
return email
class ClubMembershipForm(forms.ModelForm):
"""This season's standing -- shown and edited right on the member's own page,
since a Member has no club of its own without one. fee_amount is the only
money field here -- amount_paid is exclusively written by club.services.fees,
never hand-edited."""
class Meta:
model = ClubMembership
fields = ["license", "status", "fee_status", "fee_amount"]
class RecordFeePaymentForm(forms.Form):
"""Money received against one membership's fee -- see club.services.fees.record_payment.
Reusable for any amount, partial or the exact remaining balance; "Mark fully
paid" (management.views.MembershipMarkFullyPaidView) skips this form entirely
and settles the balance directly in one click."""
amount = forms.DecimalField(label=_("Amount"), max_digits=10, decimal_places=2, min_value=Decimal("0.01"))
method = forms.ChoiceField(label=_("Method"), choices=FeePayment.Method.choices)
reference = forms.CharField(label=_("Reference"), required=False, help_text=_("Bank reference, transaction id — whatever lets you find this again."))
note = forms.CharField(label=_("Note"), required=False, widget=forms.Textarea(attrs={"rows": 2}))

30
management/pdf.py Normal file
View File

@@ -0,0 +1,30 @@
"""HTML-to-PDF rendering for exports (currently just the memberships list).
Same lazy-import shape as billing.services.invoices.render_pdf -- WeasyPrint binds
to native pango/cairo libraries, and a machine without them must still be able to
run the app, the tests and every other page, so the import happens here, not at
module scope, and only fails when someone actually asks for a PDF. Kept separate
from billing's version rather than shared: the two exports have nothing else in
common, and sharing would make one app depend on the other's error type for no
real benefit.
"""
from django.template.loader import render_to_string
class PDFExportError(Exception):
"""Raised when WeasyPrint's native libraries aren't available."""
def render_pdf(html: str) -> bytes:
try:
from weasyprint import HTML
except (ImportError, OSError) as error:
raise PDFExportError("PDF rendering needs the native pango/cairo libraries (on macOS: brew install pango).") from error
return HTML(string=html).write_pdf()
def membership_list_pdf(context: dict) -> bytes:
html = render_to_string("management/membership_list_pdf.html", context)
return render_pdf(html)

View File

@@ -0,0 +1,92 @@
{% load lucide ui i18n %}
{% comment %}
Shared by family_detail.html and member_detail.html's own Family panel -- one
table design for "everyone in this family", included with `group` (a dict from
management.views.group_by_family: family/guardians/children/others/all) and
`family_role_choices` (FamilyMembership.FamilyRole.choices, for the role dropdown).
`next_url`, if passed, is where a role change redirects back to -- member_detail.html
passes its own URL so admins land back on the member they were viewing; family_detail.html
leaves it unset, since staying on the family page is already the right place there.
{% endcomment %}
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "Last name" %}</th>
<th>{% trans "First name" %}</th>
<th>{% trans "Email" %}</th>
<th>{% trans "Type" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for person in group.all %}
<tr>
<td><a class="link link-hover" href="{% url 'management:member_detail' person.pk %}">{{ person.last_name }}</a></td>
<td>{{ person.first_name }}</td>
<td>{{ person.contact_email|default:"-" }}</td>
<td>
{% if is_club_admin %}
<form method="post" action="{% url 'management:family_membership_role_update' group.family.pk person.pk %}">
{% csrf_token %}
{% if next_url %}<input type="hidden" name="next" value="{{ next_url }}">{% endif %}
<select name="role" class="select select-bordered select-sm" onchange="this.form.requestSubmit()" aria-label="{% trans 'Role in family' %}">
{% for value, label in family_role_choices %}
<option value="{{ value }}" {% if value == person.role_in_family %}selected{% endif %}>{{ label|capfirst }}</option>
{% endfor %}
</select>
</form>
{% else %}
<span class="badge badge-sm badge-ghost">{{ person.role_in_family_display|capfirst }}</span>
{% endif %}
</td>
<td class="text-right">
{% if is_club_admin %}
<div class="flex justify-end gap-1">
{% if person.grant_login_form %}
<button class="btn btn-sm btn-outline btn-neutral" type="button" onclick="document.getElementById('grant_login_modal_{{ group.family.pk }}_{{ person.pk }}').showModal()" aria-label="{% trans 'Grant login' %}">
{% lucide "key-round" size=14 %} {% trans "Grant login" %}
</button>
{% endif %}
<a class="btn btn-sm btn-outline btn-neutral" href="{% url 'management:member_detail' person.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('remove_family_modal_{{ group.family.pk }}_{{ person.pk }}').showModal()" aria-label="{% trans 'Remove from family' %}">
{% lucide "user-x" size=14 %} {% trans "Remove" %}
</button>
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('member_delete_modal_{{ group.family.pk }}_{{ person.pk }}').showModal()" aria-label="{% trans 'Delete' %}">
{% lucide "trash-2" size=14 %} {% trans "Delete" %}
</button>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if is_club_admin %}
{% trans "Delete member" as delete_member_title %}
{% trans "Delete" as delete_label %}
{% trans "Remove from family" as remove_from_family_title %}
{% trans "Remove" as remove_label %}
{% trans "Grant login" as grant_login_title %}
{% trans "Grant" as grant_login_submit_label %}
{% trans "They'll be able to sign in with this email once you confirm." as grant_login_blurb %}
{% with family_pk_str=group.family.pk|stringformat:"s" %}
{% for person in group.all %}
{% with person_pk_str=person.pk|stringformat:"s" %}
{% url 'management:member_delete' person.pk as member_delete_url %}
{% url 'management:member_detach_family' person.pk group.family.pk as detach_family_url %}
{% url 'management:member_grant_login' person.pk as grant_login_url %}
{% blocktrans with full_name=person.get_full_name asvar delete_member_body %}Delete {{ full_name }}? This also removes their club membership, roster spots, staff assignments, and family links. This cannot be undone.{% endblocktrans %}
{% blocktrans with full_name=person.get_full_name family_name=group.family asvar detach_family_body %}Remove {{ full_name }} from family {{ family_name }}? They become a standalone member - nothing else about them changes.{% endblocktrans %}
{% include "controlpanel/_confirm_modal.html" with modal_id="member_delete_modal_"|add:family_pk_str|add:"_"|add:person_pk_str title=delete_member_title body=delete_member_body action_url=member_delete_url submit_label=delete_label %}
{% include "controlpanel/_confirm_modal.html" with modal_id="remove_family_modal_"|add:family_pk_str|add:"_"|add:person_pk_str title=remove_from_family_title body=detach_family_body action_url=detach_family_url submit_label=remove_label submit_icon="user-x" %}
{% if person.grant_login_form %}
{% include "controlpanel/_modal_form.html" with modal_id="grant_login_modal_"|add:family_pk_str|add:"_"|add:person_pk_str title=grant_login_title form=person.grant_login_form action_url=grant_login_url submit_label=grant_login_submit_label submit_icon="key-round" blurb=grant_login_blurb %}
{% endif %}
{% endwith %}
{% endfor %}
{% endwith %}
{% endif %}

View File

@@ -0,0 +1,33 @@
{% extends "management/base.html" %}
{% load i18n %}
{% comment %}
Shared placeholder for every entity that doesn't have its own list template yet
(see management.views.StubListMixin) -- a plain one-column table of __str__, just
enough to prove the query scoping and permission gate work. Not meant to stay this
bare once each section gets its own page.
{% endcomment %}
{% block heading %}{{ page_title }}{% endblock heading %}
{% block panel %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table">
<tbody>
{% for object in object_list %}
<tr>
<td>{{ object }}</td>
</tr>
{% empty %}
<tr>
<td class="text-center opacity-60">{% trans "Nothing here yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,49 @@
{% load i18n lucide %}
{% comment %}
The management nav, in one place: the sidebar renders it on a wide screen and the
collapsed menu renders it on a narrow one. Admin-only sections are hidden here for
plain staff -- the views are gated regardless (ClubAdminRequiredMixin), this is
just so the nav never shows a link they can't follow.
`nav` (management.context_processors.active_nav_section) is the current page's
section, derived from the resolved URL name -- `menu-active` is daisyUI's active
state, same convention as controlpanel/templates/controlpanel/_nav_items.html.
{% endcomment %}
<li>
<a class="{% if nav == 'home' %}menu-active{% endif %}" href="{% url 'management:home' %}">{% lucide "layout-dashboard" size=16 %} {% trans "Dashboard" %}</a>
</li>
<li class="menu-title">{% trans "People" %}</li>
<li><a class="{% if nav == 'member_list' %}menu-active{% endif %}" href="{% url 'management:member_list' %}">{% lucide "users" size=16 %} {% trans "Members" %}</a></li>
{% if is_club_admin %}
<li><a class="{% if nav == 'membership_list' %}menu-active{% endif %}" href="{% url 'management:membership_list' %}">{% lucide "wallet" size=16 %} {% trans "Memberships" %}</a></li>
{% endif %}
{% if is_club_admin %}
<li class="menu-title">{% trans "Club setup" %}</li>
<li><a class="{% if nav == 'position_list' %}menu-active{% endif %}" href="{% url 'management:position_list' %}">{% lucide "tags" size=16 %} {% trans "Positions" %}</a></li>
<li><a class="{% if nav == 'role_list' %}menu-active{% endif %}" href="{% url 'management:role_list' %}">{% lucide "shield-check" size=16 %} {% trans "Roles" %}</a></li>
{% endif %}
<li class="menu-title">{% trans "Teams" %}</li>
<li><a class="{% if nav == 'team_list' %}menu-active{% endif %}" href="{% url 'management:team_list' %}">{% lucide "shirt" size=16 %} {% trans "Teams" %}</a></li>
<li><a class="{% if nav == 'roster_list' %}menu-active{% endif %}" href="{% url 'management:roster_list' %}">{% lucide "clipboard-list" size=16 %} {% trans "Roster" %}</a></li>
<li><a class="{% if nav == 'staff_list' %}menu-active{% endif %}" href="{% url 'management:staff_list' %}">{% lucide "hard-hat" size=16 %} {% trans "Staff" %}</a></li>
<li class="menu-title">{% trans "Calendar" %}</li>
<li><a class="{% if nav == 'event_list' %}menu-active{% endif %}" href="{% url 'management:event_list' %}">{% lucide "calendar" size=16 %} {% trans "Events" %}</a></li>
<li><a class="{% if nav == 'event_series_list' %}menu-active{% endif %}" href="{% url 'management:event_series_list' %}">{% lucide "repeat" size=16 %} {% trans "Event series" %}</a></li>
<li><a class="{% if nav == 'location_list' %}menu-active{% endif %}" href="{% url 'management:location_list' %}">{% lucide "map-pin" size=16 %} {% trans "Locations" %}</a></li>
<li><a class="{% if nav == 'opponent_list' %}menu-active{% endif %}" href="{% url 'management:opponent_list' %}">{% lucide "swords" size=16 %} {% trans "Opponents" %}</a></li>
{% if is_club_admin %}
<li class="menu-title">{% trans "Shop" %}</li>
<li><a class="{% if nav == 'product_list' %}menu-active{% endif %}" href="{% url 'management:product_list' %}">{% lucide "package" size=16 %} {% trans "Products" %}</a></li>
<li><a class="{% if nav == 'order_list' %}menu-active{% endif %}" href="{% url 'management:order_list' %}">{% lucide "shopping-cart" size=16 %} {% trans "Orders" %}</a></li>
<li><a class="{% if nav == 'discount_list' %}menu-active{% endif %}" href="{% url 'management:discount_list' %}">{% lucide "percent" size=16 %} {% trans "Discounts" %}</a></li>
<li><a class="{% if nav == 'invoice_list' %}menu-active{% endif %}" href="{% url 'management:invoice_list' %}">{% lucide "receipt" size=16 %} {% trans "Invoices" %}</a></li>
<li class="menu-title">{% trans "Forms" %}</li>
<li><a class="{% if nav == 'form_list' %}menu-active{% endif %}" href="{% url 'management:form_list' %}">{% lucide "clipboard-list" size=16 %} {% trans "Forms" %}</a></li>
{% endif %}

View File

@@ -0,0 +1,42 @@
{% extends "_club_base.html" %}
{% load i18n lucide %}
{% comment %}
The club-staff shell: team managers, coaches and admins only (never parents or
players -- see club.mixins.ClubStaffRequiredMixin). Mirrors controlpanel/base.html's
block structure, extending the club's own skin instead of the platform's.
{% endcomment %}
{% block head_title %}
{% block section_title %}{% trans "Management" %}{% endblock section_title %}
{% endblock head_title %}
{% block menu %}
<aside class="hidden w-64 shrink-0 overflow-y-auto border-r border-base-300 bg-base-100 lg:block">
<ul class="menu w-full gap-1 p-3 mt-4">
{% include "management/_nav_items.html" %}
</ul>
</aside>
{% endblock menu %}
{% block main %}
<div class="mb-6 flex flex-wrap flex-row items-center justify-between gap-3">
<div class="flex flex-col gap-2 grow">
<h1 class="text-3xl font-bold">
{% block heading %}{% trans "Management" %}{% endblock heading %}
</h1>
<span class="text-sm text-base-content/50">{% block subheading %}{% endblock subheading %}</span>
</div>
<div class="flex gap-2">
{% block actions %}{% endblock actions %}
</div>
</div>
{# Below `lg` the sidebar is hidden, so the same links appear here rather than nowhere. #}
<ul class="menu menu-horizontal mb-6 w-full gap-1 overflow-x-auto rounded-box bg-base-100 lg:hidden">
{% include "management/_nav_items.html" %}
</ul>
{% block panel %}{% endblock panel %}
{% endblock main %}

View File

@@ -0,0 +1,31 @@
{% extends "management/base.html" %}
{% load lucide i18n %}
{% block heading %}{% blocktrans with name=family %}{{ name }} Family{% endblocktrans %}{% endblock heading %}
{% block actions %}
{% if is_club_admin %}
{% trans "Add parent" as add_parent_label %}
{% trans "Add child" as add_child_label %}
<button class="btn btn-outline btn-neutral gap-2" type="button" onclick="document.getElementById('add_parent_modal').showModal()">{% lucide "user-plus" size=16 %} {{ add_parent_label }}</button>
<button class="btn btn-outline btn-neutral gap-2" type="button" onclick="document.getElementById('add_child_modal').showModal()">{% lucide "baby" size=16 %} {{ add_child_label }}</button>
{% endif %}
{% endblock actions %}
{% block panel %}
<div class="card bg-base-100 shadow">
<div class="card-body">
{% include "management/_family_members_table.html" with group=group %}
</div>
</div>
{% if is_club_admin %}
{% url 'management:family_add_parent' family.pk as add_parent_url %}
{% url 'management:family_add_child' family.pk as add_child_url %}
{% trans "Add parent" as add_parent_title %}
{% trans "Add child" as add_child_title %}
{% trans "If this email has no account yet, one is created and they set a password via the reset link." as add_parent_blurb %}
{% include "controlpanel/_modal_form.html" with modal_id="add_parent_modal" title=add_parent_title form=add_parent_form action_url=add_parent_url submit_label=add_parent_title submit_icon="user-plus" blurb=add_parent_blurb %}
{% include "controlpanel/_modal_form.html" with modal_id="add_child_modal" title=add_child_title form=add_child_form action_url=add_child_url submit_label=add_child_title submit_icon="baby" %}
{% endif %}
{% endblock panel %}

View File

@@ -0,0 +1,43 @@
{% extends "management/base.html" %}
{% load lucide ui i18n %}
{% block heading %}{% trans "Add family" %}{% endblock heading %}
{% block panel %}
<div class="card w-full bg-base-100 shadow">
<div class="card-body">
<form method="post">
{% csrf_token %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
<h2 class="card-title text-base">{% lucide "user" size=18 %} {% trans "Parent / guardian" %}</h2>
<p class="text-sm opacity-70">{% trans "Gets a login -- they set a password via the reset link if this is a new email." %}</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% form_field form.parent_first_name %}
{% form_field form.parent_last_name %}
{% form_field form.parent_email %}
</div>
<div class="divider"></div>
<h2 class="card-title text-base">{% lucide "baby" size=18 %} {% trans "Child" %}</h2>
<p class="text-sm opacity-70">{% trans "No login of their own -- the parent above manages things for them." %}</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% form_field form.child_first_name %}
{% form_field form.child_last_name %}
{% form_field form.child_date_of_birth %}
</div>
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline btn-neutral gap-2" href="{% url "management:member_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>
</form>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,245 @@
{% extends "management/base.html" %}
{% load i18n static lucide %}
{% block heading %}{{ club.name }}{% endblock heading %}
{% block subheading %}{% trans "Management" %}{% endblock subheading %}
{% block panel %}
{% if attention.no_season %}
<div class="alert alert-warning mb-6">
{% lucide "calendar-x" size=20 %}
<span>
{% trans "No season covers today, so this club cannot take a signup or schedule a match. Nothing errors — it is simply inert." %}
</span>
</div>
{% endif %}
{% comment %}
The club's own numbers that should be zero -- same attention/chart/stat-group
data controlpanel/club_detail.html shows a platform admin drilling into this
club from outside; club_attention/club_charts/club_statistics are already
club-scoped, so this is that same data for the club's own staff. Nothing
money-shaped renders below for non-admins, same line the nav already draws
around the Shop section.
{% endcomment %}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-6">
<div class="card bg-base-100 shadow border-l-4 {% if attention.teams_without_manager %}border-error{% else %}border-success{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-x" size=16 %} {% trans "Teams without coach" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.teams_without_manager }}</div>
<div class="text-xs opacity-60">{% trans "Teams nobody can pick a squad for" %}</div>
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 {% if attention.unrostered %}border-warning{% else %}border-success{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-minus" size=16 %} {% trans "Unrostered members" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.unrostered }}</div>
<div class="text-xs opacity-60">{% trans "Active members on no team" %}</div>
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 {% if attention.pending_approvals %}border-warning{% else %}border-success{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "clock" size=16 %} {% trans "Pending" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.pending_approvals }}</div>
<div class="text-xs opacity-60">{% trans "Memberships awaiting approval" %}</div>
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 border-info">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "sparkles" size=16 %} {% trans "New members" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.new_members }}</div>
<div class="text-xs opacity-60">{% trans "First season at this club" %}</div>
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 {% if attention.renewal_rate is None %}border-info{% elif attention.renewal_rate < 30 %}border-error{% elif attention.renewal_rate < 65 %}border-warning{% else %}border-success{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "repeat" size=16 %} {% trans "Renewal rate" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">
{% if attention.renewal_rate is None %}
{% trans "N/A" %}
{% else %}
{{ attention.renewal_rate }}%
{% endif %}
</div>
<div class="text-xs opacity-60">
{% if attention.renewal_rate is None %}
{% trans "No previous season" %}
{% else %}
<progress class="progress w-full {% if attention.renewal_rate < 30 %}progress-error{% elif attention.renewal_rate < 65 %}progress-warning{% else %}progress-success{% endif %}" value="{{ attention.renewal_rate }}"
max="100"></progress>
{% endif %}
</div>
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 {% if attention.attendance.turnout is None %}border-info{% elif attention.attendance.turnout < 30 %}border-error{% elif attention.attendance.turnout < 65 %}border-warning{% else %}border-success{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-check" size=16 %} {% trans "Attendance rate" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">
{% if attention.attendance.turnout is None %}
{% trans "N/A" %}
{% else %}
{{ attention.attendance.turnout }}%
{% endif %}
</div>
<div class="text-xs opacity-60">
{% if attention.attendance.turnout is None %}
{% trans "No events this season" %}
{% else %}
<progress class="progress w-full {% if attention.attendance.turnout < 30 %}progress-error{% elif attention.attendance.turnout < 65 %}progress-warning{% else %}progress-success{% endif %}"
value="{{ attention.attendance.turnout }}" max="100"></progress>
{% endif %}
</div>
</div>
</div>
</div>
<div class="card bg-base-100 shadow mb-6">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% lucide "calendar" size=18 %} {% trans "Upcoming events" %}</h2>
<a class="btn btn-outline btn-neutral btn-sm gap-2" href="{% url 'management:event_list' %}">{% lucide "arrow-right" size=14 %} {% trans "View all" %}</a>
</div>
<div class="overflow-x-auto">
<table class="table">
<tbody>
{% for event in upcoming_events %}
<tr>
<td class="whitespace-nowrap">{{ event.start|date:"D j M, H:i" }}</td>
<td>{{ event.title }}</td>
<td>{{ event.get_kind_display|capfirst }}</td>
</tr>
{% empty %}
<tr>
<td class="text-center opacity-60">{% trans "Nothing scheduled." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="mb-6 grid gap-4 lg:grid-cols-2">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "user-plus" size=18 %} {% trans "Signups per month" %}</h2>
<p class="text-sm opacity-70">{% trans "New members against returning ones." %}</p>
<div class="h-56">
<canvas id="signups-chart"></canvas>
</div>
</div>
</div>
{% if is_club_admin %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "wallet" size=18 %} {% trans "Club fee status this season" %}</h2>
<div class="h-56">
<canvas id="fees-chart"></canvas>
</div>
</div>
</div>
{% endif %}
</div>
<div class="grid gap-4 md:grid-cols-4">
{% for group in groups %}
{% if group.title != "Shop" or is_club_admin %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide group.icon size=18 %} {{ group.title }}</h2>
<dl class="divide-y divide-base-200">
{% for label, value in group.stats %}
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{{ label }}</dt>
<dd class="font-semibold tabular-nums font-mono">{% if group.title == "Shop" and label == "Outstanding" or label == "Revenue" %}&euro;{% endif %}{{ value }}</dd>
</div>
{% endfor %}
</dl>
</div>
</div>
{% endif %}
{% endfor %}
</div>
{% endblock panel %}
{% block extra_body %}
{% trans "New" as new_label %}
{% trans "Returning" as returning_label %}
{{ charts|json_script:"chart-data" }}
<script src="{% static 'js/chart.js' %}"></script>
<script>
(() => {
const data = JSON.parse(document.getElementById("chart-data").textContent);
const css = (name, fallback) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
const feesCanvas = document.getElementById("fees-chart");
const render = () => {
const ink = css("--color-base-content", "#333");
const grid = "color-mix(in oklab, " + ink + " 15%, transparent)";
// Stacked: the bar height stays "signups this month" while the split shows where
// they came from. Side-by-side bars would answer a different question.
const signups = new Chart(document.getElementById("signups-chart"), {
type: "bar",
data: {
labels: data.signups.map((point) => point.month),
datasets: [
{label: "{{ new_label|escapejs }}", data: data.signups.map((point) => point.new), backgroundColor: css("--color-primary", "#4f46e5")},
{label: "{{ returning_label|escapejs }}", data: data.signups.map((point) => point.returning), backgroundColor: css("--color-accent", "#0ea5e9")},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {legend: {position: "bottom", labels: {color: ink}}},
scales: {
x: {stacked: true, ticks: {color: ink}, grid: {color: grid}},
y: {stacked: true, beginAtZero: true, ticks: {color: ink, precision: 0}, grid: {color: grid}},
},
},
});
const charts = [signups];
// Absent for non-admins -- the fee chart is financial, same gate as the Shop
// nav section.
if (feesCanvas) {
// Colour carries the meaning here — unpaid must read as a problem, waived
// must not — so the slices are pinned to the semantic theme colours, in order.
charts.push(new Chart(feesCanvas, {
type: "pie",
data: {
labels: data.fees.map((slice) => slice.label),
datasets: [
{
data: data.fees.map((slice) => slice.value),
backgroundColor: [css("--color-success", "#16a34a"), css("--color-warning", "#f59e0b"), css("--color-error", "#dc2626"), css("--color-neutral", "#6b7280")],
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {legend: {position: "right", labels: {color: ink}}},
},
}));
}
return charts;
};
let charts = render();
// "auto" removes data-theme entirely, so watch the attribute rather than a click.
new MutationObserver(() => {
charts.forEach((chart) => chart.destroy());
charts = render();
}).observe(document.documentElement, {attributes: true, attributeFilter: ["data-theme"]});
})();
</script>
{% endblock extra_body %}

View File

@@ -0,0 +1,157 @@
{% extends "management/base.html" %}
{% load lucide ui i18n %}
{% block heading %}{{ member.get_full_name }}{% endblock heading %}
{% block subheading %}{{ member.contact_email }}{% endblock subheading %}
{% block actions %}
{% if is_club_admin %}
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:member_update' member.pk %}">{% lucide "pencil" size=16 %} {% trans "Edit" %}</a>
{% endif %}
{% endblock actions %}
{% block panel %}
<div class="grid gap-4 lg:grid-cols-2">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "user" size=18 %} {% trans "Personal information" %}</h2>
<dl class="divide-y divide-base-200">
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Date of birth" %}</dt>
<dd class="font-semibold">{{ member.date_of_birth|default:"-" }}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Phone" %}</dt>
<dd class="font-semibold">{{ member.phone.as_international|default:"-" }}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Emergency phone" %}</dt>
<dd class="font-semibold">{{ member.emergency_phone.as_international|default:"-" }}</dd>
</div>
</dl>
<div class="flex flex-row gap-2 items-center mt-2">
{% if member.phone %}
<a class="btn btn-sm btn-outline btn-primary grow" href="tel:{{ member.phone.as_international }}">{% lucide "phone" size=14 %} {% trans "Call" %}</a>
{% endif %}
{% if member.emergency_phone %}
<a class="btn btn-sm btn-outline btn-error grow" href="tel:{{ member.emergency_phone.as_international }}">{% lucide "shield-alert" size=14 %} {% trans "Emergency call" %}</a>
{% endif %}
</div>
</div>
</div>
{% comment %}
A Member has no club of its own -- ClubMembership is the only thing that
actually ties this person to *this* club, so it gets its own panel: the
current season's standing, plus every season they've been part of.
{% endcomment %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "id-card" size=18 %} {% trans "Club membership" %}</h2>
{% if current_membership %}
<dl class="divide-y divide-base-200">
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "License" %}</dt>
<dd class="font-semibold">{{ current_membership.license|default:"-" }}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Status" %}</dt>
<dd class="font-semibold">{{ current_membership.get_status_display }}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Fee status" %}</dt>
<dd class="font-semibold">{{ current_membership.get_fee_status_display }}</dd>
</div>
</dl>
{% else %}
<p class="text-sm opacity-60">{% trans "Not rostered for the current season." %}</p>
{% endif %}
{% if membership_history %}
<div class="divider"></div>
<h3 class="text-sm font-semibold opacity-70">{% trans "Season history" %}</h3>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "Season" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Fee status" %}</th>
<th>{% trans "License" %}</th>
</tr>
</thead>
<tbody>
{% for membership in membership_history %}
<tr>
<td>{{ membership.season.start_date|date:"Y" }} - {{ membership.season.end_date|date:"Y" }}</td>
<td>{{ membership.get_status_display }}</td>
<td>{{ membership.get_fee_status_display }}</td>
<td>{{ membership.license|default:"-" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
</div>
{% trans "Add parent" as add_parent_label %}
{% trans "Add child" as add_child_label %}
{% trans "If this email has no account yet, one is created and they set a password via the reset link." as add_parent_blurb %}
{% blocktrans with short_name=member.get_short_name asvar remove_from_family_label %}Remove {{ short_name }} from family{% endblocktrans %}
{% trans "Remove from family" as remove_from_family_title %}
{% trans "Remove" as remove_label %}
{% for family_group in family_groups %}
<div class="card bg-base-100 shadow mt-4">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">
{% lucide "users" size=18 %}
<a class="link link-hover" href="{% url 'management:family_detail' family_group.family.pk %}">{{ family_group.family }}</a>
</h2>
{% if is_club_admin %}
<div class="flex gap-2">
<button class="btn btn-outline btn-neutral btn-sm gap-2" type="button" onclick="document.getElementById('{{ family_group.family.pk|dom_id:"add_parent_modal" }}').showModal()">{% lucide "user-plus" size=14 %} {{ add_parent_label }}</button>
<button class="btn btn-outline btn-neutral btn-sm gap-2" type="button" onclick="document.getElementById('{{ family_group.family.pk|dom_id:"add_child_modal" }}').showModal()">{% lucide "baby" size=14 %} {{ add_child_label }}</button>
<button class="btn btn-outline btn-error btn-sm gap-2" type="button" onclick="document.getElementById('{{ family_group.family.pk|dom_id:"detach_family_modal" }}').showModal()">{% lucide "user-x" size=14 %} {{ remove_from_family_label }}</button>
</div>
{% endif %}
</div>
{% include "management/_family_members_table.html" with group=family_group next_url=request.path %}
</div>
</div>
{% if is_club_admin %}
{% url 'management:family_add_parent' family_group.family.pk as add_parent_url %}
{% url 'management:family_add_child' family_group.family.pk as add_child_url %}
{% url 'management:member_detach_family' member.pk family_group.family.pk as detach_family_url %}
{% blocktrans with full_name=member.get_full_name family_name=family_group.family asvar detach_family_body %}Remove {{ full_name }} from {{ family_name }}? They become a standalone member -- nothing else about them changes.{% endblocktrans %}
{% include "controlpanel/_modal_form.html" with modal_id=family_group.family.pk|dom_id:"add_parent_modal" title=add_parent_label form=add_parent_form action_url=add_parent_url submit_label=add_parent_label submit_icon="user-plus" blurb=add_parent_blurb %}
{% include "controlpanel/_modal_form.html" with modal_id=family_group.family.pk|dom_id:"add_child_modal" title=add_child_label form=add_child_form action_url=add_child_url submit_label=add_child_label submit_icon="baby" %}
{% include "controlpanel/_confirm_modal.html" with modal_id=family_group.family.pk|dom_id:"detach_family_modal" title=remove_from_family_title body=detach_family_body action_url=detach_family_url submit_label=remove_label submit_icon="user-x" %}
{% endif %}
{% endfor %}
{% if is_club_admin %}
<div class="card bg-base-100 shadow mt-4">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% lucide "users" size=18 %} {% trans "Family" %}</h2>
<button class="btn btn-outline btn-neutral btn-sm gap-2" type="button" onclick="document.getElementById('attach_family_modal').showModal()">{% lucide "user-plus" size=14 %} {% trans "Add to family" %}</button>
</div>
{% if not family_groups %}
<p class="text-sm opacity-60">{% trans "Not part of a family." %}</p>
{% endif %}
</div>
</div>
{% url 'management:member_attach_family' member.pk as attach_family_url %}
{% trans "Add to family" as add_to_family_title %}
{% trans "Add" as add_label %}
{% trans "Pick an existing family, or leave it blank to start a new one." as attach_family_blurb %}
{% include "controlpanel/_modal_form.html" with modal_id="attach_family_modal" title=add_to_family_title form=attach_to_family_form action_url=attach_family_url submit_label=add_label submit_icon="user-plus" blurb=attach_family_blurb %}
{% endif %}
{% endblock panel %}

View File

@@ -0,0 +1,62 @@
{% extends "management/base.html" %}
{% load lucide ui i18n %}
{% block heading %}{% if update_view %}{% blocktrans with name=object %}Edit {{ name }}{% endblocktrans %}{% else %}{% trans "New member" %}{% endif %}{% endblock heading %}
{% block actions %}
{% if update_view %}
<button class="btn btn-outline btn-error gap-2" type="button" onclick="document.getElementById('member_delete_modal').showModal()">{% lucide "trash-2" size=16 %} {% trans "Delete" %}</button>
{% endif %}
{% endblock actions %}
{% block panel %}
<div class="card w-full bg-base-100 shadow">
<div class="card-body">
<form method="post">
{% csrf_token %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% for field in form %}
{% form_field field %}
{% endfor %}
</div>
{% if membership_form %}
<div class="divider"></div>
<h2 class="card-title text-base">{% lucide "id-card" size=18 %} {% trans "This season" %}</h2>
{% for error in membership_form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% for field in membership_form %}
{% form_field field %}
{% endfor %}
</div>
{% endif %}
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline btn-neutral gap-2" href="{% if update_view %}{% url "management:member_detail" object.pk %}{% else %}{% url "management:member_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>
</form>
</div>
</div>
{% if update_view %}
{% url 'management:member_delete' object.pk as member_delete_url %}
{% trans "Delete member" as delete_member_title %}
{% trans "Delete" as delete_label %}
{% blocktrans with full_name=object.get_full_name asvar delete_member_body %}Delete {{ full_name }}? This also removes their club membership, roster spots, staff assignments, and family links. This cannot be undone.{% endblocktrans %}
{% include "controlpanel/_confirm_modal.html" with modal_id="member_delete_modal" title=delete_member_title body=delete_member_body action_url=member_delete_url submit_label=delete_label %}
{% endif %}
{% endblock panel %}

View File

@@ -0,0 +1,39 @@
{% extends "management/base.html" %}
{% load lucide ui i18n %}
{% block heading %}{% trans "Mass upload members" %}{% endblock heading %}
{% block actions %}
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:member_import_template' %}">{% lucide "download" size=16 %} {% trans "Download template" %}</a>
{% endblock actions %}
{% block panel %}
<div class="card w-full bg-base-100 shadow">
<div class="card-body">
<p class="text-sm opacity-70">
{% blocktrans %}Fill in the downloaded template — one row per member — then upload it
here. Nothing is created yet: you'll see exactly what will be added
before anything is saved.{% endblocktrans %}
</p>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
{% for field in form %}
{% form_field field %}
{% endfor %}
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:member_list' %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "upload" size=16 %} {% trans "Upload and preview" %}</button>
</div>
</form>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,68 @@
{% extends "management/base.html" %}
{% load lucide i18n %}
{% block heading %}{% trans "Review import" %}{% endblock heading %}
{% block panel %}
{% if not season %}
<div class="alert alert-warning mb-4">
{% lucide "triangle-alert" size=16 %}
<span>{% trans "No active season — members will still be created, but won't be rostered for one until it exists." %}</span>
</div>
{% endif %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<p class="text-sm opacity-70">
{% blocktrans count counter=valid_count %}{{ counter }} member will be created.{% plural %}{{ counter }} members will be created.{% endblocktrans %}
{% if skipped_count %}{% blocktrans count counter=skipped_count %}{{ counter }} row will be skipped.{% plural %}{{ counter }} rows will be skipped.{% endblocktrans %}{% endif %}
</p>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "Row" %}</th>
<th>{% trans "Last name" %}</th>
<th>{% trans "First name" %}</th>
<th>{% trans "Email" %}</th>
<th>{% trans "Outcome" %}</th>
</tr>
</thead>
<tbody>
{% for result in results %}
<tr>
<td>{{ result.line_number }}</td>
<td>{{ result.raw.last_name }}</td>
<td>{{ result.raw.first_name }}</td>
<td>{{ result.raw.email }}</td>
<td>
{% if result.member %}
<span class="badge badge-success badge-sm">{% trans "Will create" %}</span>
{% else %}
<span class="badge badge-error badge-sm">{% trans "Skipped" %}</span>
<div class="text-xs opacity-70 mt-1">{{ result.errors|join:"; " }}</div>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center opacity-60">{% trans "No rows found in the uploaded file." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:member_import' %}">{% lucide "arrow-left" size=16 %} {% trans "Back" %}</a>
{% if valid_count %}
<form method="post" action="{% url 'management:member_import_confirm' %}">
{% csrf_token %}
<button class="btn btn-primary gap-2" type="submit">{% lucide "check" size=16 %} {% blocktrans %}Confirm import ({{ valid_count }}){% endblocktrans %}</button>
</form>
{% endif %}
</div>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,102 @@
{% extends "management/base.html" %}
{% load lucide ui i18n %}
{% block heading %}{% trans "Members" %}{% endblock heading %}
{% block actions %}
<a class="btn btn-outline btn-info gap-2" href="{% url 'management:member_import_template' %}" xmlns:management="http://www.w3.org/1999/xhtml">{% lucide "download" size=16 %} {% trans "Download upload template" %}</a>
{% if is_club_admin %}
<a class="btn btn-outline btn-info gap-2" href="{% url 'management:member_import' %}">{% lucide "upload" size=16 %} {% trans "Mass upload" %}</a>
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:family_create' %}">{% lucide "users" size=16 %} {% trans "Add family" %}</a>
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:member_create' %}">{% lucide "user-plus" size=16 %} {% trans "Add member" %}</a>
{% endif %}
{% endblock actions %}
{% block panel %}
<form method="get" class="mb-4 flex gap-2">
<label class="input">
<span class="opacity-50">{% lucide "search" size=16 %}</span>
<input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Search members ...' %}" class="input input-bordered w-full max-w-xs">
</label>
<button class="btn btn-outline btn-neutral gap-2" type="submit">{% lucide "search" size=16 %} {% trans "Search" %}</button>
{% if search %}
<a class="btn btn-neutral gap-2" href="{% url "management:member_list" %}">{% lucide "x" size=16 %} {% trans "Clear filter" %}</a>
{% endif %}
</form>
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "Last name" %}</th>
<th>{% trans "First name" %}</th>
<th>{% trans "Email" %}</th>
<th>{% trans "Family" %}</th>
<th>{% trans "Status" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for member in members %}
<tr>
<td><a class="link link-hover" href="{% url 'management:member_detail' member.pk %}">{{ member.last_name }}</a></td>
<td>{{ member.first_name }}</td>
<td>{{ member.contact_email|default:"-" }}</td>
<td>
{% if member.family_memberships_display %}
<div class="flex flex-col gap-1">
{% for fm in member.family_memberships_display %}
<div>
<a class="btn btn-sm btn-outline btn-neutral" href="{% url 'management:family_detail' fm.family.pk %}">{% lucide "users" size=14 %} {{ fm.family }} {% lucide "arrow-right" size=14 %}</a>
</div>
{% endfor %}
</div>
{% else %}
<span class="opacity-40">-</span>
{% endif %}
</td>
<td>
{% if member.current_membership %}
<span class="badge badge-sm
{% if member.current_membership.status == "active" %}badge-success
{% elif member.current_membership.status == "pending" %}badge-warning
{% elif member.current_membership.status == "cancelled" %}badge-error
{% else %}badge-neutral{% endif %}">
{{ member.current_membership.get_status_display }}
</span>
{% else %}
<span class="badge badge-sm badge-neutral">{% trans "unknown" %}</span>
{% endif %}
</td>
<td class="text-right">
{% if is_club_admin %}
<div class="flex justify-end gap-1">
<a class="btn btn-sm btn-outline btn-neutral" href="{% url 'management:member_detail' member.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ member.pk|dom_id:"member_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
</div>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" class="text-center opacity-60">{% trans "No members yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% if is_club_admin %}
{% trans "Delete member" as delete_member_title %}
{% trans "Delete" as delete_label %}
{% for member in members %}
{% url 'management:member_delete' member.pk as member_delete_url %}
{% blocktrans with full_name=member.get_full_name asvar delete_member_body %}Delete {{ full_name }}? This also removes their club membership, roster spots, staff assignments, and family links. This cannot be undone.{% endblocktrans %}
{% include "controlpanel/_confirm_modal.html" with modal_id=member.pk|dom_id:"member_delete_modal" title=delete_member_title body=delete_member_body action_url=member_delete_url submit_label=delete_label %}
{% endfor %}
{% endif %}
{% endblock panel %}

View File

@@ -0,0 +1,226 @@
{% extends "management/base.html" %}
{% load i18n lucide ui %}
{% block heading %}{% trans "Memberships" %}{% endblock heading %}
{% block subheading %}{% if current_season %}{% blocktrans %}Fee status for season{% endblocktrans %}{% endif %}{% endblock subheading %}
{% block actions %}
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:membership_export_pdf' %}?{{ request.GET.urlencode }}">{% lucide "file-down" size=16 %} {% trans "Export to PDF" %}</a>
{% endblock actions %}
{% block panel %}
{% if not current_season %}
<div class="alert alert-warning mb-6">
{% lucide "calendar-x" size=20 %}
<span>{% trans "No season covers today, so there's nothing to show fee status for yet." %}</span>
</div>
{% else %}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-6">
<div class="card bg-base-100 shadow border-l-4 border-info">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "users" size=16 %} {% trans "Registered" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_total }}</div>
<div class="text-xs opacity-60">{% trans "This season" %}</div>
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 border-success">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "circle-check" size=16 %} {% trans "Paid" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_paid }}</div>
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 {% if kpi_partial %}border-warning{% else %}border-success{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "circle-dashed" size=16 %} {% trans "Partially paid" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_partial }}</div>
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 {% if kpi_unpaid %}border-error{% else %}border-success{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "circle-x" size=16 %} {% trans "Unpaid" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_unpaid }}</div>
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 border-neutral">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "circle-check" size=16 %} {% trans "Waived" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_waived }}</div>
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 {% if kpi_paid_rate is None %}border-info{% elif kpi_paid_rate < 50 %}border-error{% elif kpi_paid_rate < 85 %}border-warning{% else %}border-success{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "percent" size=16 %} {% trans "Paid rate" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">
{% if kpi_paid_rate is None %}{% trans "N/A" %}{% else %}{{ kpi_paid_rate }}%{% endif %}
</div>
</div>
</div>
</div>
{% endif %}
<form method="get" class="mb-4">
<div class="flex flex-row items-center gap-2">
<label class="input">
<span class="opacity-50">{% lucide "search" size=16 %}</span>
<input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Search members ...' %}" class="input input-bordered">
</label>
<select name="season" class="select select-bordered">
{% for season in seasons %}
<option value="{{ season.pk }}" {% if season.pk == selected_season.pk %}selected{% endif %}>{% trans "Season" %} {{ season.start_date|date:"Y" }} - {{ season.end_date|date:"Y" }}</option>
{% endfor %}
</select>
<select name="fee_status" class="select select-bordered">
<option value="not_paid" {% if selected_fee_status == "not_paid" %}selected{% endif %}>{% trans "Not paid (unpaid or partially)" %}</option>
{% for value, label in fee_status_choices %}
<option value="{{ value }}" {% if value == selected_fee_status %}selected{% endif %}>{{ label|capfirst }}</option>
{% endfor %}
<option value="all" {% if selected_fee_status == "all" %}selected{% endif %}>{% trans "All" %}</option>
</select>
<select name="status" class="select select-bordered">
<option value="all" {% if selected_status == "all" %}selected{% endif %}>{% trans "All statuses" %}</option>
{% for value, label in status_choices %}
<option value="{{ value }}" {% if value == selected_status %}selected{% endif %}>{{ label|capfirst }}</option>
{% endfor %}
</select>
<select name="team" class="select select-bordered">
<option value="" {% if not selected_team %}selected{% endif %}>{% trans "All teams" %}</option>
{% for team in teams %}
<option value="{{ team.pk }}" {% if team.pk|stringformat:"s" == selected_team %}selected{% endif %}>{{ team }}</option>
{% endfor %}
</select>
<button class="btn btn-outline btn-neutral gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "Filter" %}</button>
<a class="btn btn-neutral gap-2" href="{% url 'management:membership_list' %}">{% lucide "x" size=16 %} {% trans "Reset" %}</a>
</div>
</form>
<form method="post" action="{% url 'management:membership_mark_paid' %}" id="membership-form">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between mb-2">
<span class="text-sm opacity-70 font-semibold">
{% blocktrans count counter=memberships|length %}{{ counter }} membership{% plural %}{{ counter }} memberships{% endblocktrans %}
</span>
<button class="btn btn-success btn-sm gap-2" type="submit">{% lucide "circle-check" size=14 %} {% trans "Mark selected as paid" %}</button>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th><input type="checkbox" class="checkbox" id="select-all-memberships"></th>
<th>{% trans "Last name" %}</th>
<th>{% trans "First name" %}</th>
<th>{% trans "Email" %}</th>
<th>{% trans "Family" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Fee status" %}</th>
<th>{% trans "Owed" %}</th>
<th>{% trans "Paid" %}</th>
<th>{% trans "License" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for membership in memberships %}
<tr>
<td><input type="checkbox" class="checkbox membership-row-checkbox" name="membership_ids" value="{{ membership.pk }}"></td>
<td><a class="link link-hover" href="{% url 'management:member_detail' membership.member.pk %}">{{ membership.member.last_name }}</a></td>
<td>{{ membership.member.first_name }}</td>
<td>{{ membership.member.contact_email|default:"-" }}</td>
<td>
{% if membership.member.family_memberships_display %}
{% for fm in membership.member.family_memberships_display %}
{{ fm.family }} {% if not forloop.last %},{% endif %}
{% endfor %}
{% else %}
<span class="opacity-40">-</span>
{% endif %}
</td>
<td>
<span class="badge badge-sm
{% if membership.status == "active" %}badge-success
{% elif membership.status == "pending" %}badge-warning
{% elif membership.status == "cancelled" %}badge-error
{% else %}badge-neutral{% endif %}">
{{ membership.get_status_display }}
</span>
</td>
<td>
<span class="badge badge-sm
{% if membership.fee_status == "paid" %}badge-success
{% elif membership.fee_status == "partially_paid" %}badge-warning
{% elif membership.fee_status == "unpaid" %}badge-error
{% else %}badge-neutral{% endif %}">
{{ membership.get_fee_status_display }}
</span>
</td>
<td class="tabular-nums">&euro; {{ membership.fee_amount }}</td>
<td class="tabular-nums">
&euro; {{ membership.amount_paid }}
{% if membership.record_payment_form and membership.fee_amount %}
<div class="text-xs opacity-60">{% blocktrans with remaining=membership.remaining_balance_display %}{{ remaining }} left{% endblocktrans %}</div>
{% endif %}
</td>
<td>{{ membership.license|default:"-" }}</td>
<td class="text-right">
{% if membership.record_payment_form %}
<div class="flex justify-end gap-1">
<button class="btn btn-sm btn-outline btn-neutral" type="button" onclick="document.getElementById('{{ membership.pk|dom_id:"record_payment_modal" }}').showModal()">
{% lucide "receipt" size=12 %} {% trans "Record payment" %}
</button>
<button class="btn btn-sm btn-outline btn-success" type="submit" form="{{ membership.pk|dom_id:"mark_fully_paid_form" }}">
{% lucide "circle-check" size=12 %} {% trans "Mark fully paid" %}
</button>
</div>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="11" class="text-center opacity-60">{% trans "Nobody matches these filters." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</form>
{% trans "Record payment" as record_payment_title %}
{% trans "Record" as record_payment_submit_label %}
{% for membership in memberships %}
{% if membership.record_payment_form %}
{# A standalone form, not nested inside the bulk form above -- its submit button lives in the table row and links back here via the form="..." attribute. #}
<form method="post" action="{% url 'management:membership_mark_fully_paid' membership.pk %}" id="{{ membership.pk|dom_id:"mark_fully_paid_form" }}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
</form>
{% url 'management:membership_record_payment' membership.pk as record_payment_url %}
{% blocktrans with name=membership.member asvar record_payment_blurb %}For {{ name }}. Any amount is fine -- partial payments accumulate until the fee is settled.{% endblocktrans %}
{% include "controlpanel/_modal_form.html" with modal_id=membership.pk|dom_id:"record_payment_modal" title=record_payment_title form=membership.record_payment_form action_url=record_payment_url submit_label=record_payment_submit_label submit_icon="receipt" blurb=record_payment_blurb %}
{% endif %}
{% endfor %}
{% endblock panel %}
{% block extra_body %}
<script>
(() => {
const selectAll = document.getElementById("select-all-memberships");
const rowCheckboxes = document.querySelectorAll(".membership-row-checkbox");
if (!selectAll) return;
selectAll.addEventListener("change", () => {
rowCheckboxes.forEach((checkbox) => { checkbox.checked = selectAll.checked; });
});
})();
</script>
{% endblock extra_body %}

View File

@@ -0,0 +1,99 @@
{% load i18n %}
{% comment %}
Rendered by WeasyPrint, not a browser: a standalone document with its own print
stylesheet, same convention as billing/templates/billing/invoice.html -- it
deliberately doesn't pull in app.css (daisyUI's dark theme/flex layouts mean
nothing on paper).
{% endcomment %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{% trans "Memberships" %}</title>
<style>
@page {
size: A4;
margin: 18mm;
@bottom-center {
content: "{{ club.name }} — {% trans "memberships" %} — " counter(page) " / " counter(pages);
font-size: 8pt;
color: #666;
}
}
body { font-family: sans-serif; font-size: 9.5pt; color: #111; }
h1 { font-size: 18pt; margin: 0 0 2mm; }
.muted { color: #666; }
.header { display: flex; justify-content: space-between; margin-bottom: 8mm; }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; font-size: 8pt; text-transform: uppercase; letter-spacing: 0.5pt; color: #666; border-bottom: 1px solid #999; padding: 2mm 2mm 1mm 0; }
td { padding: 1.5mm 2mm 1.5mm 0; border-bottom: 1px solid #eee; vertical-align: top; }
.badge { display: inline-block; padding: 0.5mm 2mm; border-radius: 2mm; font-size: 8pt; }
.badge-success { background: #dcfce7; color: #15803d; }
.badge-warning { background: #fef3c7; color: #92400e; }
.badge-error { background: #fee2e2; color: #b91c1c; }
.badge-neutral { background: #e5e7eb; color: #374151; }
</style>
</head>
<body>
<div class="header">
<div>
<h1>{{ club.name }}</h1>
<div class="muted">{% trans "Membership fee status" %}{% if selected_season %} — {{ selected_season }}{% endif %}</div>
</div>
<div class="muted">
{% blocktrans with generated=generated_at|date:"j F Y, H:i" %}Generated {{ generated }}{% endblocktrans %}<br>
{% blocktrans count counter=memberships|length %}{{ counter }} member{% plural %}{{ counter }} members{% endblocktrans %}
</div>
</div>
<table>
<thead>
<tr>
<th>{% trans "Last name" %}</th>
<th>{% trans "First name" %}</th>
<th>{% trans "Email" %}</th>
<th>{% trans "Family" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Fee status" %}</th>
<th>{% trans "Owed" %}</th>
<th>{% trans "Paid" %}</th>
<th>{% trans "License" %}</th>
</tr>
</thead>
<tbody>
{% for membership in memberships %}
<tr>
<td>{{ membership.member.last_name }}</td>
<td>{{ membership.member.first_name }}</td>
<td>{{ membership.member.contact_email|default:"—" }}</td>
<td>
{% for fm in membership.member.family_memberships_display %}{{ fm.family }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}
</td>
<td>
<span class="badge
{% if membership.status == "active" %}badge-success
{% elif membership.status == "pending" %}badge-warning
{% elif membership.status == "cancelled" %}badge-error
{% else %}badge-neutral{% endif %}">{{ membership.get_status_display }}</span>
</td>
<td>
<span class="badge
{% if membership.fee_status == "paid" %}badge-success
{% elif membership.fee_status == "partially_paid" %}badge-warning
{% elif membership.fee_status == "unpaid" %}badge-error
{% else %}badge-neutral{% endif %}">{{ membership.get_fee_status_display }}</span>
</td>
<td>{{ membership.fee_amount }}</td>
<td>{{ membership.amount_paid }}</td>
<td>{{ membership.license|default:"—" }}</td>
</tr>
{% empty %}
<tr>
<td colspan="9" class="muted">{% trans "Nobody matches these filters." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1,29 @@
{% extends "management/base.html" %}
{% load i18n lucide ui %}
{% block heading %}{% trans "Grant role" %}{% endblock heading %}
{% block panel %}
<div class="card w-full max-w-xl bg-base-100 shadow">
<div class="card-body">
<form method="post">
{% csrf_token %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
{% for field in form %}
{% form_field field %}
{% endfor %}
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline btn-neutral gap-2" href="{% url "management:role_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>
</form>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,44 @@
{% extends "management/base.html" %}
{% load i18n lucide %}
{% block heading %}{% trans "Roles" %}{% endblock heading %}
{% block actions %}
<a class="btn btn-primary gap-2" href="{% url 'management:role_create' %}">{% lucide "plus" size=16 %} {% trans "Grant role" %}</a>
{% endblock actions %}
{% block panel %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "Member" %}</th>
<th>{% trans "Role" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for role in roles %}
<tr>
<td>{{ role.member }}</td>
<td>{{ role.get_role_display }}</td>
<td class="text-right">
<form method="post" action="{% url 'management:role_revoke' role.pk %}">
{% csrf_token %}
<button class="btn btn-outline btn-error btn-sm gap-2" type="submit">{% lucide "x" size=14 %} {% trans "Revoke" %}</button>
</form>
</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-center opacity-60">{% trans "No roles granted yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,19 @@
{% extends "management/base.html" %}
{% load i18n lucide %}
{% block heading %}{{ team.name }}{% endblock heading %}
{% block subheading %}{{ team.short_name }}{% endblock subheading %}
{% block actions %}
{% if is_club_admin %}
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:team_update' team.pk %}">{% lucide "pencil" size=16 %} {% trans "Edit" %}</a>
{% endif %}
{% endblock actions %}
{% block panel %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<p class="opacity-70">{% trans "Roster and staff assignments for this team are managed from the Roster and Staff sections." %}</p>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,31 @@
{% extends "management/base.html" %}
{% load i18n lucide ui %}
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New team" %}{% endif %}{% endblock heading %}
{% block panel %}
<div class="card w-full bg-base-100 shadow">
<div class="card-body">
<form method="post">
{% csrf_token %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% for field in form %}
{% form_field field %}
{% endfor %}
</div>
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline btn-neutral gap-2" href="{% if update_view %}{% url "management:team_detail" object.pk %}{% else %}{% url "management:team_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>
</form>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,39 @@
{% extends "management/base.html" %}
{% load i18n lucide %}
{% block heading %}{% trans "Teams" %}{% endblock heading %}
{% block actions %}
{% if is_club_admin %}
<a class="btn btn-primary gap-2" href="{% url 'management:team_create' %}">{% lucide "plus" size=16 %} {% trans "New team" %}</a>
{% endif %}
{% endblock actions %}
{% block panel %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Short name" %}</th>
</tr>
</thead>
<tbody>
{% for team in teams %}
<tr>
<td><a class="link link-hover" href="{% url 'management:team_detail' team.pk %}">{{ team.name }}</a></td>
<td>{{ team.short_name }}</td>
</tr>
{% empty %}
<tr>
<td colspan="2" class="text-center opacity-60">{% trans "No teams yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock panel %}

1644
management/tests.py Normal file

File diff suppressed because it is too large Load Diff

56
management/urls.py Normal file
View File

@@ -0,0 +1,56 @@
from django.urls import path
from . import views
app_name = "management"
urlpatterns = [
path("", views.HomeView.as_view(), name="home"),
# People
path("members/", views.MemberListView.as_view(), name="member_list"),
path("memberships/", views.MembershipListView.as_view(), name="membership_list"),
path("memberships/mark-paid/", views.MembershipMarkPaidView.as_view(), name="membership_mark_paid"),
path("memberships/export/", views.MembershipExportPdfView.as_view(), name="membership_export_pdf"),
path("memberships/<uuid:pk>/mark-fully-paid/", views.MembershipMarkFullyPaidView.as_view(), name="membership_mark_fully_paid"),
path("memberships/<uuid:pk>/record-payment/", views.MembershipRecordPaymentView.as_view(), name="membership_record_payment"),
path("members/new/", views.MemberCreateView.as_view(), name="member_create"),
path("members/import/template/", views.MemberImportTemplateView.as_view(), name="member_import_template"),
path("members/import/", views.MemberImportView.as_view(), name="member_import"),
path("members/import/confirm/", views.MemberImportConfirmView.as_view(), name="member_import_confirm"),
path("members/<uuid:pk>/", views.MemberDetailView.as_view(), name="member_detail"),
path("members/<uuid:pk>/edit/", views.MemberUpdateView.as_view(), name="member_update"),
path("members/<uuid:pk>/delete/", views.MemberDeleteView.as_view(), name="member_delete"),
path("members/<uuid:pk>/attach-family/", views.MemberAttachToFamilyView.as_view(), name="member_attach_family"),
path("members/<uuid:pk>/grant-login/", views.MemberGrantLoginView.as_view(), name="member_grant_login"),
path("members/<uuid:pk>/detach-family/<uuid:family_pk>/", views.MemberDetachFromFamilyView.as_view(), name="member_detach_family"),
path("families/new/", views.FamilyCreateView.as_view(), name="family_create"),
path("families/<uuid:pk>/", views.FamilyDetailView.as_view(), name="family_detail"),
path("families/<uuid:pk>/add-child/", views.FamilyAddChildView.as_view(), name="family_add_child"),
path("families/<uuid:pk>/add-parent/", views.FamilyAddParentView.as_view(), name="family_add_parent"),
path("families/<uuid:family_pk>/members/<uuid:member_pk>/role/", views.FamilyMembershipRoleUpdateView.as_view(), name="family_membership_role_update"),
# Club setup (admin only)
path("positions/", views.PositionListView.as_view(), name="position_list"),
path("roles/", views.ClubRoleListView.as_view(), name="role_list"),
path("roles/new/", views.ClubRoleCreateView.as_view(), name="role_create"),
path("roles/<uuid:pk>/revoke/", views.ClubRoleRevokeView.as_view(), name="role_revoke"),
# Teams
path("teams/", views.TeamListView.as_view(), name="team_list"),
path("teams/new/", views.TeamCreateView.as_view(), name="team_create"),
path("teams/<uuid:pk>/", views.TeamDetailView.as_view(), name="team_detail"),
path("teams/<uuid:pk>/edit/", views.TeamUpdateView.as_view(), name="team_update"),
path("roster/", views.RosterListView.as_view(), name="roster_list"),
path("staff/", views.StaffListView.as_view(), name="staff_list"),
# Calendar
path("events/", views.EventListView.as_view(), name="event_list"),
path("event-series/", views.EventSeriesListView.as_view(), name="event_series_list"),
path("locations/", views.LocationListView.as_view(), name="location_list"),
path("opponents/", views.OpponentListView.as_view(), name="opponent_list"),
# Shop (admin only)
path("shop/products/", views.ProductListView.as_view(), name="product_list"),
path("shop/orders/", views.OrderListView.as_view(), name="order_list"),
path("shop/discounts/", views.DiscountListView.as_view(), name="discount_list"),
path("shop/invoices/", views.InvoiceListView.as_view(), name="invoice_list"),
# Forms
path("forms/", views.FormListView.as_view(), name="form_list"),
path("forms/<uuid:pk>/submissions/", views.SubmissionListView.as_view(), name="submission_list"),
]

966
management/views.py Normal file
View File

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