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

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

View File

@@ -5,6 +5,7 @@
@source "../templates";
@source "../controlpanel";
@source "../billing";
@source "../management";
/* daisyUI: light is the default, dark applies automatically when the OS asks
for it. An explicit data-theme on <html> (set by the toggle) overrides both. */

View File

@@ -1,7 +1,7 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from .models import Club, ClubMembership, ClubRole, Season
from .models import Club, ClubMembership, ClubRole, FeePayment, Season
@admin.register(Club)
@@ -20,17 +20,33 @@ class SeasonAdmin(admin.ModelAdmin):
ordering = ["club", "-start_date"]
class FeePaymentInline(admin.TabularInline):
model = FeePayment
extra = 0
readonly_fields = ["recorded_by"]
@admin.register(ClubMembership)
class ClubMembershipAdmin(admin.ModelAdmin):
list_display = ["club__name", "member__last_name", "member__first_name", "season", "status", "fee_status", "license"]
list_display = ["club__name", "member__last_name", "member__first_name", "season", "status", "fee_status", "fee_amount", "amount_paid", "license"]
search_fields = ["club__name", "member__last_name", "member__first_name", "license"]
list_filter = ["club", "season", "status", "fee_status"]
raw_id_fields = ["member"]
# Money is settled by club.services.fees, which re-derives fee_status from the payments.
readonly_fields = ["amount_paid", "fee_status"]
fieldsets = [
[None, {"fields": ["club", "season", "member"]}],
[_("Membership"), {"fields": ["license", "status", "fee_status"]}],
[_("Membership"), {"fields": ["license", "status", "fee_status", "fee_amount", "amount_paid"]}],
[_("Dates"), {"fields": ["signed_up_at", "activated_at"]}],
]
inlines = [FeePaymentInline]
@admin.register(FeePayment)
class FeePaymentAdmin(admin.ModelAdmin):
list_display = ["membership", "amount", "method", "paid_at", "recorded_by"]
list_filter = ["method"]
search_fields = ["membership__club__name", "membership__member__last_name", "reference"]
@admin.register(ClubRole)

View File

@@ -0,0 +1,50 @@
# Generated by Django 6.0.6 on 2026-08-03 11:22
import django.core.validators
import django.db.models.deletion
import django.utils.timezone
import uuid
from decimal import Decimal
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0015_alter_club_logo'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='clubmembership',
name='amount_paid',
field=models.DecimalField(blank=True, decimal_places=2, default=Decimal('0.00'), help_text='Kept in step with payments by the fee service; not hand-edited.', max_digits=10, verbose_name='amount paid'),
),
migrations.AddField(
model_name='clubmembership',
name='fee_amount',
field=models.DecimalField(blank=True, decimal_places=2, default=Decimal('0.00'), max_digits=10, verbose_name='fee amount'),
),
migrations.CreateModel(
name='FeePayment',
fields=[
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('amount', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))], verbose_name='amount')),
('method', models.CharField(choices=[('bank_transfer', 'bank transfer'), ('cash', 'cash'), ('card', 'card'), ('other', 'other')], default='bank_transfer', max_length=20, verbose_name='method')),
('reference', models.CharField(blank=True, help_text='Bank reference, transaction id — whatever lets you find this again.', max_length=255, verbose_name='reference')),
('paid_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='paid at')),
('note', models.TextField(blank=True, verbose_name='note')),
('membership', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='club.clubmembership', verbose_name='membership')),
('recorded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='recorded_fee_payments', to=settings.AUTH_USER_MODEL, verbose_name='recorded by')),
],
options={
'verbose_name': 'fee payment',
'verbose_name_plural': 'fee payments',
'ordering': ['-paid_at'],
},
),
]

51
club/mixins.py Normal file
View File

@@ -0,0 +1,51 @@
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.http import Http404
from .services.access import has_management_access, is_club_admin, teams_managed_by
class ClubStaffRequiredMixin(LoginRequiredMixin, UserPassesTestMixin):
"""Gate for the club-facing management UI.
Two rules, the mirror image of ``controlpanel.mixins.PlatformStaffRequiredMixin``:
* **Club subdomain only.** This UI manages *one* club, so it doesn't exist on the
base domain — same reasoning as the control panel refusing to exist on a club
subdomain, just inverted.
* **Staff only.** ADMIN/EDITOR, or a current-season ``StaffAssignment`` (coach,
team manager, ...) — see ``has_management_access``. The plain MEMBER role every
active player/club member holds automatically does *not* count: a club member
with neither is a player/parent, and belongs in the separate app that serves
them.
"""
def dispatch(self, request, *args, **kwargs):
if getattr(request, "club", None) is None:
raise Http404("The management app is not available on the base domain.")
return super().dispatch(request, *args, **kwargs)
def test_func(self):
return has_management_access(self.request.user, self.request.club)
class ClubAdminRequiredMixin(ClubStaffRequiredMixin):
"""ADMIN role only — club-wide settings that aren't scoped to a single team:
seasons, positions, roles, shop configuration."""
def test_func(self):
return is_club_admin(self.request.user, self.request.club)
class TeamManagerRequiredMixin(ClubStaffRequiredMixin):
"""A manager of *this* team, or a club ADMIN. ``self.get_team()`` must return the
``Team`` the view acts on (e.g. from the URL's ``pk``) before ``test_func`` runs.
"""
def get_team(self):
raise NotImplementedError("Subclasses must return the Team this view acts on.")
def test_func(self):
user, club = self.request.user, self.request.club
if is_club_admin(user, club):
return True
return teams_managed_by(user, club).filter(pk=self.get_team().pk).exists()

View File

@@ -1,6 +1,8 @@
import datetime
from decimal import Decimal
from django.core.validators import FileExtensionValidator, RegexValidator
from django.conf import settings
from django.core.validators import FileExtensionValidator, MinValueValidator, RegexValidator
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
@@ -182,6 +184,9 @@ class ClubMembership(ClubScopedModel):
status = models.CharField(_("status"), max_length=250, choices=StatusChoices.choices, default=StatusChoices.PENDING)
fee_status = models.CharField(_("fee status"), max_length=250, choices=FeeStatus.choices, default=FeeStatus.UNPAID)
fee_amount = models.DecimalField(_("fee amount"), max_digits=10, decimal_places=2, default=Decimal("0.00"), blank=True)
amount_paid = models.DecimalField(_("amount paid"), max_digits=10, decimal_places=2, default=Decimal("0.00"), blank=True, help_text=_("Kept in step with payments by the fee service; not hand-edited."))
signed_up_at = models.DateField(_("signed up at"), blank=True, null=True)
activated_at = models.DateField(_("activated at"), blank=True, null=True)
@@ -200,6 +205,35 @@ class ClubMembership(ClubScopedModel):
validate_club_scope(self, self.club_id, same_club_fields=("season",))
class FeePayment(UUIDModel):
"""Money received against one membership's fee. Several may land on one
membership: a family paying in two installments must not read as unpaid, and
the part that did arrive has to be recorded somewhere. Not itself club-scoped
-- its club is reached through ``membership``, same as DuePayment/Due."""
class Method(models.TextChoices):
BANK_TRANSFER = "bank_transfer", _("bank transfer")
CASH = "cash", _("cash")
CARD = "card", _("card")
OTHER = "other", _("other")
membership = models.ForeignKey(ClubMembership, on_delete=models.CASCADE, related_name="payments", verbose_name=_("membership"))
amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2, validators=[MinValueValidator(Decimal("0.01"))])
method = models.CharField(_("method"), max_length=20, choices=Method.choices, default=Method.BANK_TRANSFER)
reference = models.CharField(_("reference"), max_length=255, blank=True, help_text=_("Bank reference, transaction id — whatever lets you find this again."))
paid_at = models.DateTimeField(_("paid at"), default=timezone.now)
note = models.TextField(_("note"), blank=True)
recorded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="recorded_fee_payments", verbose_name=_("recorded by"))
class Meta:
verbose_name = _("fee payment")
verbose_name_plural = _("fee payments")
ordering = ["-paid_at"]
def __str__(self):
return f"{self.membership}{self.amount}"
class ClubRole(ClubScopedModel):
class Roles(models.TextChoices):
ADMIN = "admin", _("admin")

View File

@@ -49,6 +49,18 @@ def is_club_admin(user: User, club: Club) -> bool:
return has_club_role(user, club, ClubRole.Roles.ADMIN)
def has_management_access(user: User, club: Club) -> bool:
"""Anyone with real authority in the club: ADMIN/EDITOR, or *any* current-season
staff assignment (coach, team manager, physio, ...).
Deliberately excludes the plain MEMBER role -- every signed-up player (or club
member generally) holds that automatically the moment their ClubMembership goes
active (club/signals.py), so it says nothing about whether someone is staff.
"""
elevated = ClubRole.objects.filter(member__user=user, club=club, role__in=(ClubRole.Roles.ADMIN, ClubRole.Roles.EDITOR)).exists()
return elevated or teams_staffed_by(user, club).exists()
def is_coach_manager(user: User, club: Club) -> bool:
"""Derived from a current-season StaffAssignment in a *management* position."""
return StaffAssignment.objects.filter(

69
club/services/fees.py Normal file
View File

@@ -0,0 +1,69 @@
"""Recording money received against a membership's fee.
Mirrors billing.services.dues.record_payment for a different kind of money: a
member's own club fee, not the club's platform subscription. amount_paid is kept in
step here, never recomputed by re-aggregating FeePayment on every read.
"""
from decimal import Decimal
from django.db.models import F
from django.utils import timezone
from club.models import ClubMembership, FeePayment
def remaining_balance(membership):
return max(membership.fee_amount - membership.amount_paid, Decimal("0.00"))
def record_payment(membership, *, amount, method=FeePayment.Method.BANK_TRANSFER, reference="", note="", recorded_by=None):
"""Record money received against one membership's fee. Several payments may
land on one membership -- a family paying in two installments must not read as
unpaid. Updates amount_paid and re-syncs fee_status/status to match."""
payment = FeePayment.objects.create(membership=membership, amount=amount, method=method, reference=reference, note=note, recorded_by=recorded_by)
membership.amount_paid = F("amount_paid") + amount
membership.save(update_fields=["amount_paid"])
membership.refresh_from_db(fields=["amount_paid"])
_sync_fee_status(membership)
return payment
def mark_as_paid(membership, *, recorded_by=None):
"""The "settle this one" action behind both the per-row and bulk buttons. If
there's a real remaining balance, records it as a payment (auditable, shows up
in history); if fee_amount was never priced (remaining is 0), just flips the
flags directly -- there's no real transaction to log."""
remaining = remaining_balance(membership)
if remaining > 0:
record_payment(membership, amount=remaining, method=FeePayment.Method.OTHER, note="Marked as paid", recorded_by=recorded_by)
else:
_sync_fee_status(membership, force_paid=True)
def _sync_fee_status(membership, *, force_paid=False):
if membership.fee_status == ClubMembership.FeeStatus.WAIVED:
return # manual, independent of payments -- this never overrides it
if force_paid or (membership.fee_amount > 0 and membership.amount_paid >= membership.fee_amount):
new_status = ClubMembership.FeeStatus.PAID
elif membership.amount_paid > 0:
new_status = ClubMembership.FeeStatus.PARTIALLY_PAID
else:
new_status = ClubMembership.FeeStatus.UNPAID
membership.fee_status = new_status
update_fields = ["fee_status"]
# Same "become a full member" behavior the bulk action already had: settling
# the fee in full also activates the membership, once, first time only.
if new_status == ClubMembership.FeeStatus.PAID:
membership.status = ClubMembership.StatusChoices.ACTIVE
update_fields.append("status")
if membership.activated_at is None:
membership.activated_at = timezone.localdate()
update_fields.append("activated_at")
membership.save(update_fields=update_fields)

View File

@@ -1,6 +1,7 @@
import datetime
import uuid
from contextlib import contextmanager
from decimal import Decimal
from allauth.mfa.models import Authenticator
from django.contrib import admin as django_admin
@@ -16,7 +17,7 @@ from events.models import Event
from members.models import Family, FamilyMembership, Member
from teams.models import Position, StaffAssignment, Team, TeamMembership
from .models import Club, ClubMembership, ClubRole, Season, club_logo_path
from .models import Club, ClubMembership, ClubRole, FeePayment, Season, club_logo_path
from .services.access import (
COACH_MANAGER,
can_edit_event,
@@ -27,6 +28,7 @@ from .services.access import (
teams_managed_by,
teams_staffed_by,
)
from .services.fees import mark_as_paid, record_payment, remaining_balance
from .tenancy import (
ClubTenantMiddleware,
get_current_club,
@@ -440,14 +442,17 @@ class SeasonGetCurrentTests(TestCase):
self.assertEqual(found.club, self.other)
def test_defaults_to_today(self):
# self.other, not self.club -- setUp's self.season (2026-08-01 to 2027-05-31)
# would otherwise also cover "today" once real dates reach that window,
# colliding with the one created here.
today = timezone.now().date()
current = Season.objects.create(
club=self.club,
club=self.other,
start_date=today - datetime.timedelta(days=10),
end_date=today + datetime.timedelta(days=10),
)
with with_club(self.club):
with with_club(self.other):
self.assertEqual(Season.get_current(), current)
def test_requires_an_active_club(self):
@@ -1083,3 +1088,105 @@ class RootViewTests(TestCase):
response = self.client.get("/", HTTP_HOST="ajax-united.rosterchief.app")
self.assertRedirects(response, f"{reverse('account_login')}?next=/", fetch_redirect_response=False)
class FeeServiceTests(TestCase):
"""club.services.fees -- record_payment/mark_as_paid/remaining_balance, the
service layer behind the Memberships page's per-row payment actions."""
def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
self.season = make_season(self.club)
self.member = Member.objects.create(first_name="Jane", last_name="Doe")
self.membership = ClubMembership.objects.create(
club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_amount=Decimal("150.00")
)
def roles(self):
return ClubRole.objects.filter(club=self.club, member=self.member)
def test_remaining_balance_starts_at_the_full_fee(self):
self.assertEqual(remaining_balance(self.membership), Decimal("150.00"))
def test_remaining_balance_is_never_negative(self):
record_payment(self.membership, amount=Decimal("200.00"))
self.assertEqual(remaining_balance(self.membership), Decimal("0.00"))
def test_a_partial_payment_creates_a_record_and_updates_the_running_total(self):
payment = record_payment(self.membership, amount=Decimal("50.00"), method=FeePayment.Method.CASH, reference="R1", note="first installment")
self.assertEqual(payment.membership, self.membership)
self.assertEqual(payment.amount, Decimal("50.00"))
self.membership.refresh_from_db()
self.assertEqual(self.membership.amount_paid, Decimal("50.00"))
self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PARTIALLY_PAID)
# Not yet settled -- status doesn't change on a partial payment.
self.assertEqual(self.membership.status, ClubMembership.StatusChoices.PENDING)
def test_multiple_partial_payments_accumulate(self):
record_payment(self.membership, amount=Decimal("50.00"))
record_payment(self.membership, amount=Decimal("60.00"))
self.membership.refresh_from_db()
self.assertEqual(self.membership.amount_paid, Decimal("110.00"))
self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PARTIALLY_PAID)
self.assertEqual(FeePayment.objects.filter(membership=self.membership).count(), 2)
def test_reaching_the_full_amount_settles_and_activates(self):
record_payment(self.membership, amount=Decimal("100.00"))
record_payment(self.membership, amount=Decimal("50.00"))
self.membership.refresh_from_db()
self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PAID)
self.assertEqual(self.membership.status, ClubMembership.StatusChoices.ACTIVE)
self.assertEqual(self.membership.activated_at, timezone.localdate())
self.assertTrue(self.roles().filter(role=ClubRole.Roles.MEMBER).exists())
def test_settling_in_full_does_not_overwrite_an_earlier_activated_at(self):
earlier = datetime.date(2026, 1, 1)
self.membership.activated_at = earlier
self.membership.save()
record_payment(self.membership, amount=Decimal("150.00"))
self.membership.refresh_from_db()
self.assertEqual(self.membership.activated_at, earlier)
def test_a_waived_membership_is_untouched_by_a_payment(self):
self.membership.fee_status = ClubMembership.FeeStatus.WAIVED
self.membership.save()
record_payment(self.membership, amount=Decimal("50.00"))
self.membership.refresh_from_db()
self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.WAIVED)
def test_mark_as_paid_records_the_exact_remaining_balance(self):
record_payment(self.membership, amount=Decimal("100.00"))
mark_as_paid(self.membership)
self.membership.refresh_from_db()
self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PAID)
payment = FeePayment.objects.get(membership=self.membership, amount=Decimal("50.00"))
self.assertEqual(payment.note, "Marked as paid")
def test_mark_as_paid_with_no_fee_amount_set_skips_creating_a_zero_payment(self):
# FeePayment.amount has a MinValueValidator(0.01) -- a $0 "payment" isn't a
# real transaction, so this must flip the flags directly instead.
unpriced = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="No", last_name="Price"), season=self.season, status=ClubMembership.StatusChoices.PENDING)
mark_as_paid(unpriced)
unpriced.refresh_from_db()
self.assertEqual(unpriced.fee_status, ClubMembership.FeeStatus.PAID)
self.assertEqual(unpriced.status, ClubMembership.StatusChoices.ACTIVE)
self.assertFalse(FeePayment.objects.filter(membership=unpriced).exists())
def test_recorded_by_is_stored_on_the_payment(self):
user = get_user_model().objects.create_user(email="admin-fees@example.com", password="pw-secret-123")
payment = record_payment(self.membership, amount=Decimal("50.00"), recorded_by=user)
self.assertEqual(payment.recorded_by, user)

17
features/admin.py Normal file
View File

@@ -0,0 +1,17 @@
from django.contrib import admin
from .models import Flag, Maintenance
@admin.register(Flag)
class FlagAdmin(admin.ModelAdmin):
list_display = ["name", "note", "everyone", "percent", "superusers", "staff"]
list_filter = ["everyone", "superusers", "staff"]
search_fields = ["name", "note"]
filter_horizontal = ["clubs"]
@admin.register(Maintenance)
class MaintenanceAdmin(admin.ModelAdmin):
list_display = ["__str__", "started_at", "started_by"]
readonly_fields = ["started_at", "started_by"]

0
management/__init__.py Normal file
View File

5
management/apps.py Normal file
View File

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

163
management/bulk_import.py Normal file
View File

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

View File

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

144
management/forms.py Normal file
View File

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

30
management/pdf.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

1644
management/tests.py Normal file

File diff suppressed because it is too large Load Diff

56
management/urls.py Normal file
View File

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

966
management/views.py Normal file
View File

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

122
members/services/family.py Normal file
View File

@@ -0,0 +1,122 @@
"""Registering families: a parent (who needs a login) and a child (who doesn't),
linked to each other -- either together as a new family, or one at a time onto an
existing one.
"""
from django.contrib.auth import get_user_model
from django.db import transaction
from django.utils import timezone
from club.models import ClubMembership
from members.models import Family, FamilyMembership, Member
User = get_user_model()
def find_member_by_email(email):
"""The Member behind a login email, if that account exists at all."""
return Member.objects.filter(user__email__iexact=email).first()
def get_or_create_login_member(email, first_name="", last_name=""):
"""Find or create the Member behind ``email``, creating a User (no usable
password -- they set one via the reset-link flow) the first time we see them.
Mirrors controlpanel.services.admins.grant_club_admin.
"""
email = email.lower()
user, user_created = User.objects.get_or_create(email=email, defaults={"is_active": True})
if user_created:
user.set_unusable_password()
user.save(update_fields=["password"])
member, _ = Member.objects.get_or_create(user=user, defaults={"first_name": first_name, "last_name": last_name})
return member
def grant_login(member, email):
"""Give a login-less member (a child old enough to need their own account, most
often) the ability to sign in -- a new User with no usable password, same as
get_or_create_login_member; they set one via the reset-link flow. Unlike that
function, this attaches the login to a specific, already-existing Member rather
than finding-or-creating one -- the caller's form has already checked the email
isn't already in use."""
user = User.objects.create(email=email.lower(), is_active=True)
user.set_unusable_password()
user.save(update_fields=["password"])
member.user = user
member.save(update_fields=["user"])
return user
def _enrol(club, season, member):
"""Sign a member up for the club's current season, if there is one. The
implicit MEMBER role follows automatically (club/signals.py)."""
if season is None:
return None
membership, _ = ClubMembership.objects.get_or_create(
club=club,
member=member,
season=season,
defaults={"status": ClubMembership.StatusChoices.ACTIVE, "signed_up_at": timezone.localdate()},
)
return membership
@transaction.atomic
def register_family(club, season, *, parent_email, parent_first_name, parent_last_name, child_first_name, child_last_name, child_date_of_birth=None):
"""Create a new family in one go: a parent (with a login) and a child
(without one), linked to each other and signed up for ``season``."""
parent = get_or_create_login_member(parent_email, parent_first_name, parent_last_name)
child = Member.objects.create(first_name=child_first_name, last_name=child_last_name, date_of_birth=child_date_of_birth)
family = Family.objects.create()
FamilyMembership.objects.create(family=family, member=parent, role=FamilyMembership.FamilyRole.PARENT)
FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD)
_enrol(club, season, parent)
_enrol(club, season, child)
return family
@transaction.atomic
def add_child_to_family(club, season, family, *, first_name, last_name, date_of_birth=None):
"""A family that needs one more child registered -- a sibling joining, most often."""
child = Member.objects.create(first_name=first_name, last_name=last_name, date_of_birth=date_of_birth)
FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD)
_enrol(club, season, child)
return child
@transaction.atomic
def add_parent_to_family(club, season, family, *, email, first_name="", last_name=""):
"""A family that needs one more parent/guardian registered."""
parent = get_or_create_login_member(email, first_name, last_name)
# get_or_create, not create: re-adding an email already on this family (a typo'd
# re-submit, say) must not trip unique_member_per_family.
FamilyMembership.objects.get_or_create(family=family, member=parent, defaults={"role": FamilyMembership.FamilyRole.PARENT})
_enrol(club, season, parent)
return parent
def attach_to_family(member, *, role, family=None):
"""Link a standalone member into a family -- a new one, or an existing one they
turn out to belong to (e.g. a second parent already registered separately)."""
if family is None:
family = Family.objects.create()
FamilyMembership.objects.get_or_create(family=family, member=member, defaults={"role": role})
return family
@transaction.atomic
def detach_from_family(member, family):
"""Remove member from one specific family -- they may still belong to others.
An empty family (this was its last member) is cleaned up same as before."""
FamilyMembership.objects.filter(member=member, family=family).delete()
if not family.memberships.exists():
family.delete()

View File

@@ -12,6 +12,7 @@ dependencies = [
"django-storages[s3]>=1.14.6",
"django-waffle>=5.0.0",
"gunicorn>=26.0.0",
"openpyxl>=3.1.5",
"pillow>=12.3.0",
"psycopg[binary]>=3.3.4",
"python-dateutil>=2.9.0.post0",
@@ -56,7 +57,7 @@ ignore = [
[tool.ruff.lint.isort]
known-first-party = [
"billing", "authentication", "club", "members", "teams", "events", "formbuilder", "shop", "controlpanel", "news", "pages", "home", "search", "rosterchief"]
"billing", "authentication", "club", "members", "teams", "events", "formbuilder", "shop", "controlpanel", "management", "news", "pages", "home", "search", "rosterchief"]
[tool.uv.sources]
django-lucide = { git = "https://github.com/bsiebens/lucide" }

View File

@@ -73,6 +73,9 @@ INSTALLED_APPS = [
"waffle",
"features.apps.FeaturesConfig",
"controlpanel.apps.ControlpanelConfig",
# Club-facing UI for team managers, coaches and admins -- not controlpanel (platform
# staff managing every club) and not the future parent/player app.
"management.apps.ManagementConfig",
]
# Feature flags (django-waffle). The Flag model is swappable, like AUTH_USER_MODEL:
@@ -183,6 +186,9 @@ TEMPLATES = [
"django.contrib.messages.context_processors.messages",
"club.context_processors.branding",
"features.context_processors.maintenance",
"management.context_processors.is_admin",
"management.context_processors.management_link",
"management.context_processors.active_nav_section",
],
},
},

View File

@@ -24,6 +24,7 @@ urlpatterns = [
path("admin/", admin.site.urls),
path("accounts/", include("allauth.urls")),
path("controlpanel/", include("controlpanel.urls")),
path("manage/", include("management.urls")),
# "/" resolves per tenant: a club subdomain lands on the club, the base domain
# hands off to the control panel. This is why LOGIN_REDIRECT_URL can stay "/".
path("", root, name="root"),

View File

@@ -34,6 +34,8 @@
--font-weight-bold: 700;
--tracking-wide: 0.025em;
--tracking-wider: 0.05em;
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--default-transition-duration: 150ms;
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
--default-font-family: var(--font-sans);
@@ -3310,6 +3312,9 @@
.border-info {
border-color: var(--color-info);
}
.border-neutral {
border-color: var(--color-neutral);
}
.border-success {
border-color: var(--color-success);
}
@@ -3429,9 +3434,6 @@
.object-contain {
object-fit: contain;
}
.p-1 {
padding: var(--spacing);
}
.p-2 {
padding: calc(var(--spacing) * 2);
}
@@ -3666,6 +3668,9 @@
text-decoration-line: none;
}
}
.opacity-40 {
opacity: 40%;
}
.opacity-50 {
opacity: 50%;
}
@@ -3726,6 +3731,17 @@
}
}
}
.select-sm {
@layer daisyui.l1.l2 {
--sl-size-mul: 8;
--font-size-min: 0.75rem;
--option-px: 2.5;
.floating-label:has(&) {
--top-mul: 4;
--font-size: 0.75rem;
}
}
}
.btn-neutral {
@layer daisyui.l1.l2 {
--btn-color: var(--color-neutral);
@@ -3748,6 +3764,13 @@
--btn-soft-bg: initial;
}
}
.btn-info {
@layer daisyui.l1.l2 {
--btn-color: var(--color-info);
--btn-fg: var(--color-info-content);
--btn-soft-bg: initial;
}
}
.btn-primary {
@layer daisyui.l1.l2 {
--btn-color: var(--color-primary);

View File

@@ -64,6 +64,11 @@
</button>
{% if user.is_authenticated %}
{% if has_management_access %}
{# Only ever true on a club subdomain -- see management.context_processors.management_link. #}
<a class="btn btn-ghost gap-4" href="{% url "management:home" %}" aria-label="Management">{% lucide "layout-dashboard" %}<span class="hidden sm:inline">Management</span></a>
{% endif %}
{% if user.is_superuser %}
<a class="btn btn-ghost gap-4" href="{% url "admin:index" %}" aria-label="Django admin">{% lucide "shield-cog" %}<span class="hidden sm:inline">Django admin</span></a>
{% endif %}

23
uv.lock generated
View File

@@ -370,6 +370,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/d2/6f0d664bd35a3fdd0403655c7c32ec290704923f11541ef356b180cd8fbf/django_waffle-5.0.0-py3-none-any.whl", hash = "sha256:3312851d9d926b76b9e90712355781700a383b82b5bf2b61e1f1be97532c0f3d", size = 48137, upload-time = "2025-06-12T07:38:53.698Z" },
]
[[package]]
name = "et-xmlfile"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" },
]
[[package]]
name = "fido2"
version = "2.2.1"
@@ -435,6 +444,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
]
[[package]]
name = "openpyxl"
version = "3.1.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "et-xmlfile" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" },
]
[[package]]
name = "packaging"
version = "26.2"
@@ -621,6 +642,7 @@ dependencies = [
{ name = "django-storages", extra = ["s3"] },
{ name = "django-waffle" },
{ name = "gunicorn" },
{ name = "openpyxl" },
{ name = "pillow" },
{ name = "psycopg", extra = ["binary"] },
{ name = "python-dateutil" },
@@ -647,6 +669,7 @@ requires-dist = [
{ name = "django-storages", extras = ["s3"], specifier = ">=1.14.6" },
{ name = "django-waffle", specifier = ">=5.0.0" },
{ name = "gunicorn", specifier = ">=26.0.0" },
{ name = "openpyxl", specifier = ">=3.1.5" },
{ name = "pillow", specifier = ">=12.3.0" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.3.4" },
{ name = "python-dateutil", specifier = ">=2.9.0.post0" },