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:
0
management/__init__.py
Normal file
0
management/__init__.py
Normal file
5
management/apps.py
Normal file
5
management/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ManagementConfig(AppConfig):
|
||||
name = "management"
|
||||
163
management/bulk_import.py
Normal file
163
management/bulk_import.py
Normal 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
|
||||
88
management/context_processors.py
Normal file
88
management/context_processors.py
Normal 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
144
management/forms.py
Normal 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
30
management/pdf.py
Normal 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)
|
||||
92
management/templates/management/_family_members_table.html
Normal file
92
management/templates/management/_family_members_table.html
Normal 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 %}
|
||||
33
management/templates/management/_generic_list.html
Normal file
33
management/templates/management/_generic_list.html
Normal 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 %}
|
||||
49
management/templates/management/_nav_items.html
Normal file
49
management/templates/management/_nav_items.html
Normal 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 %}
|
||||
42
management/templates/management/base.html
Normal file
42
management/templates/management/base.html
Normal 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 %}
|
||||
31
management/templates/management/family_detail.html
Normal file
31
management/templates/management/family_detail.html
Normal 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 %}
|
||||
43
management/templates/management/family_form.html
Normal file
43
management/templates/management/family_form.html
Normal 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 %}
|
||||
245
management/templates/management/home.html
Normal file
245
management/templates/management/home.html
Normal 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" %}€{% 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 %}
|
||||
157
management/templates/management/member_detail.html
Normal file
157
management/templates/management/member_detail.html
Normal 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 %}
|
||||
62
management/templates/management/member_form.html
Normal file
62
management/templates/management/member_form.html
Normal 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 %}
|
||||
39
management/templates/management/member_import.html
Normal file
39
management/templates/management/member_import.html
Normal 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 %}
|
||||
68
management/templates/management/member_import_preview.html
Normal file
68
management/templates/management/member_import_preview.html
Normal 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 %}
|
||||
102
management/templates/management/member_list.html
Normal file
102
management/templates/management/member_list.html
Normal 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 %}
|
||||
226
management/templates/management/membership_list.html
Normal file
226
management/templates/management/membership_list.html
Normal 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">€ {{ membership.fee_amount }}</td>
|
||||
<td class="tabular-nums">
|
||||
€ {{ 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 %}
|
||||
99
management/templates/management/membership_list_pdf.html
Normal file
99
management/templates/management/membership_list_pdf.html
Normal 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>
|
||||
29
management/templates/management/role_form.html
Normal file
29
management/templates/management/role_form.html
Normal 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 %}
|
||||
44
management/templates/management/role_list.html
Normal file
44
management/templates/management/role_list.html
Normal 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 %}
|
||||
19
management/templates/management/team_detail.html
Normal file
19
management/templates/management/team_detail.html
Normal 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 %}
|
||||
31
management/templates/management/team_form.html
Normal file
31
management/templates/management/team_form.html
Normal 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 %}
|
||||
39
management/templates/management/team_list.html
Normal file
39
management/templates/management/team_list.html
Normal 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
1644
management/tests.py
Normal file
File diff suppressed because it is too large
Load Diff
56
management/urls.py
Normal file
56
management/urls.py
Normal 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
966
management/views.py
Normal 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"])
|
||||
Reference in New Issue
Block a user