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:
@@ -5,6 +5,7 @@
|
|||||||
@source "../templates";
|
@source "../templates";
|
||||||
@source "../controlpanel";
|
@source "../controlpanel";
|
||||||
@source "../billing";
|
@source "../billing";
|
||||||
|
@source "../management";
|
||||||
|
|
||||||
/* daisyUI: light is the default, dark applies automatically when the OS asks
|
/* 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. */
|
for it. An explicit data-theme on <html> (set by the toggle) overrides both. */
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.utils.translation import gettext_lazy as _
|
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)
|
@admin.register(Club)
|
||||||
@@ -20,17 +20,33 @@ class SeasonAdmin(admin.ModelAdmin):
|
|||||||
ordering = ["club", "-start_date"]
|
ordering = ["club", "-start_date"]
|
||||||
|
|
||||||
|
|
||||||
|
class FeePaymentInline(admin.TabularInline):
|
||||||
|
model = FeePayment
|
||||||
|
extra = 0
|
||||||
|
readonly_fields = ["recorded_by"]
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ClubMembership)
|
@admin.register(ClubMembership)
|
||||||
class ClubMembershipAdmin(admin.ModelAdmin):
|
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"]
|
search_fields = ["club__name", "member__last_name", "member__first_name", "license"]
|
||||||
list_filter = ["club", "season", "status", "fee_status"]
|
list_filter = ["club", "season", "status", "fee_status"]
|
||||||
raw_id_fields = ["member"]
|
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 = [
|
fieldsets = [
|
||||||
[None, {"fields": ["club", "season", "member"]}],
|
[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"]}],
|
[_("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)
|
@admin.register(ClubRole)
|
||||||
|
|||||||
@@ -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
51
club/mixins.py
Normal 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()
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import datetime
|
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.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
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)
|
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_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)
|
signed_up_at = models.DateField(_("signed up at"), blank=True, null=True)
|
||||||
activated_at = models.DateField(_("activated 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",))
|
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 ClubRole(ClubScopedModel):
|
||||||
class Roles(models.TextChoices):
|
class Roles(models.TextChoices):
|
||||||
ADMIN = "admin", _("admin")
|
ADMIN = "admin", _("admin")
|
||||||
|
|||||||
@@ -49,6 +49,18 @@ def is_club_admin(user: User, club: Club) -> bool:
|
|||||||
return has_club_role(user, club, ClubRole.Roles.ADMIN)
|
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:
|
def is_coach_manager(user: User, club: Club) -> bool:
|
||||||
"""Derived from a current-season StaffAssignment in a *management* position."""
|
"""Derived from a current-season StaffAssignment in a *management* position."""
|
||||||
return StaffAssignment.objects.filter(
|
return StaffAssignment.objects.filter(
|
||||||
|
|||||||
69
club/services/fees.py
Normal file
69
club/services/fees.py
Normal 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)
|
||||||
113
club/tests.py
113
club/tests.py
@@ -1,6 +1,7 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import uuid
|
import uuid
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from allauth.mfa.models import Authenticator
|
from allauth.mfa.models import Authenticator
|
||||||
from django.contrib import admin as django_admin
|
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 members.models import Family, FamilyMembership, Member
|
||||||
from teams.models import Position, StaffAssignment, Team, TeamMembership
|
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 (
|
from .services.access import (
|
||||||
COACH_MANAGER,
|
COACH_MANAGER,
|
||||||
can_edit_event,
|
can_edit_event,
|
||||||
@@ -27,6 +28,7 @@ from .services.access import (
|
|||||||
teams_managed_by,
|
teams_managed_by,
|
||||||
teams_staffed_by,
|
teams_staffed_by,
|
||||||
)
|
)
|
||||||
|
from .services.fees import mark_as_paid, record_payment, remaining_balance
|
||||||
from .tenancy import (
|
from .tenancy import (
|
||||||
ClubTenantMiddleware,
|
ClubTenantMiddleware,
|
||||||
get_current_club,
|
get_current_club,
|
||||||
@@ -440,14 +442,17 @@ class SeasonGetCurrentTests(TestCase):
|
|||||||
self.assertEqual(found.club, self.other)
|
self.assertEqual(found.club, self.other)
|
||||||
|
|
||||||
def test_defaults_to_today(self):
|
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()
|
today = timezone.now().date()
|
||||||
current = Season.objects.create(
|
current = Season.objects.create(
|
||||||
club=self.club,
|
club=self.other,
|
||||||
start_date=today - datetime.timedelta(days=10),
|
start_date=today - datetime.timedelta(days=10),
|
||||||
end_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)
|
self.assertEqual(Season.get_current(), current)
|
||||||
|
|
||||||
def test_requires_an_active_club(self):
|
def test_requires_an_active_club(self):
|
||||||
@@ -1083,3 +1088,105 @@ class RootViewTests(TestCase):
|
|||||||
response = self.client.get("/", HTTP_HOST="ajax-united.rosterchief.app")
|
response = self.client.get("/", HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
|
||||||
self.assertRedirects(response, f"{reverse('account_login')}?next=/", fetch_redirect_response=False)
|
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
17
features/admin.py
Normal 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
0
management/__init__.py
Normal file
5
management/apps.py
Normal file
5
management/apps.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ManagementConfig(AppConfig):
|
||||||
|
name = "management"
|
||||||
163
management/bulk_import.py
Normal file
163
management/bulk_import.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
"""Mass-uploading members from an Excel template: extracting a workbook into plain
|
||||||
|
row data, and validating that data into what will be created.
|
||||||
|
|
||||||
|
Kept as two separate functions so binary parsing happens exactly once (at upload
|
||||||
|
time) while validation -- the part that must behave identically whether it's
|
||||||
|
building the preview or actually creating records -- runs against plain data both
|
||||||
|
times (see MemberImportView / MemberImportConfirmView in management/views.py).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
import openpyxl
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from openpyxl.worksheet.datavalidation import DataValidation
|
||||||
|
|
||||||
|
from club.models import ClubMembership
|
||||||
|
from members.models import Member
|
||||||
|
|
||||||
|
from .forms import MemberForm
|
||||||
|
|
||||||
|
TEMPLATE_COLUMNS = ["first_name", "last_name", "date_of_birth", "email", "phone", "emergency_phone", "license", "status", "fee_status"]
|
||||||
|
REQUIRED_HEADER_COLUMNS = {"first_name", "last_name"}
|
||||||
|
|
||||||
|
TEMPLATE_EXAMPLE_ROW = ["Alex", "Morgan", date(2012, 5, 14), "alex.morgan@example.com", "+32470123456", "+32470654321", "", ClubMembership.StatusChoices.ACTIVE, ClubMembership.FeeStatus.UNPAID]
|
||||||
|
|
||||||
|
|
||||||
|
def build_member_import_template():
|
||||||
|
"""The downloadable .xlsx: header row, one example row, and a dropdown on the
|
||||||
|
status/fee_status columns so a cell can't be typo'd into an invalid value."""
|
||||||
|
workbook = openpyxl.Workbook()
|
||||||
|
sheet = workbook.active
|
||||||
|
sheet.title = "Members"
|
||||||
|
|
||||||
|
sheet.append(TEMPLATE_COLUMNS)
|
||||||
|
for cell in sheet[1]:
|
||||||
|
cell.font = openpyxl.styles.Font(bold=True)
|
||||||
|
sheet.freeze_panes = "A2"
|
||||||
|
|
||||||
|
sheet.append(TEMPLATE_EXAMPLE_ROW)
|
||||||
|
|
||||||
|
for column_name, choices in (("status", ClubMembership.StatusChoices), ("fee_status", ClubMembership.FeeStatus)):
|
||||||
|
column_index = TEMPLATE_COLUMNS.index(column_name) + 1
|
||||||
|
column_letter = sheet.cell(row=1, column=column_index).column_letter
|
||||||
|
options = ",".join(choices.values)
|
||||||
|
validation = DataValidation(type="list", formula1=f'"{options}"', allow_blank=True)
|
||||||
|
sheet.add_data_validation(validation)
|
||||||
|
validation.add(f"{column_letter}2:{column_letter}1000")
|
||||||
|
|
||||||
|
for column_index, column_name in enumerate(TEMPLATE_COLUMNS, start=1):
|
||||||
|
sheet.column_dimensions[sheet.cell(row=1, column=column_index).column_letter].width = max(12, len(column_name) + 2)
|
||||||
|
|
||||||
|
return workbook
|
||||||
|
|
||||||
|
|
||||||
|
def read_member_import_workbook(file):
|
||||||
|
"""Uploaded .xlsx -> list[dict], one dict per row keyed by TEMPLATE_COLUMNS.
|
||||||
|
|
||||||
|
Normalizes every cell to a plain str/None here: openpyxl returns typed cells
|
||||||
|
(a date-formatted cell comes back as a datetime.date, a phone number typed as
|
||||||
|
digits-only can come back as a number), so this is the one place that has to
|
||||||
|
deal with that -- everything downstream, including the session, only ever
|
||||||
|
sees plain strings.
|
||||||
|
"""
|
||||||
|
workbook = openpyxl.load_workbook(file, data_only=True)
|
||||||
|
sheet = workbook.active
|
||||||
|
|
||||||
|
header = [str(cell.value).strip() if cell.value is not None else "" for cell in next(sheet.iter_rows(min_row=1, max_row=1))]
|
||||||
|
if not REQUIRED_HEADER_COLUMNS.issubset(header):
|
||||||
|
missing = REQUIRED_HEADER_COLUMNS - set(header)
|
||||||
|
raise ValueError(_("This doesn't look like the template — missing column(s): %(columns)s.") % {"columns": ", ".join(sorted(missing))})
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for excel_row in sheet.iter_rows(min_row=2):
|
||||||
|
values = {}
|
||||||
|
for column_name, cell in zip(header, excel_row, strict=False):
|
||||||
|
if column_name not in TEMPLATE_COLUMNS:
|
||||||
|
continue
|
||||||
|
values[column_name] = _cell_to_str(cell.value)
|
||||||
|
|
||||||
|
if not any(values.values()):
|
||||||
|
continue # a fully blank row (trailing spreadsheet padding) isn't a row to import
|
||||||
|
rows.append(values)
|
||||||
|
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _cell_to_str(value):
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.date().isoformat()
|
||||||
|
if isinstance(value, date):
|
||||||
|
return value.isoformat()
|
||||||
|
if isinstance(value, float) and value.is_integer():
|
||||||
|
return str(int(value))
|
||||||
|
return str(value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_member_import_rows(rows, club):
|
||||||
|
"""list[dict] (as returned by read_member_import_workbook) -> one result per
|
||||||
|
row: {line_number, raw, member, membership_kwargs, errors}. `member` is an
|
||||||
|
unsaved Member instance (ready to .save()) when the row is valid, else None."""
|
||||||
|
results = []
|
||||||
|
seen_emails = set()
|
||||||
|
|
||||||
|
for line_number, raw in enumerate(rows, start=2):
|
||||||
|
errors = []
|
||||||
|
member_fields = {key: raw.get(key, "") for key in ("first_name", "last_name", "date_of_birth", "email", "phone", "emergency_phone")}
|
||||||
|
form = MemberForm(data=member_fields)
|
||||||
|
|
||||||
|
member = None
|
||||||
|
if form.is_valid():
|
||||||
|
member = form.save(commit=False)
|
||||||
|
else:
|
||||||
|
for field_errors in form.errors.values():
|
||||||
|
errors.extend(field_errors)
|
||||||
|
|
||||||
|
email = member_fields["email"].strip()
|
||||||
|
if email:
|
||||||
|
if email.lower() in seen_emails:
|
||||||
|
errors.append(_("Duplicate email in this file."))
|
||||||
|
seen_emails.add(email.lower())
|
||||||
|
already_in_club = Member.objects.filter(member_of__club=club).filter(Q(email__iexact=email) | Q(user__email__iexact=email)).exists()
|
||||||
|
if already_in_club:
|
||||||
|
errors.append(_("Already a member of this club."))
|
||||||
|
|
||||||
|
membership_kwargs, status_fee_errors = _parse_membership_fields(raw)
|
||||||
|
errors.extend(status_fee_errors)
|
||||||
|
|
||||||
|
results.append({"line_number": line_number, "raw": raw, "member": member if not errors else None, "membership_kwargs": membership_kwargs, "errors": errors})
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_membership_fields(raw):
|
||||||
|
errors = []
|
||||||
|
license_number = raw.get("license", "").strip()
|
||||||
|
|
||||||
|
status = raw.get("status", "").strip()
|
||||||
|
if status:
|
||||||
|
status_value = _match_choice(status, ClubMembership.StatusChoices)
|
||||||
|
if status_value is None:
|
||||||
|
errors.append(_("Invalid status '%(value)s'.") % {"value": status})
|
||||||
|
else:
|
||||||
|
status_value = ClubMembership.StatusChoices.ACTIVE
|
||||||
|
|
||||||
|
fee_status = raw.get("fee_status", "").strip()
|
||||||
|
if fee_status:
|
||||||
|
fee_status_value = _match_choice(fee_status, ClubMembership.FeeStatus)
|
||||||
|
if fee_status_value is None:
|
||||||
|
errors.append(_("Invalid fee status '%(value)s'.") % {"value": fee_status})
|
||||||
|
else:
|
||||||
|
fee_status_value = ClubMembership.FeeStatus.UNPAID
|
||||||
|
|
||||||
|
return {"license": license_number, "status": status_value, "fee_status": fee_status_value}, errors
|
||||||
|
|
||||||
|
|
||||||
|
def _match_choice(value, choices):
|
||||||
|
for choice_value in choices.values:
|
||||||
|
if choice_value.lower() == value.lower():
|
||||||
|
return choice_value
|
||||||
|
return None
|
||||||
88
management/context_processors.py
Normal file
88
management/context_processors.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""Whether the signed-in user is a club ADMIN, for the management nav to hide
|
||||||
|
admin-only sections (seasons, positions, roles, shop, forms) from plain staff.
|
||||||
|
|
||||||
|
The underlying views are gated regardless (``ClubAdminRequiredMixin``) -- this is
|
||||||
|
purely so the nav doesn't show a link a coach or manager can't actually follow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from club.services.access import has_management_access, is_club_admin
|
||||||
|
|
||||||
|
#: Every management URL name, mapped to the nav item it should light up --
|
||||||
|
#: management/templates/management/_nav_items.html compares against this.
|
||||||
|
#: One dict here beats threading `nav=` through every view in views.py, and
|
||||||
|
#: unlike that, a new page can't silently be forgotten (it just renders with
|
||||||
|
#: no active item until added below, rather than needing every view touched).
|
||||||
|
_NAV_SECTIONS = {
|
||||||
|
"home": "home",
|
||||||
|
"member_list": "member_list",
|
||||||
|
"member_create": "member_list",
|
||||||
|
"member_import_template": "member_list",
|
||||||
|
"member_import": "member_list",
|
||||||
|
"member_import_confirm": "member_list",
|
||||||
|
"member_detail": "member_list",
|
||||||
|
"member_update": "member_list",
|
||||||
|
"member_delete": "member_list",
|
||||||
|
"member_attach_family": "member_list",
|
||||||
|
"member_grant_login": "member_list",
|
||||||
|
"member_detach_family": "member_list",
|
||||||
|
"family_create": "member_list",
|
||||||
|
"family_detail": "member_list",
|
||||||
|
"family_add_child": "member_list",
|
||||||
|
"family_add_parent": "member_list",
|
||||||
|
"family_membership_role_update": "member_list",
|
||||||
|
"membership_list": "membership_list",
|
||||||
|
"membership_mark_paid": "membership_list",
|
||||||
|
"membership_export_pdf": "membership_list",
|
||||||
|
"membership_mark_fully_paid": "membership_list",
|
||||||
|
"membership_record_payment": "membership_list",
|
||||||
|
"position_list": "position_list",
|
||||||
|
"role_list": "role_list",
|
||||||
|
"role_create": "role_list",
|
||||||
|
"role_revoke": "role_list",
|
||||||
|
"team_list": "team_list",
|
||||||
|
"team_create": "team_list",
|
||||||
|
"team_update": "team_list",
|
||||||
|
"team_detail": "team_list",
|
||||||
|
"roster_list": "roster_list",
|
||||||
|
"staff_list": "staff_list",
|
||||||
|
"event_list": "event_list",
|
||||||
|
"event_series_list": "event_series_list",
|
||||||
|
"location_list": "location_list",
|
||||||
|
"opponent_list": "opponent_list",
|
||||||
|
"product_list": "product_list",
|
||||||
|
"order_list": "order_list",
|
||||||
|
"discount_list": "discount_list",
|
||||||
|
"invoice_list": "invoice_list",
|
||||||
|
"form_list": "form_list",
|
||||||
|
"submission_list": "form_list",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def active_nav_section(request):
|
||||||
|
"""Which management nav item is currently active, derived from the resolved
|
||||||
|
URL name. Guarded on the "management" namespace so a same-named url_name in
|
||||||
|
some other app can never leak into this."""
|
||||||
|
match = request.resolver_match
|
||||||
|
if match is None or match.namespace != "management":
|
||||||
|
return {"nav": None}
|
||||||
|
|
||||||
|
return {"nav": _NAV_SECTIONS.get(match.url_name)}
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin(request):
|
||||||
|
club = getattr(request, "club", None)
|
||||||
|
if club is None or not request.user.is_authenticated:
|
||||||
|
return {"is_club_admin": False}
|
||||||
|
|
||||||
|
return {"is_club_admin": is_club_admin(request.user, club)}
|
||||||
|
|
||||||
|
|
||||||
|
def management_link(request):
|
||||||
|
"""Whether to show a "Management" link in the global navbar (templates/_base.html),
|
||||||
|
next to the Django admin one -- only on a club subdomain, and only for someone with
|
||||||
|
real authority there (see has_management_access)."""
|
||||||
|
club = getattr(request, "club", None)
|
||||||
|
if club is None or not request.user.is_authenticated:
|
||||||
|
return {"has_management_access": False}
|
||||||
|
|
||||||
|
return {"has_management_access": has_management_access(request.user, club)}
|
||||||
144
management/forms.py
Normal file
144
management/forms.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from club.models import ClubMembership, ClubRole, FeePayment
|
||||||
|
from members.models import Family, FamilyMembership, Member
|
||||||
|
from members.services.family import find_member_by_email
|
||||||
|
from teams.models import Team
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class MemberForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Member
|
||||||
|
fields = ["first_name", "last_name", "date_of_birth", "email", "phone", "emergency_phone"]
|
||||||
|
widgets = {"date_of_birth": forms.DateInput(attrs={"type": "date"})}
|
||||||
|
|
||||||
|
|
||||||
|
class TeamForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Team
|
||||||
|
fields = ["name", "short_name"]
|
||||||
|
|
||||||
|
|
||||||
|
class ClubRoleAssignForm(forms.ModelForm):
|
||||||
|
"""Grant a club-wide role to a member already affiliated with this club."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ClubRole
|
||||||
|
fields = ["member", "role"]
|
||||||
|
|
||||||
|
def __init__(self, *args, club=None, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
# Never list members of other clubs -- this isn't a platform-wide picker.
|
||||||
|
self.fields["member"].queryset = Member.objects.filter(member_of__club=club).distinct()
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyCreateForm(forms.Form):
|
||||||
|
"""One new family in one go: a parent (who gets a login) and a child (who
|
||||||
|
doesn't). See members.services.family.register_family."""
|
||||||
|
|
||||||
|
parent_first_name = forms.CharField(label=_("Parent first name"))
|
||||||
|
parent_last_name = forms.CharField(label=_("Parent last name"))
|
||||||
|
parent_email = forms.EmailField(label=_("Parent email"), help_text=_("If this email has no account yet, one is created and they set a password via the reset link."))
|
||||||
|
|
||||||
|
child_first_name = forms.CharField(label=_("Child first name"))
|
||||||
|
child_last_name = forms.CharField(label=_("Child last name"))
|
||||||
|
child_date_of_birth = forms.DateField(label=_("Child date of birth"), required=False, widget=forms.DateInput(attrs={"type": "date"}))
|
||||||
|
|
||||||
|
|
||||||
|
class AddChildForm(forms.Form):
|
||||||
|
"""A family that needs one more child registered -- see
|
||||||
|
members.services.family.add_child_to_family."""
|
||||||
|
|
||||||
|
first_name = forms.CharField(label=_("First name"))
|
||||||
|
last_name = forms.CharField(label=_("Last name"))
|
||||||
|
date_of_birth = forms.DateField(label=_("Date of birth"), required=False, widget=forms.DateInput(attrs={"type": "date"}))
|
||||||
|
|
||||||
|
|
||||||
|
class AddParentForm(forms.Form):
|
||||||
|
"""A family that needs one more parent/guardian registered -- see
|
||||||
|
members.services.family.add_parent_to_family."""
|
||||||
|
|
||||||
|
email = forms.EmailField(label=_("Email address"), help_text=_("If this email has no account yet, one is created and they set a password via the reset link."))
|
||||||
|
first_name = forms.CharField(label=_("First name"), required=False)
|
||||||
|
last_name = forms.CharField(label=_("Last name"), required=False)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned = super().clean()
|
||||||
|
email = cleaned.get("email")
|
||||||
|
|
||||||
|
# Only a brand-new person needs a name; an existing member already has one.
|
||||||
|
if email and find_member_by_email(email) is None:
|
||||||
|
for field in ("first_name", "last_name"):
|
||||||
|
if not cleaned.get(field):
|
||||||
|
self.add_error(field, _("Required: this email has no account yet."))
|
||||||
|
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
class AttachToFamilyForm(forms.Form):
|
||||||
|
"""Link a standalone member into a family -- a new one, or an existing one they
|
||||||
|
turn out to belong to. See members.services.family.attach_to_family."""
|
||||||
|
|
||||||
|
role = forms.ChoiceField(label=_("Role"), choices=FamilyMembership.FamilyRole.choices)
|
||||||
|
family = forms.ModelChoiceField(label=_("Family"), queryset=Family.objects.none(), required=False, empty_label=_("— start a new family —"))
|
||||||
|
|
||||||
|
def __init__(self, *args, club=None, member=None, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
# Same scoping query as management.views.families_of_club -- inlined rather
|
||||||
|
# than imported, since that function lives in views.py, which imports this
|
||||||
|
# module (a module-level import back here would be circular).
|
||||||
|
queryset = Family.objects.filter(memberships__member__member_of__club=club).distinct()
|
||||||
|
if member is not None:
|
||||||
|
# Already a member of it -- offering it again would be a no-op re-add.
|
||||||
|
queryset = queryset.exclude(memberships__member=member)
|
||||||
|
self.fields["family"].queryset = queryset
|
||||||
|
|
||||||
|
|
||||||
|
class MemberImportUploadForm(forms.Form):
|
||||||
|
"""The mass-upload entry point -- one .xlsx file, built from the downloadable
|
||||||
|
template. See management.bulk_import.read_member_import_workbook."""
|
||||||
|
|
||||||
|
file = forms.FileField(label=_("Excel file"), help_text=_("Use the downloaded template — one row per member."))
|
||||||
|
|
||||||
|
|
||||||
|
class GrantLoginForm(forms.Form):
|
||||||
|
"""A login-less family member (a child, typically) getting their own account --
|
||||||
|
see members.services.family.grant_login. Pre-filled from the member's contact
|
||||||
|
email where one is already on file; still editable, and required either way."""
|
||||||
|
|
||||||
|
email = forms.EmailField(label=_("Email"), help_text=_("They'll set a password via the reset link the first time they sign in."))
|
||||||
|
|
||||||
|
def clean_email(self):
|
||||||
|
email = self.cleaned_data["email"]
|
||||||
|
if User.objects.filter(email__iexact=email).exists():
|
||||||
|
raise forms.ValidationError(_("This email is already in use."))
|
||||||
|
return email
|
||||||
|
|
||||||
|
|
||||||
|
class ClubMembershipForm(forms.ModelForm):
|
||||||
|
"""This season's standing -- shown and edited right on the member's own page,
|
||||||
|
since a Member has no club of its own without one. fee_amount is the only
|
||||||
|
money field here -- amount_paid is exclusively written by club.services.fees,
|
||||||
|
never hand-edited."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ClubMembership
|
||||||
|
fields = ["license", "status", "fee_status", "fee_amount"]
|
||||||
|
|
||||||
|
|
||||||
|
class RecordFeePaymentForm(forms.Form):
|
||||||
|
"""Money received against one membership's fee -- see club.services.fees.record_payment.
|
||||||
|
Reusable for any amount, partial or the exact remaining balance; "Mark fully
|
||||||
|
paid" (management.views.MembershipMarkFullyPaidView) skips this form entirely
|
||||||
|
and settles the balance directly in one click."""
|
||||||
|
|
||||||
|
amount = forms.DecimalField(label=_("Amount"), max_digits=10, decimal_places=2, min_value=Decimal("0.01"))
|
||||||
|
method = forms.ChoiceField(label=_("Method"), choices=FeePayment.Method.choices)
|
||||||
|
reference = forms.CharField(label=_("Reference"), required=False, help_text=_("Bank reference, transaction id — whatever lets you find this again."))
|
||||||
|
note = forms.CharField(label=_("Note"), required=False, widget=forms.Textarea(attrs={"rows": 2}))
|
||||||
30
management/pdf.py
Normal file
30
management/pdf.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""HTML-to-PDF rendering for exports (currently just the memberships list).
|
||||||
|
|
||||||
|
Same lazy-import shape as billing.services.invoices.render_pdf -- WeasyPrint binds
|
||||||
|
to native pango/cairo libraries, and a machine without them must still be able to
|
||||||
|
run the app, the tests and every other page, so the import happens here, not at
|
||||||
|
module scope, and only fails when someone actually asks for a PDF. Kept separate
|
||||||
|
from billing's version rather than shared: the two exports have nothing else in
|
||||||
|
common, and sharing would make one app depend on the other's error type for no
|
||||||
|
real benefit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
|
||||||
|
|
||||||
|
class PDFExportError(Exception):
|
||||||
|
"""Raised when WeasyPrint's native libraries aren't available."""
|
||||||
|
|
||||||
|
|
||||||
|
def render_pdf(html: str) -> bytes:
|
||||||
|
try:
|
||||||
|
from weasyprint import HTML
|
||||||
|
except (ImportError, OSError) as error:
|
||||||
|
raise PDFExportError("PDF rendering needs the native pango/cairo libraries (on macOS: brew install pango).") from error
|
||||||
|
|
||||||
|
return HTML(string=html).write_pdf()
|
||||||
|
|
||||||
|
|
||||||
|
def membership_list_pdf(context: dict) -> bytes:
|
||||||
|
html = render_to_string("management/membership_list_pdf.html", context)
|
||||||
|
return render_pdf(html)
|
||||||
92
management/templates/management/_family_members_table.html
Normal file
92
management/templates/management/_family_members_table.html
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
{% load lucide ui i18n %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
Shared by family_detail.html and member_detail.html's own Family panel -- one
|
||||||
|
table design for "everyone in this family", included with `group` (a dict from
|
||||||
|
management.views.group_by_family: family/guardians/children/others/all) and
|
||||||
|
`family_role_choices` (FamilyMembership.FamilyRole.choices, for the role dropdown).
|
||||||
|
`next_url`, if passed, is where a role change redirects back to -- member_detail.html
|
||||||
|
passes its own URL so admins land back on the member they were viewing; family_detail.html
|
||||||
|
leaves it unset, since staying on the family page is already the right place there.
|
||||||
|
{% endcomment %}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Last name" %}</th>
|
||||||
|
<th>{% trans "First name" %}</th>
|
||||||
|
<th>{% trans "Email" %}</th>
|
||||||
|
<th>{% trans "Type" %}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for person in group.all %}
|
||||||
|
<tr>
|
||||||
|
<td><a class="link link-hover" href="{% url 'management:member_detail' person.pk %}">{{ person.last_name }}</a></td>
|
||||||
|
<td>{{ person.first_name }}</td>
|
||||||
|
<td>{{ person.contact_email|default:"-" }}</td>
|
||||||
|
<td>
|
||||||
|
{% if is_club_admin %}
|
||||||
|
<form method="post" action="{% url 'management:family_membership_role_update' group.family.pk person.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% if next_url %}<input type="hidden" name="next" value="{{ next_url }}">{% endif %}
|
||||||
|
<select name="role" class="select select-bordered select-sm" onchange="this.form.requestSubmit()" aria-label="{% trans 'Role in family' %}">
|
||||||
|
{% for value, label in family_role_choices %}
|
||||||
|
<option value="{{ value }}" {% if value == person.role_in_family %}selected{% endif %}>{{ label|capfirst }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-sm badge-ghost">{{ person.role_in_family_display|capfirst }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
{% if is_club_admin %}
|
||||||
|
<div class="flex justify-end gap-1">
|
||||||
|
{% if person.grant_login_form %}
|
||||||
|
<button class="btn btn-sm btn-outline btn-neutral" type="button" onclick="document.getElementById('grant_login_modal_{{ group.family.pk }}_{{ person.pk }}').showModal()" aria-label="{% trans 'Grant login' %}">
|
||||||
|
{% lucide "key-round" size=14 %} {% trans "Grant login" %}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<a class="btn btn-sm btn-outline btn-neutral" href="{% url 'management:member_detail' person.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
|
||||||
|
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('remove_family_modal_{{ group.family.pk }}_{{ person.pk }}').showModal()" aria-label="{% trans 'Remove from family' %}">
|
||||||
|
{% lucide "user-x" size=14 %} {% trans "Remove" %}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('member_delete_modal_{{ group.family.pk }}_{{ person.pk }}').showModal()" aria-label="{% trans 'Delete' %}">
|
||||||
|
{% lucide "trash-2" size=14 %} {% trans "Delete" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if is_club_admin %}
|
||||||
|
{% trans "Delete member" as delete_member_title %}
|
||||||
|
{% trans "Delete" as delete_label %}
|
||||||
|
{% trans "Remove from family" as remove_from_family_title %}
|
||||||
|
{% trans "Remove" as remove_label %}
|
||||||
|
{% trans "Grant login" as grant_login_title %}
|
||||||
|
{% trans "Grant" as grant_login_submit_label %}
|
||||||
|
{% trans "They'll be able to sign in with this email once you confirm." as grant_login_blurb %}
|
||||||
|
{% with family_pk_str=group.family.pk|stringformat:"s" %}
|
||||||
|
{% for person in group.all %}
|
||||||
|
{% with person_pk_str=person.pk|stringformat:"s" %}
|
||||||
|
{% url 'management:member_delete' person.pk as member_delete_url %}
|
||||||
|
{% url 'management:member_detach_family' person.pk group.family.pk as detach_family_url %}
|
||||||
|
{% url 'management:member_grant_login' person.pk as grant_login_url %}
|
||||||
|
{% blocktrans with full_name=person.get_full_name asvar delete_member_body %}Delete {{ full_name }}? This also removes their club membership, roster spots, staff assignments, and family links. This cannot be undone.{% endblocktrans %}
|
||||||
|
{% blocktrans with full_name=person.get_full_name family_name=group.family asvar detach_family_body %}Remove {{ full_name }} from family {{ family_name }}? They become a standalone member - nothing else about them changes.{% endblocktrans %}
|
||||||
|
{% include "controlpanel/_confirm_modal.html" with modal_id="member_delete_modal_"|add:family_pk_str|add:"_"|add:person_pk_str title=delete_member_title body=delete_member_body action_url=member_delete_url submit_label=delete_label %}
|
||||||
|
{% include "controlpanel/_confirm_modal.html" with modal_id="remove_family_modal_"|add:family_pk_str|add:"_"|add:person_pk_str title=remove_from_family_title body=detach_family_body action_url=detach_family_url submit_label=remove_label submit_icon="user-x" %}
|
||||||
|
{% if person.grant_login_form %}
|
||||||
|
{% include "controlpanel/_modal_form.html" with modal_id="grant_login_modal_"|add:family_pk_str|add:"_"|add:person_pk_str title=grant_login_title form=person.grant_login_form action_url=grant_login_url submit_label=grant_login_submit_label submit_icon="key-round" blurb=grant_login_blurb %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endif %}
|
||||||
33
management/templates/management/_generic_list.html
Normal file
33
management/templates/management/_generic_list.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
Shared placeholder for every entity that doesn't have its own list template yet
|
||||||
|
(see management.views.StubListMixin) -- a plain one-column table of __str__, just
|
||||||
|
enough to prove the query scoping and permission gate work. Not meant to stay this
|
||||||
|
bare once each section gets its own page.
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
|
{% block heading %}{{ page_title }}{% endblock heading %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<tbody>
|
||||||
|
{% for object in object_list %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ object }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-center opacity-60">{% trans "Nothing here yet." %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock panel %}
|
||||||
49
management/templates/management/_nav_items.html
Normal file
49
management/templates/management/_nav_items.html
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{% load i18n lucide %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
The management nav, in one place: the sidebar renders it on a wide screen and the
|
||||||
|
collapsed menu renders it on a narrow one. Admin-only sections are hidden here for
|
||||||
|
plain staff -- the views are gated regardless (ClubAdminRequiredMixin), this is
|
||||||
|
just so the nav never shows a link they can't follow.
|
||||||
|
|
||||||
|
`nav` (management.context_processors.active_nav_section) is the current page's
|
||||||
|
section, derived from the resolved URL name -- `menu-active` is daisyUI's active
|
||||||
|
state, same convention as controlpanel/templates/controlpanel/_nav_items.html.
|
||||||
|
{% endcomment %}
|
||||||
|
<li>
|
||||||
|
<a class="{% if nav == 'home' %}menu-active{% endif %}" href="{% url 'management:home' %}">{% lucide "layout-dashboard" size=16 %} {% trans "Dashboard" %}</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="menu-title">{% trans "People" %}</li>
|
||||||
|
<li><a class="{% if nav == 'member_list' %}menu-active{% endif %}" href="{% url 'management:member_list' %}">{% lucide "users" size=16 %} {% trans "Members" %}</a></li>
|
||||||
|
{% if is_club_admin %}
|
||||||
|
<li><a class="{% if nav == 'membership_list' %}menu-active{% endif %}" href="{% url 'management:membership_list' %}">{% lucide "wallet" size=16 %} {% trans "Memberships" %}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if is_club_admin %}
|
||||||
|
<li class="menu-title">{% trans "Club setup" %}</li>
|
||||||
|
<li><a class="{% if nav == 'position_list' %}menu-active{% endif %}" href="{% url 'management:position_list' %}">{% lucide "tags" size=16 %} {% trans "Positions" %}</a></li>
|
||||||
|
<li><a class="{% if nav == 'role_list' %}menu-active{% endif %}" href="{% url 'management:role_list' %}">{% lucide "shield-check" size=16 %} {% trans "Roles" %}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<li class="menu-title">{% trans "Teams" %}</li>
|
||||||
|
<li><a class="{% if nav == 'team_list' %}menu-active{% endif %}" href="{% url 'management:team_list' %}">{% lucide "shirt" size=16 %} {% trans "Teams" %}</a></li>
|
||||||
|
<li><a class="{% if nav == 'roster_list' %}menu-active{% endif %}" href="{% url 'management:roster_list' %}">{% lucide "clipboard-list" size=16 %} {% trans "Roster" %}</a></li>
|
||||||
|
<li><a class="{% if nav == 'staff_list' %}menu-active{% endif %}" href="{% url 'management:staff_list' %}">{% lucide "hard-hat" size=16 %} {% trans "Staff" %}</a></li>
|
||||||
|
|
||||||
|
<li class="menu-title">{% trans "Calendar" %}</li>
|
||||||
|
<li><a class="{% if nav == 'event_list' %}menu-active{% endif %}" href="{% url 'management:event_list' %}">{% lucide "calendar" size=16 %} {% trans "Events" %}</a></li>
|
||||||
|
<li><a class="{% if nav == 'event_series_list' %}menu-active{% endif %}" href="{% url 'management:event_series_list' %}">{% lucide "repeat" size=16 %} {% trans "Event series" %}</a></li>
|
||||||
|
<li><a class="{% if nav == 'location_list' %}menu-active{% endif %}" href="{% url 'management:location_list' %}">{% lucide "map-pin" size=16 %} {% trans "Locations" %}</a></li>
|
||||||
|
<li><a class="{% if nav == 'opponent_list' %}menu-active{% endif %}" href="{% url 'management:opponent_list' %}">{% lucide "swords" size=16 %} {% trans "Opponents" %}</a></li>
|
||||||
|
|
||||||
|
{% if is_club_admin %}
|
||||||
|
<li class="menu-title">{% trans "Shop" %}</li>
|
||||||
|
<li><a class="{% if nav == 'product_list' %}menu-active{% endif %}" href="{% url 'management:product_list' %}">{% lucide "package" size=16 %} {% trans "Products" %}</a></li>
|
||||||
|
<li><a class="{% if nav == 'order_list' %}menu-active{% endif %}" href="{% url 'management:order_list' %}">{% lucide "shopping-cart" size=16 %} {% trans "Orders" %}</a></li>
|
||||||
|
<li><a class="{% if nav == 'discount_list' %}menu-active{% endif %}" href="{% url 'management:discount_list' %}">{% lucide "percent" size=16 %} {% trans "Discounts" %}</a></li>
|
||||||
|
<li><a class="{% if nav == 'invoice_list' %}menu-active{% endif %}" href="{% url 'management:invoice_list' %}">{% lucide "receipt" size=16 %} {% trans "Invoices" %}</a></li>
|
||||||
|
|
||||||
|
<li class="menu-title">{% trans "Forms" %}</li>
|
||||||
|
<li><a class="{% if nav == 'form_list' %}menu-active{% endif %}" href="{% url 'management:form_list' %}">{% lucide "clipboard-list" size=16 %} {% trans "Forms" %}</a></li>
|
||||||
|
{% endif %}
|
||||||
42
management/templates/management/base.html
Normal file
42
management/templates/management/base.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{% extends "_club_base.html" %}
|
||||||
|
{% load i18n lucide %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
The club-staff shell: team managers, coaches and admins only (never parents or
|
||||||
|
players -- see club.mixins.ClubStaffRequiredMixin). Mirrors controlpanel/base.html's
|
||||||
|
block structure, extending the club's own skin instead of the platform's.
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
|
{% block head_title %}
|
||||||
|
{% block section_title %}{% trans "Management" %}{% endblock section_title %}
|
||||||
|
{% endblock head_title %}
|
||||||
|
|
||||||
|
{% block menu %}
|
||||||
|
<aside class="hidden w-64 shrink-0 overflow-y-auto border-r border-base-300 bg-base-100 lg:block">
|
||||||
|
<ul class="menu w-full gap-1 p-3 mt-4">
|
||||||
|
{% include "management/_nav_items.html" %}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
{% endblock menu %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<div class="mb-6 flex flex-wrap flex-row items-center justify-between gap-3">
|
||||||
|
<div class="flex flex-col gap-2 grow">
|
||||||
|
<h1 class="text-3xl font-bold">
|
||||||
|
{% block heading %}{% trans "Management" %}{% endblock heading %}
|
||||||
|
</h1>
|
||||||
|
<span class="text-sm text-base-content/50">{% block subheading %}{% endblock subheading %}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{% block actions %}{% endblock actions %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Below `lg` the sidebar is hidden, so the same links appear here rather than nowhere. #}
|
||||||
|
<ul class="menu menu-horizontal mb-6 w-full gap-1 overflow-x-auto rounded-box bg-base-100 lg:hidden">
|
||||||
|
{% include "management/_nav_items.html" %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{% block panel %}{% endblock panel %}
|
||||||
|
{% endblock main %}
|
||||||
31
management/templates/management/family_detail.html
Normal file
31
management/templates/management/family_detail.html
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load lucide i18n %}
|
||||||
|
|
||||||
|
{% block heading %}{% blocktrans with name=family %}{{ name }} Family{% endblocktrans %}{% endblock heading %}
|
||||||
|
|
||||||
|
{% block actions %}
|
||||||
|
{% if is_club_admin %}
|
||||||
|
{% trans "Add parent" as add_parent_label %}
|
||||||
|
{% trans "Add child" as add_child_label %}
|
||||||
|
<button class="btn btn-outline btn-neutral gap-2" type="button" onclick="document.getElementById('add_parent_modal').showModal()">{% lucide "user-plus" size=16 %} {{ add_parent_label }}</button>
|
||||||
|
<button class="btn btn-outline btn-neutral gap-2" type="button" onclick="document.getElementById('add_child_modal').showModal()">{% lucide "baby" size=16 %} {{ add_child_label }}</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock actions %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
{% include "management/_family_members_table.html" with group=group %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if is_club_admin %}
|
||||||
|
{% url 'management:family_add_parent' family.pk as add_parent_url %}
|
||||||
|
{% url 'management:family_add_child' family.pk as add_child_url %}
|
||||||
|
{% trans "Add parent" as add_parent_title %}
|
||||||
|
{% trans "Add child" as add_child_title %}
|
||||||
|
{% trans "If this email has no account yet, one is created and they set a password via the reset link." as add_parent_blurb %}
|
||||||
|
{% include "controlpanel/_modal_form.html" with modal_id="add_parent_modal" title=add_parent_title form=add_parent_form action_url=add_parent_url submit_label=add_parent_title submit_icon="user-plus" blurb=add_parent_blurb %}
|
||||||
|
{% include "controlpanel/_modal_form.html" with modal_id="add_child_modal" title=add_child_title form=add_child_form action_url=add_child_url submit_label=add_child_title submit_icon="baby" %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock panel %}
|
||||||
43
management/templates/management/family_form.html
Normal file
43
management/templates/management/family_form.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load lucide ui i18n %}
|
||||||
|
|
||||||
|
{% block heading %}{% trans "Add family" %}{% endblock heading %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<div class="card w-full bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{% for error in form.non_field_errors %}
|
||||||
|
<div class="alert alert-error my-2">
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<h2 class="card-title text-base">{% lucide "user" size=18 %} {% trans "Parent / guardian" %}</h2>
|
||||||
|
<p class="text-sm opacity-70">{% trans "Gets a login -- they set a password via the reset link if this is a new email." %}</p>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{% form_field form.parent_first_name %}
|
||||||
|
{% form_field form.parent_last_name %}
|
||||||
|
{% form_field form.parent_email %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<h2 class="card-title text-base">{% lucide "baby" size=18 %} {% trans "Child" %}</h2>
|
||||||
|
<p class="text-sm opacity-70">{% trans "No login of their own -- the parent above manages things for them." %}</p>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{% form_field form.child_first_name %}
|
||||||
|
{% form_field form.child_last_name %}
|
||||||
|
{% form_field form.child_date_of_birth %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions justify-start pt-2 mt-2">
|
||||||
|
<a class="btn btn-outline btn-neutral gap-2" href="{% url "management:member_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
||||||
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock panel %}
|
||||||
245
management/templates/management/home.html
Normal file
245
management/templates/management/home.html
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load i18n static lucide %}
|
||||||
|
|
||||||
|
{% block heading %}{{ club.name }}{% endblock heading %}
|
||||||
|
{% block subheading %}{% trans "Management" %}{% endblock subheading %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
{% if attention.no_season %}
|
||||||
|
<div class="alert alert-warning mb-6">
|
||||||
|
{% lucide "calendar-x" size=20 %}
|
||||||
|
<span>
|
||||||
|
{% trans "No season covers today, so this club cannot take a signup or schedule a match. Nothing errors — it is simply inert." %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
The club's own numbers that should be zero -- same attention/chart/stat-group
|
||||||
|
data controlpanel/club_detail.html shows a platform admin drilling into this
|
||||||
|
club from outside; club_attention/club_charts/club_statistics are already
|
||||||
|
club-scoped, so this is that same data for the club's own staff. Nothing
|
||||||
|
money-shaped renders below for non-admins, same line the nav already draws
|
||||||
|
around the Shop section.
|
||||||
|
{% endcomment %}
|
||||||
|
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-6">
|
||||||
|
<div class="card bg-base-100 shadow border-l-4 {% if attention.teams_without_manager %}border-error{% else %}border-success{% endif %}">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-x" size=16 %} {% trans "Teams without coach" %}</div>
|
||||||
|
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.teams_without_manager }}</div>
|
||||||
|
<div class="text-xs opacity-60">{% trans "Teams nobody can pick a squad for" %}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow border-l-4 {% if attention.unrostered %}border-warning{% else %}border-success{% endif %}">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-minus" size=16 %} {% trans "Unrostered members" %}</div>
|
||||||
|
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.unrostered }}</div>
|
||||||
|
<div class="text-xs opacity-60">{% trans "Active members on no team" %}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow border-l-4 {% if attention.pending_approvals %}border-warning{% else %}border-success{% endif %}">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "clock" size=16 %} {% trans "Pending" %}</div>
|
||||||
|
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.pending_approvals }}</div>
|
||||||
|
<div class="text-xs opacity-60">{% trans "Memberships awaiting approval" %}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow border-l-4 border-info">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "sparkles" size=16 %} {% trans "New members" %}</div>
|
||||||
|
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.new_members }}</div>
|
||||||
|
<div class="text-xs opacity-60">{% trans "First season at this club" %}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow border-l-4 {% if attention.renewal_rate is None %}border-info{% elif attention.renewal_rate < 30 %}border-error{% elif attention.renewal_rate < 65 %}border-warning{% else %}border-success{% endif %}">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "repeat" size=16 %} {% trans "Renewal rate" %}</div>
|
||||||
|
<div class="text-4xl font-bold tabular-nums font-mono">
|
||||||
|
{% if attention.renewal_rate is None %}
|
||||||
|
{% trans "N/A" %}
|
||||||
|
{% else %}
|
||||||
|
{{ attention.renewal_rate }}%
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs opacity-60">
|
||||||
|
{% if attention.renewal_rate is None %}
|
||||||
|
{% trans "No previous season" %}
|
||||||
|
{% else %}
|
||||||
|
<progress class="progress w-full {% if attention.renewal_rate < 30 %}progress-error{% elif attention.renewal_rate < 65 %}progress-warning{% else %}progress-success{% endif %}" value="{{ attention.renewal_rate }}"
|
||||||
|
max="100"></progress>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow border-l-4 {% if attention.attendance.turnout is None %}border-info{% elif attention.attendance.turnout < 30 %}border-error{% elif attention.attendance.turnout < 65 %}border-warning{% else %}border-success{% endif %}">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-check" size=16 %} {% trans "Attendance rate" %}</div>
|
||||||
|
<div class="text-4xl font-bold tabular-nums font-mono">
|
||||||
|
{% if attention.attendance.turnout is None %}
|
||||||
|
{% trans "N/A" %}
|
||||||
|
{% else %}
|
||||||
|
{{ attention.attendance.turnout }}%
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs opacity-60">
|
||||||
|
{% if attention.attendance.turnout is None %}
|
||||||
|
{% trans "No events this season" %}
|
||||||
|
{% else %}
|
||||||
|
<progress class="progress w-full {% if attention.attendance.turnout < 30 %}progress-error{% elif attention.attendance.turnout < 65 %}progress-warning{% else %}progress-success{% endif %}"
|
||||||
|
value="{{ attention.attendance.turnout }}" max="100"></progress>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow mb-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="card-title text-base">{% lucide "calendar" size=18 %} {% trans "Upcoming events" %}</h2>
|
||||||
|
<a class="btn btn-outline btn-neutral btn-sm gap-2" href="{% url 'management:event_list' %}">{% lucide "arrow-right" size=14 %} {% trans "View all" %}</a>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<tbody>
|
||||||
|
{% for event in upcoming_events %}
|
||||||
|
<tr>
|
||||||
|
<td class="whitespace-nowrap">{{ event.start|date:"D j M, H:i" }}</td>
|
||||||
|
<td>{{ event.title }}</td>
|
||||||
|
<td>{{ event.get_kind_display|capfirst }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-center opacity-60">{% trans "Nothing scheduled." %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6 grid gap-4 lg:grid-cols-2">
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-base">{% lucide "user-plus" size=18 %} {% trans "Signups per month" %}</h2>
|
||||||
|
<p class="text-sm opacity-70">{% trans "New members against returning ones." %}</p>
|
||||||
|
<div class="h-56">
|
||||||
|
<canvas id="signups-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if is_club_admin %}
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-base">{% lucide "wallet" size=18 %} {% trans "Club fee status this season" %}</h2>
|
||||||
|
<div class="h-56">
|
||||||
|
<canvas id="fees-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-4">
|
||||||
|
{% for group in groups %}
|
||||||
|
{% if group.title != "Shop" or is_club_admin %}
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-base">{% lucide group.icon size=18 %} {{ group.title }}</h2>
|
||||||
|
<dl class="divide-y divide-base-200">
|
||||||
|
{% for label, value in group.stats %}
|
||||||
|
<div class="flex items-center justify-between py-2">
|
||||||
|
<dt class="text-sm opacity-70">{{ label }}</dt>
|
||||||
|
<dd class="font-semibold tabular-nums font-mono">{% if group.title == "Shop" and label == "Outstanding" or label == "Revenue" %}€{% endif %}{{ value }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock panel %}
|
||||||
|
|
||||||
|
{% block extra_body %}
|
||||||
|
{% trans "New" as new_label %}
|
||||||
|
{% trans "Returning" as returning_label %}
|
||||||
|
{{ charts|json_script:"chart-data" }}
|
||||||
|
<script src="{% static 'js/chart.js' %}"></script>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const data = JSON.parse(document.getElementById("chart-data").textContent);
|
||||||
|
const css = (name, fallback) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
|
||||||
|
const feesCanvas = document.getElementById("fees-chart");
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
const ink = css("--color-base-content", "#333");
|
||||||
|
const grid = "color-mix(in oklab, " + ink + " 15%, transparent)";
|
||||||
|
|
||||||
|
// Stacked: the bar height stays "signups this month" while the split shows where
|
||||||
|
// they came from. Side-by-side bars would answer a different question.
|
||||||
|
const signups = new Chart(document.getElementById("signups-chart"), {
|
||||||
|
type: "bar",
|
||||||
|
data: {
|
||||||
|
labels: data.signups.map((point) => point.month),
|
||||||
|
datasets: [
|
||||||
|
{label: "{{ new_label|escapejs }}", data: data.signups.map((point) => point.new), backgroundColor: css("--color-primary", "#4f46e5")},
|
||||||
|
{label: "{{ returning_label|escapejs }}", data: data.signups.map((point) => point.returning), backgroundColor: css("--color-accent", "#0ea5e9")},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {legend: {position: "bottom", labels: {color: ink}}},
|
||||||
|
scales: {
|
||||||
|
x: {stacked: true, ticks: {color: ink}, grid: {color: grid}},
|
||||||
|
y: {stacked: true, beginAtZero: true, ticks: {color: ink, precision: 0}, grid: {color: grid}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const charts = [signups];
|
||||||
|
|
||||||
|
// Absent for non-admins -- the fee chart is financial, same gate as the Shop
|
||||||
|
// nav section.
|
||||||
|
if (feesCanvas) {
|
||||||
|
// Colour carries the meaning here — unpaid must read as a problem, waived
|
||||||
|
// must not — so the slices are pinned to the semantic theme colours, in order.
|
||||||
|
charts.push(new Chart(feesCanvas, {
|
||||||
|
type: "pie",
|
||||||
|
data: {
|
||||||
|
labels: data.fees.map((slice) => slice.label),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: data.fees.map((slice) => slice.value),
|
||||||
|
backgroundColor: [css("--color-success", "#16a34a"), css("--color-warning", "#f59e0b"), css("--color-error", "#dc2626"), css("--color-neutral", "#6b7280")],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {legend: {position: "right", labels: {color: ink}}},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return charts;
|
||||||
|
};
|
||||||
|
|
||||||
|
let charts = render();
|
||||||
|
|
||||||
|
// "auto" removes data-theme entirely, so watch the attribute rather than a click.
|
||||||
|
new MutationObserver(() => {
|
||||||
|
charts.forEach((chart) => chart.destroy());
|
||||||
|
charts = render();
|
||||||
|
}).observe(document.documentElement, {attributes: true, attributeFilter: ["data-theme"]});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock extra_body %}
|
||||||
157
management/templates/management/member_detail.html
Normal file
157
management/templates/management/member_detail.html
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load lucide ui i18n %}
|
||||||
|
|
||||||
|
{% block heading %}{{ member.get_full_name }}{% endblock heading %}
|
||||||
|
{% block subheading %}{{ member.contact_email }}{% endblock subheading %}
|
||||||
|
|
||||||
|
{% block actions %}
|
||||||
|
{% if is_club_admin %}
|
||||||
|
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:member_update' member.pk %}">{% lucide "pencil" size=16 %} {% trans "Edit" %}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock actions %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-base">{% lucide "user" size=18 %} {% trans "Personal information" %}</h2>
|
||||||
|
<dl class="divide-y divide-base-200">
|
||||||
|
<div class="flex items-center justify-between py-2">
|
||||||
|
<dt class="text-sm opacity-70">{% trans "Date of birth" %}</dt>
|
||||||
|
<dd class="font-semibold">{{ member.date_of_birth|default:"-" }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between py-2">
|
||||||
|
<dt class="text-sm opacity-70">{% trans "Phone" %}</dt>
|
||||||
|
<dd class="font-semibold">{{ member.phone.as_international|default:"-" }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between py-2">
|
||||||
|
<dt class="text-sm opacity-70">{% trans "Emergency phone" %}</dt>
|
||||||
|
<dd class="font-semibold">{{ member.emergency_phone.as_international|default:"-" }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="flex flex-row gap-2 items-center mt-2">
|
||||||
|
{% if member.phone %}
|
||||||
|
<a class="btn btn-sm btn-outline btn-primary grow" href="tel:{{ member.phone.as_international }}">{% lucide "phone" size=14 %} {% trans "Call" %}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if member.emergency_phone %}
|
||||||
|
<a class="btn btn-sm btn-outline btn-error grow" href="tel:{{ member.emergency_phone.as_international }}">{% lucide "shield-alert" size=14 %} {% trans "Emergency call" %}</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
A Member has no club of its own -- ClubMembership is the only thing that
|
||||||
|
actually ties this person to *this* club, so it gets its own panel: the
|
||||||
|
current season's standing, plus every season they've been part of.
|
||||||
|
{% endcomment %}
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-base">{% lucide "id-card" size=18 %} {% trans "Club membership" %}</h2>
|
||||||
|
{% if current_membership %}
|
||||||
|
<dl class="divide-y divide-base-200">
|
||||||
|
<div class="flex items-center justify-between py-2">
|
||||||
|
<dt class="text-sm opacity-70">{% trans "License" %}</dt>
|
||||||
|
<dd class="font-semibold">{{ current_membership.license|default:"-" }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between py-2">
|
||||||
|
<dt class="text-sm opacity-70">{% trans "Status" %}</dt>
|
||||||
|
<dd class="font-semibold">{{ current_membership.get_status_display }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between py-2">
|
||||||
|
<dt class="text-sm opacity-70">{% trans "Fee status" %}</dt>
|
||||||
|
<dd class="font-semibold">{{ current_membership.get_fee_status_display }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm opacity-60">{% trans "Not rostered for the current season." %}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if membership_history %}
|
||||||
|
<div class="divider"></div>
|
||||||
|
<h3 class="text-sm font-semibold opacity-70">{% trans "Season history" %}</h3>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Season" %}</th>
|
||||||
|
<th>{% trans "Status" %}</th>
|
||||||
|
<th>{% trans "Fee status" %}</th>
|
||||||
|
<th>{% trans "License" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for membership in membership_history %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ membership.season.start_date|date:"Y" }} - {{ membership.season.end_date|date:"Y" }}</td>
|
||||||
|
<td>{{ membership.get_status_display }}</td>
|
||||||
|
<td>{{ membership.get_fee_status_display }}</td>
|
||||||
|
<td>{{ membership.license|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% trans "Add parent" as add_parent_label %}
|
||||||
|
{% trans "Add child" as add_child_label %}
|
||||||
|
{% trans "If this email has no account yet, one is created and they set a password via the reset link." as add_parent_blurb %}
|
||||||
|
{% blocktrans with short_name=member.get_short_name asvar remove_from_family_label %}Remove {{ short_name }} from family{% endblocktrans %}
|
||||||
|
{% trans "Remove from family" as remove_from_family_title %}
|
||||||
|
{% trans "Remove" as remove_label %}
|
||||||
|
{% for family_group in family_groups %}
|
||||||
|
<div class="card bg-base-100 shadow mt-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="card-title text-base">
|
||||||
|
{% lucide "users" size=18 %}
|
||||||
|
<a class="link link-hover" href="{% url 'management:family_detail' family_group.family.pk %}">{{ family_group.family }}</a>
|
||||||
|
</h2>
|
||||||
|
{% if is_club_admin %}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button class="btn btn-outline btn-neutral btn-sm gap-2" type="button" onclick="document.getElementById('{{ family_group.family.pk|dom_id:"add_parent_modal" }}').showModal()">{% lucide "user-plus" size=14 %} {{ add_parent_label }}</button>
|
||||||
|
<button class="btn btn-outline btn-neutral btn-sm gap-2" type="button" onclick="document.getElementById('{{ family_group.family.pk|dom_id:"add_child_modal" }}').showModal()">{% lucide "baby" size=14 %} {{ add_child_label }}</button>
|
||||||
|
<button class="btn btn-outline btn-error btn-sm gap-2" type="button" onclick="document.getElementById('{{ family_group.family.pk|dom_id:"detach_family_modal" }}').showModal()">{% lucide "user-x" size=14 %} {{ remove_from_family_label }}</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% include "management/_family_members_table.html" with group=family_group next_url=request.path %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if is_club_admin %}
|
||||||
|
{% url 'management:family_add_parent' family_group.family.pk as add_parent_url %}
|
||||||
|
{% url 'management:family_add_child' family_group.family.pk as add_child_url %}
|
||||||
|
{% url 'management:member_detach_family' member.pk family_group.family.pk as detach_family_url %}
|
||||||
|
{% blocktrans with full_name=member.get_full_name family_name=family_group.family asvar detach_family_body %}Remove {{ full_name }} from {{ family_name }}? They become a standalone member -- nothing else about them changes.{% endblocktrans %}
|
||||||
|
{% include "controlpanel/_modal_form.html" with modal_id=family_group.family.pk|dom_id:"add_parent_modal" title=add_parent_label form=add_parent_form action_url=add_parent_url submit_label=add_parent_label submit_icon="user-plus" blurb=add_parent_blurb %}
|
||||||
|
{% include "controlpanel/_modal_form.html" with modal_id=family_group.family.pk|dom_id:"add_child_modal" title=add_child_label form=add_child_form action_url=add_child_url submit_label=add_child_label submit_icon="baby" %}
|
||||||
|
{% include "controlpanel/_confirm_modal.html" with modal_id=family_group.family.pk|dom_id:"detach_family_modal" title=remove_from_family_title body=detach_family_body action_url=detach_family_url submit_label=remove_label submit_icon="user-x" %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if is_club_admin %}
|
||||||
|
<div class="card bg-base-100 shadow mt-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="card-title text-base">{% lucide "users" size=18 %} {% trans "Family" %}</h2>
|
||||||
|
<button class="btn btn-outline btn-neutral btn-sm gap-2" type="button" onclick="document.getElementById('attach_family_modal').showModal()">{% lucide "user-plus" size=14 %} {% trans "Add to family" %}</button>
|
||||||
|
</div>
|
||||||
|
{% if not family_groups %}
|
||||||
|
<p class="text-sm opacity-60">{% trans "Not part of a family." %}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% url 'management:member_attach_family' member.pk as attach_family_url %}
|
||||||
|
{% trans "Add to family" as add_to_family_title %}
|
||||||
|
{% trans "Add" as add_label %}
|
||||||
|
{% trans "Pick an existing family, or leave it blank to start a new one." as attach_family_blurb %}
|
||||||
|
{% include "controlpanel/_modal_form.html" with modal_id="attach_family_modal" title=add_to_family_title form=attach_to_family_form action_url=attach_family_url submit_label=add_label submit_icon="user-plus" blurb=attach_family_blurb %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock panel %}
|
||||||
62
management/templates/management/member_form.html
Normal file
62
management/templates/management/member_form.html
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load lucide ui i18n %}
|
||||||
|
|
||||||
|
{% block heading %}{% if update_view %}{% blocktrans with name=object %}Edit {{ name }}{% endblocktrans %}{% else %}{% trans "New member" %}{% endif %}{% endblock heading %}
|
||||||
|
|
||||||
|
{% block actions %}
|
||||||
|
{% if update_view %}
|
||||||
|
<button class="btn btn-outline btn-error gap-2" type="button" onclick="document.getElementById('member_delete_modal').showModal()">{% lucide "trash-2" size=16 %} {% trans "Delete" %}</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock actions %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<div class="card w-full bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{% for error in form.non_field_errors %}
|
||||||
|
<div class="alert alert-error my-2">
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{% for field in form %}
|
||||||
|
{% form_field field %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if membership_form %}
|
||||||
|
<div class="divider"></div>
|
||||||
|
<h2 class="card-title text-base">{% lucide "id-card" size=18 %} {% trans "This season" %}</h2>
|
||||||
|
|
||||||
|
{% for error in membership_form.non_field_errors %}
|
||||||
|
<div class="alert alert-error my-2">
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{% for field in membership_form %}
|
||||||
|
{% form_field field %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card-actions justify-start pt-2 mt-2">
|
||||||
|
<a class="btn btn-outline btn-neutral gap-2" href="{% if update_view %}{% url "management:member_detail" object.pk %}{% else %}{% url "management:member_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
||||||
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if update_view %}
|
||||||
|
{% url 'management:member_delete' object.pk as member_delete_url %}
|
||||||
|
{% trans "Delete member" as delete_member_title %}
|
||||||
|
{% trans "Delete" as delete_label %}
|
||||||
|
{% blocktrans with full_name=object.get_full_name asvar delete_member_body %}Delete {{ full_name }}? This also removes their club membership, roster spots, staff assignments, and family links. This cannot be undone.{% endblocktrans %}
|
||||||
|
{% include "controlpanel/_confirm_modal.html" with modal_id="member_delete_modal" title=delete_member_title body=delete_member_body action_url=member_delete_url submit_label=delete_label %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock panel %}
|
||||||
39
management/templates/management/member_import.html
Normal file
39
management/templates/management/member_import.html
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load lucide ui i18n %}
|
||||||
|
|
||||||
|
{% block heading %}{% trans "Mass upload members" %}{% endblock heading %}
|
||||||
|
|
||||||
|
{% block actions %}
|
||||||
|
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:member_import_template' %}">{% lucide "download" size=16 %} {% trans "Download template" %}</a>
|
||||||
|
{% endblock actions %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<div class="card w-full bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-sm opacity-70">
|
||||||
|
{% blocktrans %}Fill in the downloaded template — one row per member — then upload it
|
||||||
|
here. Nothing is created yet: you'll see exactly what will be added
|
||||||
|
before anything is saved.{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{% for error in form.non_field_errors %}
|
||||||
|
<div class="alert alert-error my-2">
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% for field in form %}
|
||||||
|
{% form_field field %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="card-actions justify-start pt-2 mt-2">
|
||||||
|
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:member_list' %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
||||||
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "upload" size=16 %} {% trans "Upload and preview" %}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock panel %}
|
||||||
68
management/templates/management/member_import_preview.html
Normal file
68
management/templates/management/member_import_preview.html
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load lucide i18n %}
|
||||||
|
|
||||||
|
{% block heading %}{% trans "Review import" %}{% endblock heading %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
{% if not season %}
|
||||||
|
<div class="alert alert-warning mb-4">
|
||||||
|
{% lucide "triangle-alert" size=16 %}
|
||||||
|
<span>{% trans "No active season — members will still be created, but won't be rostered for one until it exists." %}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-sm opacity-70">
|
||||||
|
{% blocktrans count counter=valid_count %}{{ counter }} member will be created.{% plural %}{{ counter }} members will be created.{% endblocktrans %}
|
||||||
|
{% if skipped_count %}{% blocktrans count counter=skipped_count %}{{ counter }} row will be skipped.{% plural %}{{ counter }} rows will be skipped.{% endblocktrans %}{% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Row" %}</th>
|
||||||
|
<th>{% trans "Last name" %}</th>
|
||||||
|
<th>{% trans "First name" %}</th>
|
||||||
|
<th>{% trans "Email" %}</th>
|
||||||
|
<th>{% trans "Outcome" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for result in results %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ result.line_number }}</td>
|
||||||
|
<td>{{ result.raw.last_name }}</td>
|
||||||
|
<td>{{ result.raw.first_name }}</td>
|
||||||
|
<td>{{ result.raw.email }}</td>
|
||||||
|
<td>
|
||||||
|
{% if result.member %}
|
||||||
|
<span class="badge badge-success badge-sm">{% trans "Will create" %}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-error badge-sm">{% trans "Skipped" %}</span>
|
||||||
|
<div class="text-xs opacity-70 mt-1">{{ result.errors|join:"; " }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center opacity-60">{% trans "No rows found in the uploaded file." %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions justify-start pt-2 mt-2">
|
||||||
|
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:member_import' %}">{% lucide "arrow-left" size=16 %} {% trans "Back" %}</a>
|
||||||
|
{% if valid_count %}
|
||||||
|
<form method="post" action="{% url 'management:member_import_confirm' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "check" size=16 %} {% blocktrans %}Confirm import ({{ valid_count }}){% endblocktrans %}</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock panel %}
|
||||||
102
management/templates/management/member_list.html
Normal file
102
management/templates/management/member_list.html
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load lucide ui i18n %}
|
||||||
|
|
||||||
|
{% block heading %}{% trans "Members" %}{% endblock heading %}
|
||||||
|
|
||||||
|
{% block actions %}
|
||||||
|
<a class="btn btn-outline btn-info gap-2" href="{% url 'management:member_import_template' %}" xmlns:management="http://www.w3.org/1999/xhtml">{% lucide "download" size=16 %} {% trans "Download upload template" %}</a>
|
||||||
|
{% if is_club_admin %}
|
||||||
|
<a class="btn btn-outline btn-info gap-2" href="{% url 'management:member_import' %}">{% lucide "upload" size=16 %} {% trans "Mass upload" %}</a>
|
||||||
|
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:family_create' %}">{% lucide "users" size=16 %} {% trans "Add family" %}</a>
|
||||||
|
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:member_create' %}">{% lucide "user-plus" size=16 %} {% trans "Add member" %}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock actions %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<form method="get" class="mb-4 flex gap-2">
|
||||||
|
<label class="input">
|
||||||
|
<span class="opacity-50">{% lucide "search" size=16 %}</span>
|
||||||
|
<input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Search members ...' %}" class="input input-bordered w-full max-w-xs">
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-outline btn-neutral gap-2" type="submit">{% lucide "search" size=16 %} {% trans "Search" %}</button>
|
||||||
|
{% if search %}
|
||||||
|
<a class="btn btn-neutral gap-2" href="{% url "management:member_list" %}">{% lucide "x" size=16 %} {% trans "Clear filter" %}</a>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Last name" %}</th>
|
||||||
|
<th>{% trans "First name" %}</th>
|
||||||
|
<th>{% trans "Email" %}</th>
|
||||||
|
<th>{% trans "Family" %}</th>
|
||||||
|
<th>{% trans "Status" %}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for member in members %}
|
||||||
|
<tr>
|
||||||
|
<td><a class="link link-hover" href="{% url 'management:member_detail' member.pk %}">{{ member.last_name }}</a></td>
|
||||||
|
<td>{{ member.first_name }}</td>
|
||||||
|
<td>{{ member.contact_email|default:"-" }}</td>
|
||||||
|
<td>
|
||||||
|
{% if member.family_memberships_display %}
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
{% for fm in member.family_memberships_display %}
|
||||||
|
<div>
|
||||||
|
<a class="btn btn-sm btn-outline btn-neutral" href="{% url 'management:family_detail' fm.family.pk %}">{% lucide "users" size=14 %} {{ fm.family }} {% lucide "arrow-right" size=14 %}</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="opacity-40">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if member.current_membership %}
|
||||||
|
<span class="badge badge-sm
|
||||||
|
{% if member.current_membership.status == "active" %}badge-success
|
||||||
|
{% elif member.current_membership.status == "pending" %}badge-warning
|
||||||
|
{% elif member.current_membership.status == "cancelled" %}badge-error
|
||||||
|
{% else %}badge-neutral{% endif %}">
|
||||||
|
{{ member.current_membership.get_status_display }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-sm badge-neutral">{% trans "unknown" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
{% if is_club_admin %}
|
||||||
|
<div class="flex justify-end gap-1">
|
||||||
|
<a class="btn btn-sm btn-outline btn-neutral" href="{% url 'management:member_detail' member.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
|
||||||
|
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ member.pk|dom_id:"member_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-center opacity-60">{% trans "No members yet." %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if is_club_admin %}
|
||||||
|
{% trans "Delete member" as delete_member_title %}
|
||||||
|
{% trans "Delete" as delete_label %}
|
||||||
|
{% for member in members %}
|
||||||
|
{% url 'management:member_delete' member.pk as member_delete_url %}
|
||||||
|
{% blocktrans with full_name=member.get_full_name asvar delete_member_body %}Delete {{ full_name }}? This also removes their club membership, roster spots, staff assignments, and family links. This cannot be undone.{% endblocktrans %}
|
||||||
|
{% include "controlpanel/_confirm_modal.html" with modal_id=member.pk|dom_id:"member_delete_modal" title=delete_member_title body=delete_member_body action_url=member_delete_url submit_label=delete_label %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock panel %}
|
||||||
226
management/templates/management/membership_list.html
Normal file
226
management/templates/management/membership_list.html
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load i18n lucide ui %}
|
||||||
|
|
||||||
|
{% block heading %}{% trans "Memberships" %}{% endblock heading %}
|
||||||
|
{% block subheading %}{% if current_season %}{% blocktrans %}Fee status for season{% endblocktrans %}{% endif %}{% endblock subheading %}
|
||||||
|
|
||||||
|
{% block actions %}
|
||||||
|
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:membership_export_pdf' %}?{{ request.GET.urlencode }}">{% lucide "file-down" size=16 %} {% trans "Export to PDF" %}</a>
|
||||||
|
{% endblock actions %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
{% if not current_season %}
|
||||||
|
<div class="alert alert-warning mb-6">
|
||||||
|
{% lucide "calendar-x" size=20 %}
|
||||||
|
<span>{% trans "No season covers today, so there's nothing to show fee status for yet." %}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-6">
|
||||||
|
<div class="card bg-base-100 shadow border-l-4 border-info">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "users" size=16 %} {% trans "Registered" %}</div>
|
||||||
|
<div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_total }}</div>
|
||||||
|
<div class="text-xs opacity-60">{% trans "This season" %}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card bg-base-100 shadow border-l-4 border-success">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "circle-check" size=16 %} {% trans "Paid" %}</div>
|
||||||
|
<div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_paid }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card bg-base-100 shadow border-l-4 {% if kpi_partial %}border-warning{% else %}border-success{% endif %}">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "circle-dashed" size=16 %} {% trans "Partially paid" %}</div>
|
||||||
|
<div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_partial }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card bg-base-100 shadow border-l-4 {% if kpi_unpaid %}border-error{% else %}border-success{% endif %}">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "circle-x" size=16 %} {% trans "Unpaid" %}</div>
|
||||||
|
<div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_unpaid }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card bg-base-100 shadow border-l-4 border-neutral">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "circle-check" size=16 %} {% trans "Waived" %}</div>
|
||||||
|
<div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_waived }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card bg-base-100 shadow border-l-4 {% if kpi_paid_rate is None %}border-info{% elif kpi_paid_rate < 50 %}border-error{% elif kpi_paid_rate < 85 %}border-warning{% else %}border-success{% endif %}">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "percent" size=16 %} {% trans "Paid rate" %}</div>
|
||||||
|
<div class="text-4xl font-bold tabular-nums font-mono">
|
||||||
|
{% if kpi_paid_rate is None %}{% trans "N/A" %}{% else %}{{ kpi_paid_rate }}%{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="get" class="mb-4">
|
||||||
|
<div class="flex flex-row items-center gap-2">
|
||||||
|
<label class="input">
|
||||||
|
<span class="opacity-50">{% lucide "search" size=16 %}</span>
|
||||||
|
<input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Search members ...' %}" class="input input-bordered">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<select name="season" class="select select-bordered">
|
||||||
|
{% for season in seasons %}
|
||||||
|
<option value="{{ season.pk }}" {% if season.pk == selected_season.pk %}selected{% endif %}>{% trans "Season" %} {{ season.start_date|date:"Y" }} - {{ season.end_date|date:"Y" }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select name="fee_status" class="select select-bordered">
|
||||||
|
<option value="not_paid" {% if selected_fee_status == "not_paid" %}selected{% endif %}>{% trans "Not paid (unpaid or partially)" %}</option>
|
||||||
|
{% for value, label in fee_status_choices %}
|
||||||
|
<option value="{{ value }}" {% if value == selected_fee_status %}selected{% endif %}>{{ label|capfirst }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
<option value="all" {% if selected_fee_status == "all" %}selected{% endif %}>{% trans "All" %}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select name="status" class="select select-bordered">
|
||||||
|
<option value="all" {% if selected_status == "all" %}selected{% endif %}>{% trans "All statuses" %}</option>
|
||||||
|
{% for value, label in status_choices %}
|
||||||
|
<option value="{{ value }}" {% if value == selected_status %}selected{% endif %}>{{ label|capfirst }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select name="team" class="select select-bordered">
|
||||||
|
<option value="" {% if not selected_team %}selected{% endif %}>{% trans "All teams" %}</option>
|
||||||
|
{% for team in teams %}
|
||||||
|
<option value="{{ team.pk }}" {% if team.pk|stringformat:"s" == selected_team %}selected{% endif %}>{{ team }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button class="btn btn-outline btn-neutral gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "Filter" %}</button>
|
||||||
|
<a class="btn btn-neutral gap-2" href="{% url 'management:membership_list' %}">{% lucide "x" size=16 %} {% trans "Reset" %}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" action="{% url 'management:membership_mark_paid' %}" id="membership-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-sm opacity-70 font-semibold">
|
||||||
|
{% blocktrans count counter=memberships|length %}{{ counter }} membership{% plural %}{{ counter }} memberships{% endblocktrans %}
|
||||||
|
</span>
|
||||||
|
<button class="btn btn-success btn-sm gap-2" type="submit">{% lucide "circle-check" size=14 %} {% trans "Mark selected as paid" %}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><input type="checkbox" class="checkbox" id="select-all-memberships"></th>
|
||||||
|
<th>{% trans "Last name" %}</th>
|
||||||
|
<th>{% trans "First name" %}</th>
|
||||||
|
<th>{% trans "Email" %}</th>
|
||||||
|
<th>{% trans "Family" %}</th>
|
||||||
|
<th>{% trans "Status" %}</th>
|
||||||
|
<th>{% trans "Fee status" %}</th>
|
||||||
|
<th>{% trans "Owed" %}</th>
|
||||||
|
<th>{% trans "Paid" %}</th>
|
||||||
|
<th>{% trans "License" %}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for membership in memberships %}
|
||||||
|
<tr>
|
||||||
|
<td><input type="checkbox" class="checkbox membership-row-checkbox" name="membership_ids" value="{{ membership.pk }}"></td>
|
||||||
|
<td><a class="link link-hover" href="{% url 'management:member_detail' membership.member.pk %}">{{ membership.member.last_name }}</a></td>
|
||||||
|
<td>{{ membership.member.first_name }}</td>
|
||||||
|
<td>{{ membership.member.contact_email|default:"-" }}</td>
|
||||||
|
<td>
|
||||||
|
{% if membership.member.family_memberships_display %}
|
||||||
|
{% for fm in membership.member.family_memberships_display %}
|
||||||
|
{{ fm.family }} {% if not forloop.last %},{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<span class="opacity-40">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-sm
|
||||||
|
{% if membership.status == "active" %}badge-success
|
||||||
|
{% elif membership.status == "pending" %}badge-warning
|
||||||
|
{% elif membership.status == "cancelled" %}badge-error
|
||||||
|
{% else %}badge-neutral{% endif %}">
|
||||||
|
{{ membership.get_status_display }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-sm
|
||||||
|
{% if membership.fee_status == "paid" %}badge-success
|
||||||
|
{% elif membership.fee_status == "partially_paid" %}badge-warning
|
||||||
|
{% elif membership.fee_status == "unpaid" %}badge-error
|
||||||
|
{% else %}badge-neutral{% endif %}">
|
||||||
|
{{ membership.get_fee_status_display }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="tabular-nums">€ {{ membership.fee_amount }}</td>
|
||||||
|
<td class="tabular-nums">
|
||||||
|
€ {{ membership.amount_paid }}
|
||||||
|
{% if membership.record_payment_form and membership.fee_amount %}
|
||||||
|
<div class="text-xs opacity-60">{% blocktrans with remaining=membership.remaining_balance_display %}{{ remaining }} left{% endblocktrans %}</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ membership.license|default:"-" }}</td>
|
||||||
|
<td class="text-right">
|
||||||
|
{% if membership.record_payment_form %}
|
||||||
|
<div class="flex justify-end gap-1">
|
||||||
|
<button class="btn btn-sm btn-outline btn-neutral" type="button" onclick="document.getElementById('{{ membership.pk|dom_id:"record_payment_modal" }}').showModal()">
|
||||||
|
{% lucide "receipt" size=12 %} {% trans "Record payment" %}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline btn-success" type="submit" form="{{ membership.pk|dom_id:"mark_fully_paid_form" }}">
|
||||||
|
{% lucide "circle-check" size=12 %} {% trans "Mark fully paid" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="11" class="text-center opacity-60">{% trans "Nobody matches these filters." %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% trans "Record payment" as record_payment_title %}
|
||||||
|
{% trans "Record" as record_payment_submit_label %}
|
||||||
|
{% for membership in memberships %}
|
||||||
|
{% if membership.record_payment_form %}
|
||||||
|
{# A standalone form, not nested inside the bulk form above -- its submit button lives in the table row and links back here via the form="..." attribute. #}
|
||||||
|
<form method="post" action="{% url 'management:membership_mark_fully_paid' membership.pk %}" id="{{ membership.pk|dom_id:"mark_fully_paid_form" }}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
</form>
|
||||||
|
{% url 'management:membership_record_payment' membership.pk as record_payment_url %}
|
||||||
|
{% blocktrans with name=membership.member asvar record_payment_blurb %}For {{ name }}. Any amount is fine -- partial payments accumulate until the fee is settled.{% endblocktrans %}
|
||||||
|
{% include "controlpanel/_modal_form.html" with modal_id=membership.pk|dom_id:"record_payment_modal" title=record_payment_title form=membership.record_payment_form action_url=record_payment_url submit_label=record_payment_submit_label submit_icon="receipt" blurb=record_payment_blurb %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock panel %}
|
||||||
|
|
||||||
|
{% block extra_body %}
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const selectAll = document.getElementById("select-all-memberships");
|
||||||
|
const rowCheckboxes = document.querySelectorAll(".membership-row-checkbox");
|
||||||
|
if (!selectAll) return;
|
||||||
|
|
||||||
|
selectAll.addEventListener("change", () => {
|
||||||
|
rowCheckboxes.forEach((checkbox) => { checkbox.checked = selectAll.checked; });
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock extra_body %}
|
||||||
99
management/templates/management/membership_list_pdf.html
Normal file
99
management/templates/management/membership_list_pdf.html
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
Rendered by WeasyPrint, not a browser: a standalone document with its own print
|
||||||
|
stylesheet, same convention as billing/templates/billing/invoice.html -- it
|
||||||
|
deliberately doesn't pull in app.css (daisyUI's dark theme/flex layouts mean
|
||||||
|
nothing on paper).
|
||||||
|
{% endcomment %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{% trans "Memberships" %}</title>
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 18mm;
|
||||||
|
@bottom-center {
|
||||||
|
content: "{{ club.name }} — {% trans "memberships" %} — " counter(page) " / " counter(pages);
|
||||||
|
font-size: 8pt;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body { font-family: sans-serif; font-size: 9.5pt; color: #111; }
|
||||||
|
h1 { font-size: 18pt; margin: 0 0 2mm; }
|
||||||
|
.muted { color: #666; }
|
||||||
|
.header { display: flex; justify-content: space-between; margin-bottom: 8mm; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th { text-align: left; font-size: 8pt; text-transform: uppercase; letter-spacing: 0.5pt; color: #666; border-bottom: 1px solid #999; padding: 2mm 2mm 1mm 0; }
|
||||||
|
td { padding: 1.5mm 2mm 1.5mm 0; border-bottom: 1px solid #eee; vertical-align: top; }
|
||||||
|
.badge { display: inline-block; padding: 0.5mm 2mm; border-radius: 2mm; font-size: 8pt; }
|
||||||
|
.badge-success { background: #dcfce7; color: #15803d; }
|
||||||
|
.badge-warning { background: #fef3c7; color: #92400e; }
|
||||||
|
.badge-error { background: #fee2e2; color: #b91c1c; }
|
||||||
|
.badge-neutral { background: #e5e7eb; color: #374151; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<div>
|
||||||
|
<h1>{{ club.name }}</h1>
|
||||||
|
<div class="muted">{% trans "Membership fee status" %}{% if selected_season %} — {{ selected_season }}{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
<div class="muted">
|
||||||
|
{% blocktrans with generated=generated_at|date:"j F Y, H:i" %}Generated {{ generated }}{% endblocktrans %}<br>
|
||||||
|
{% blocktrans count counter=memberships|length %}{{ counter }} member{% plural %}{{ counter }} members{% endblocktrans %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Last name" %}</th>
|
||||||
|
<th>{% trans "First name" %}</th>
|
||||||
|
<th>{% trans "Email" %}</th>
|
||||||
|
<th>{% trans "Family" %}</th>
|
||||||
|
<th>{% trans "Status" %}</th>
|
||||||
|
<th>{% trans "Fee status" %}</th>
|
||||||
|
<th>{% trans "Owed" %}</th>
|
||||||
|
<th>{% trans "Paid" %}</th>
|
||||||
|
<th>{% trans "License" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for membership in memberships %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ membership.member.last_name }}</td>
|
||||||
|
<td>{{ membership.member.first_name }}</td>
|
||||||
|
<td>{{ membership.member.contact_email|default:"—" }}</td>
|
||||||
|
<td>
|
||||||
|
{% for fm in membership.member.family_memberships_display %}{{ fm.family }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge
|
||||||
|
{% if membership.status == "active" %}badge-success
|
||||||
|
{% elif membership.status == "pending" %}badge-warning
|
||||||
|
{% elif membership.status == "cancelled" %}badge-error
|
||||||
|
{% else %}badge-neutral{% endif %}">{{ membership.get_status_display }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge
|
||||||
|
{% if membership.fee_status == "paid" %}badge-success
|
||||||
|
{% elif membership.fee_status == "partially_paid" %}badge-warning
|
||||||
|
{% elif membership.fee_status == "unpaid" %}badge-error
|
||||||
|
{% else %}badge-neutral{% endif %}">{{ membership.get_fee_status_display }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ membership.fee_amount }}</td>
|
||||||
|
<td>{{ membership.amount_paid }}</td>
|
||||||
|
<td>{{ membership.license|default:"—" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" class="muted">{% trans "Nobody matches these filters." %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
29
management/templates/management/role_form.html
Normal file
29
management/templates/management/role_form.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load i18n lucide ui %}
|
||||||
|
|
||||||
|
{% block heading %}{% trans "Grant role" %}{% endblock heading %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<div class="card w-full max-w-xl bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{% for error in form.non_field_errors %}
|
||||||
|
<div class="alert alert-error my-2">
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% for field in form %}
|
||||||
|
{% form_field field %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="card-actions justify-start pt-2 mt-2">
|
||||||
|
<a class="btn btn-outline btn-neutral gap-2" href="{% url "management:role_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
||||||
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock panel %}
|
||||||
44
management/templates/management/role_list.html
Normal file
44
management/templates/management/role_list.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load i18n lucide %}
|
||||||
|
|
||||||
|
{% block heading %}{% trans "Roles" %}{% endblock heading %}
|
||||||
|
|
||||||
|
{% block actions %}
|
||||||
|
<a class="btn btn-primary gap-2" href="{% url 'management:role_create' %}">{% lucide "plus" size=16 %} {% trans "Grant role" %}</a>
|
||||||
|
{% endblock actions %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Member" %}</th>
|
||||||
|
<th>{% trans "Role" %}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for role in roles %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ role.member }}</td>
|
||||||
|
<td>{{ role.get_role_display }}</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<form method="post" action="{% url 'management:role_revoke' role.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button class="btn btn-outline btn-error btn-sm gap-2" type="submit">{% lucide "x" size=14 %} {% trans "Revoke" %}</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="text-center opacity-60">{% trans "No roles granted yet." %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock panel %}
|
||||||
19
management/templates/management/team_detail.html
Normal file
19
management/templates/management/team_detail.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load i18n lucide %}
|
||||||
|
|
||||||
|
{% block heading %}{{ team.name }}{% endblock heading %}
|
||||||
|
{% block subheading %}{{ team.short_name }}{% endblock subheading %}
|
||||||
|
|
||||||
|
{% block actions %}
|
||||||
|
{% if is_club_admin %}
|
||||||
|
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:team_update' team.pk %}">{% lucide "pencil" size=16 %} {% trans "Edit" %}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock actions %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="opacity-70">{% trans "Roster and staff assignments for this team are managed from the Roster and Staff sections." %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock panel %}
|
||||||
31
management/templates/management/team_form.html
Normal file
31
management/templates/management/team_form.html
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load i18n lucide ui %}
|
||||||
|
|
||||||
|
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New team" %}{% endif %}{% endblock heading %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<div class="card w-full bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{% for error in form.non_field_errors %}
|
||||||
|
<div class="alert alert-error my-2">
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{% for field in form %}
|
||||||
|
{% form_field field %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions justify-start pt-2 mt-2">
|
||||||
|
<a class="btn btn-outline btn-neutral gap-2" href="{% if update_view %}{% url "management:team_detail" object.pk %}{% else %}{% url "management:team_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
||||||
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock panel %}
|
||||||
39
management/templates/management/team_list.html
Normal file
39
management/templates/management/team_list.html
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load i18n lucide %}
|
||||||
|
|
||||||
|
{% block heading %}{% trans "Teams" %}{% endblock heading %}
|
||||||
|
|
||||||
|
{% block actions %}
|
||||||
|
{% if is_club_admin %}
|
||||||
|
<a class="btn btn-primary gap-2" href="{% url 'management:team_create' %}">{% lucide "plus" size=16 %} {% trans "New team" %}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock actions %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Name" %}</th>
|
||||||
|
<th>{% trans "Short name" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for team in teams %}
|
||||||
|
<tr>
|
||||||
|
<td><a class="link link-hover" href="{% url 'management:team_detail' team.pk %}">{{ team.name }}</a></td>
|
||||||
|
<td>{{ team.short_name }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" class="text-center opacity-60">{% trans "No teams yet." %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock panel %}
|
||||||
1644
management/tests.py
Normal file
1644
management/tests.py
Normal file
File diff suppressed because it is too large
Load Diff
56
management/urls.py
Normal file
56
management/urls.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = "management"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", views.HomeView.as_view(), name="home"),
|
||||||
|
# People
|
||||||
|
path("members/", views.MemberListView.as_view(), name="member_list"),
|
||||||
|
path("memberships/", views.MembershipListView.as_view(), name="membership_list"),
|
||||||
|
path("memberships/mark-paid/", views.MembershipMarkPaidView.as_view(), name="membership_mark_paid"),
|
||||||
|
path("memberships/export/", views.MembershipExportPdfView.as_view(), name="membership_export_pdf"),
|
||||||
|
path("memberships/<uuid:pk>/mark-fully-paid/", views.MembershipMarkFullyPaidView.as_view(), name="membership_mark_fully_paid"),
|
||||||
|
path("memberships/<uuid:pk>/record-payment/", views.MembershipRecordPaymentView.as_view(), name="membership_record_payment"),
|
||||||
|
path("members/new/", views.MemberCreateView.as_view(), name="member_create"),
|
||||||
|
path("members/import/template/", views.MemberImportTemplateView.as_view(), name="member_import_template"),
|
||||||
|
path("members/import/", views.MemberImportView.as_view(), name="member_import"),
|
||||||
|
path("members/import/confirm/", views.MemberImportConfirmView.as_view(), name="member_import_confirm"),
|
||||||
|
path("members/<uuid:pk>/", views.MemberDetailView.as_view(), name="member_detail"),
|
||||||
|
path("members/<uuid:pk>/edit/", views.MemberUpdateView.as_view(), name="member_update"),
|
||||||
|
path("members/<uuid:pk>/delete/", views.MemberDeleteView.as_view(), name="member_delete"),
|
||||||
|
path("members/<uuid:pk>/attach-family/", views.MemberAttachToFamilyView.as_view(), name="member_attach_family"),
|
||||||
|
path("members/<uuid:pk>/grant-login/", views.MemberGrantLoginView.as_view(), name="member_grant_login"),
|
||||||
|
path("members/<uuid:pk>/detach-family/<uuid:family_pk>/", views.MemberDetachFromFamilyView.as_view(), name="member_detach_family"),
|
||||||
|
path("families/new/", views.FamilyCreateView.as_view(), name="family_create"),
|
||||||
|
path("families/<uuid:pk>/", views.FamilyDetailView.as_view(), name="family_detail"),
|
||||||
|
path("families/<uuid:pk>/add-child/", views.FamilyAddChildView.as_view(), name="family_add_child"),
|
||||||
|
path("families/<uuid:pk>/add-parent/", views.FamilyAddParentView.as_view(), name="family_add_parent"),
|
||||||
|
path("families/<uuid:family_pk>/members/<uuid:member_pk>/role/", views.FamilyMembershipRoleUpdateView.as_view(), name="family_membership_role_update"),
|
||||||
|
# Club setup (admin only)
|
||||||
|
path("positions/", views.PositionListView.as_view(), name="position_list"),
|
||||||
|
path("roles/", views.ClubRoleListView.as_view(), name="role_list"),
|
||||||
|
path("roles/new/", views.ClubRoleCreateView.as_view(), name="role_create"),
|
||||||
|
path("roles/<uuid:pk>/revoke/", views.ClubRoleRevokeView.as_view(), name="role_revoke"),
|
||||||
|
# Teams
|
||||||
|
path("teams/", views.TeamListView.as_view(), name="team_list"),
|
||||||
|
path("teams/new/", views.TeamCreateView.as_view(), name="team_create"),
|
||||||
|
path("teams/<uuid:pk>/", views.TeamDetailView.as_view(), name="team_detail"),
|
||||||
|
path("teams/<uuid:pk>/edit/", views.TeamUpdateView.as_view(), name="team_update"),
|
||||||
|
path("roster/", views.RosterListView.as_view(), name="roster_list"),
|
||||||
|
path("staff/", views.StaffListView.as_view(), name="staff_list"),
|
||||||
|
# Calendar
|
||||||
|
path("events/", views.EventListView.as_view(), name="event_list"),
|
||||||
|
path("event-series/", views.EventSeriesListView.as_view(), name="event_series_list"),
|
||||||
|
path("locations/", views.LocationListView.as_view(), name="location_list"),
|
||||||
|
path("opponents/", views.OpponentListView.as_view(), name="opponent_list"),
|
||||||
|
# Shop (admin only)
|
||||||
|
path("shop/products/", views.ProductListView.as_view(), name="product_list"),
|
||||||
|
path("shop/orders/", views.OrderListView.as_view(), name="order_list"),
|
||||||
|
path("shop/discounts/", views.DiscountListView.as_view(), name="discount_list"),
|
||||||
|
path("shop/invoices/", views.InvoiceListView.as_view(), name="invoice_list"),
|
||||||
|
# Forms
|
||||||
|
path("forms/", views.FormListView.as_view(), name="form_list"),
|
||||||
|
path("forms/<uuid:pk>/submissions/", views.SubmissionListView.as_view(), name="submission_list"),
|
||||||
|
]
|
||||||
966
management/views.py
Normal file
966
management/views.py
Normal file
@@ -0,0 +1,966 @@
|
|||||||
|
from django.db import transaction
|
||||||
|
from django.db.models import Count, ProtectedError
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.http import url_has_allowed_host_and_scheme
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.views.generic import CreateView, DetailView, FormView, ListView, TemplateView, UpdateView, View
|
||||||
|
|
||||||
|
from club.mixins import ClubAdminRequiredMixin, ClubStaffRequiredMixin
|
||||||
|
from club.models import ClubMembership, ClubRole, Season
|
||||||
|
from club.services.access import current_season, members_visible_to
|
||||||
|
from club.services.fees import mark_as_paid, record_payment, remaining_balance
|
||||||
|
from controlpanel.messages import notify
|
||||||
|
from controlpanel.mixins import RedirectOnInvalidMixin
|
||||||
|
from controlpanel.services.statistics import club_attention, club_charts, club_statistics
|
||||||
|
from events.models import Event, EventSeries, Location, Opponent
|
||||||
|
from formbuilder.models import Form as FormBuilderForm
|
||||||
|
from formbuilder.models import Submission
|
||||||
|
from members.models import Family, FamilyMembership, Member
|
||||||
|
from members.services.family import add_child_to_family, add_parent_to_family, attach_to_family, detach_from_family, grant_login, register_family
|
||||||
|
from shop.models import Discount, Invoice, Order, Product
|
||||||
|
from teams.models import Position, StaffAssignment, Team, TeamMembership
|
||||||
|
|
||||||
|
from .bulk_import import build_member_import_template, parse_member_import_rows, read_member_import_workbook
|
||||||
|
from .forms import AddChildForm, AddParentForm, AttachToFamilyForm, ClubMembershipForm, ClubRoleAssignForm, FamilyCreateForm, GrantLoginForm, MemberForm, MemberImportUploadForm, RecordFeePaymentForm, TeamForm
|
||||||
|
from .pdf import PDFExportError, membership_list_pdf
|
||||||
|
|
||||||
|
|
||||||
|
class HomeView(ClubStaffRequiredMixin, TemplateView):
|
||||||
|
"""The at-a-glance numbers a club admin/team manager/coach would actually want:
|
||||||
|
club_attention/club_charts/club_statistics are the exact functions
|
||||||
|
controlpanel/club_detail.html uses for the platform admin's per-club drill-down --
|
||||||
|
already club-scoped, so directly reusable for this club's own staff."""
|
||||||
|
|
||||||
|
template_name = "management/home.html"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
club = self.request.club
|
||||||
|
return super().get_context_data(
|
||||||
|
attention=club_attention(club),
|
||||||
|
charts=club_charts(club),
|
||||||
|
groups=club_statistics(club),
|
||||||
|
upcoming_events=Event.objects.filter(club=club, start__gte=timezone.now()).order_by("start")[:5],
|
||||||
|
today=timezone.localdate(),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Members (full tier) -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def group_by_family(members):
|
||||||
|
"""Bucket an already-scoped Member iterable by family: {family, guardians,
|
||||||
|
children, others}, plus whatever's left un-grouped.
|
||||||
|
|
||||||
|
Deliberately built from ``members`` rather than ``Family.guardians``/``.children``
|
||||||
|
(members/models.py) -- those query a family's *entire* membership unconditionally,
|
||||||
|
which would leak people outside whatever visibility scope ``members`` already
|
||||||
|
represents (e.g. a coach who only sees their own rostered players).
|
||||||
|
"""
|
||||||
|
memberships = FamilyMembership.objects.filter(member__in=members).select_related("family", "member")
|
||||||
|
|
||||||
|
groups = {}
|
||||||
|
for fm in memberships:
|
||||||
|
bucket = groups.setdefault(fm.family, {"family": fm.family, "guardians": [], "children": [], "others": []})
|
||||||
|
# Attached here, not read later from person.family_memberships.first() --
|
||||||
|
# a member can belong to more than one family, so "their role" only means
|
||||||
|
# anything once it's scoped to *this* family's membership row.
|
||||||
|
fm.member.role_in_family = fm.role
|
||||||
|
fm.member.role_in_family_display = fm.get_role_display()
|
||||||
|
# A login-less child can be granted one right from the table -- parents/
|
||||||
|
# guardians already get one when added (add_parent_to_family/register_family
|
||||||
|
# both call get_or_create_login_member), so in practice this is only ever
|
||||||
|
# None for a child, but the check is on the actual state, not the role alone.
|
||||||
|
if fm.role == FamilyMembership.FamilyRole.CHILD and fm.member.user_id is None:
|
||||||
|
fm.member.grant_login_form = GrantLoginForm(initial={"email": fm.member.email})
|
||||||
|
else:
|
||||||
|
fm.member.grant_login_form = None
|
||||||
|
if fm.role in (FamilyMembership.FamilyRole.PARENT, FamilyMembership.FamilyRole.GUARDIAN):
|
||||||
|
bucket["guardians"].append(fm.member)
|
||||||
|
elif fm.role == FamilyMembership.FamilyRole.CHILD:
|
||||||
|
bucket["children"].append(fm.member)
|
||||||
|
else:
|
||||||
|
bucket["others"].append(fm.member)
|
||||||
|
|
||||||
|
grouped_ids = {fm.member_id for fm in memberships}
|
||||||
|
ungrouped = [member for member in members if member.pk not in grouped_ids]
|
||||||
|
|
||||||
|
groups = list(groups.values())
|
||||||
|
for group in groups:
|
||||||
|
# One flat list for anything that just wants "everyone in this family",
|
||||||
|
# regardless of role (e.g. rendering one delete-confirm modal per member).
|
||||||
|
group["all"] = group["guardians"] + group["children"] + group["others"]
|
||||||
|
|
||||||
|
return groups, ungrouped
|
||||||
|
|
||||||
|
|
||||||
|
class MemberListView(ClubStaffRequiredMixin, ListView):
|
||||||
|
"""One flat list, everybody -- family is a column, not a grouping. Each
|
||||||
|
member's family/role is attached in Python below (from a single query over
|
||||||
|
the already-scoped ``members``), same reasoning as group_by_family: never
|
||||||
|
resolve it per-row from the template via Family.guardians/.children, which
|
||||||
|
would ignore visibility scoping entirely."""
|
||||||
|
|
||||||
|
template_name = "management/member_list.html"
|
||||||
|
context_object_name = "members"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
members = members_visible_to(self.request.user, self.request.club)
|
||||||
|
search = self.request.GET.get("q", "").strip()
|
||||||
|
if search:
|
||||||
|
members = members.filter(first_name__icontains=search) | members.filter(last_name__icontains=search) | members.filter(email__icontains=search) | members.filter(user__email__icontains=search)
|
||||||
|
return members.distinct()
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(search=self.request.GET.get("q", ""), **kwargs)
|
||||||
|
members = list(context["members"])
|
||||||
|
|
||||||
|
memberships = FamilyMembership.objects.filter(member__in=members).select_related("family")
|
||||||
|
memberships_by_member_id = {}
|
||||||
|
for fm in memberships:
|
||||||
|
memberships_by_member_id.setdefault(fm.member_id, []).append(fm)
|
||||||
|
for member in members:
|
||||||
|
member.family_memberships_display = memberships_by_member_id.get(member.pk, [])
|
||||||
|
|
||||||
|
season = current_season(self.request.club)
|
||||||
|
club_memberships_by_member_id = {}
|
||||||
|
if season is not None:
|
||||||
|
club_memberships = ClubMembership.objects.filter(club=self.request.club, season=season, member__in=members)
|
||||||
|
club_memberships_by_member_id = {cm.member_id: cm for cm in club_memberships}
|
||||||
|
for member in members:
|
||||||
|
member.current_membership = club_memberships_by_member_id.get(member.pk)
|
||||||
|
|
||||||
|
return context | {"members": members}
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipListView(ClubAdminRequiredMixin, ListView):
|
||||||
|
"""Who's paid for the current season, and who hasn't -- MemberListView's Status
|
||||||
|
column can only show this one row at a time. Financial data, so admin-only
|
||||||
|
throughout (same line already drawn around the Shop nav section)."""
|
||||||
|
|
||||||
|
template_name = "management/membership_list.html"
|
||||||
|
context_object_name = "memberships"
|
||||||
|
|
||||||
|
def get_selected_season(self):
|
||||||
|
season_id = self.request.GET.get("season")
|
||||||
|
if season_id:
|
||||||
|
season = Season.objects.filter(club=self.request.club, pk=season_id).first()
|
||||||
|
if season is not None:
|
||||||
|
return season
|
||||||
|
return current_season(self.request.club)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
season = self.get_selected_season()
|
||||||
|
if season is None:
|
||||||
|
return ClubMembership.objects.none()
|
||||||
|
|
||||||
|
memberships = ClubMembership.objects.filter(club=self.request.club, season=season).select_related("member").order_by("member__last_name", "member__first_name")
|
||||||
|
|
||||||
|
fee_status = self.request.GET.get("fee_status", "not_paid")
|
||||||
|
if fee_status == "not_paid":
|
||||||
|
# Literally "does not have a paid status" -- unpaid, partially paid, and
|
||||||
|
# waived all qualify; the dropdown can narrow to any single one of those.
|
||||||
|
memberships = memberships.exclude(fee_status__in=[ClubMembership.FeeStatus.PAID, ClubMembership.FeeStatus.PARTIALLY_PAID])
|
||||||
|
elif fee_status and fee_status != "all":
|
||||||
|
memberships = memberships.filter(fee_status=fee_status)
|
||||||
|
|
||||||
|
status = self.request.GET.get("status", "all")
|
||||||
|
if status and status != "all":
|
||||||
|
memberships = memberships.filter(status=status)
|
||||||
|
|
||||||
|
team_id = self.request.GET.get("team")
|
||||||
|
if team_id:
|
||||||
|
memberships = memberships.filter(member__team_memberships__team_id=team_id, member__team_memberships__season=season)
|
||||||
|
|
||||||
|
search = self.request.GET.get("q", "").strip()
|
||||||
|
if search:
|
||||||
|
# Also matches by family -- searching "Smith" finds every member of a
|
||||||
|
# family that has an explicit name of "Smith" or that includes anyone
|
||||||
|
# surnamed Smith, not just a member literally named Smith themself.
|
||||||
|
memberships = (
|
||||||
|
memberships.filter(member__first_name__icontains=search)
|
||||||
|
| memberships.filter(member__last_name__icontains=search)
|
||||||
|
| memberships.filter(member__email__icontains=search)
|
||||||
|
| memberships.filter(member__user__email__icontains=search)
|
||||||
|
| memberships.filter(member__family_memberships__family__name__icontains=search)
|
||||||
|
| memberships.filter(member__family_memberships__family__memberships__member__last_name__icontains=search)
|
||||||
|
)
|
||||||
|
|
||||||
|
return memberships.distinct()
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
club = self.request.club
|
||||||
|
current = current_season(club)
|
||||||
|
|
||||||
|
counts = {}
|
||||||
|
if current is not None:
|
||||||
|
counts = {row["fee_status"]: row["count"] for row in ClubMembership.objects.filter(club=club, season=current).values("fee_status").annotate(count=Count("id"))}
|
||||||
|
paid = counts.get(ClubMembership.FeeStatus.PAID, 0)
|
||||||
|
partial = counts.get(ClubMembership.FeeStatus.PARTIALLY_PAID, 0)
|
||||||
|
unpaid = counts.get(ClubMembership.FeeStatus.UNPAID, 0)
|
||||||
|
waived = counts.get(ClubMembership.FeeStatus.WAIVED, 0)
|
||||||
|
total = paid + partial + unpaid + waived
|
||||||
|
|
||||||
|
context = super().get_context_data(
|
||||||
|
current_season=current,
|
||||||
|
selected_season=self.get_selected_season(),
|
||||||
|
seasons=Season.objects.filter(club=club).order_by("-start_date"),
|
||||||
|
teams=Team.objects.filter(club=club).order_by("name"),
|
||||||
|
fee_status_choices=ClubMembership.FeeStatus.choices,
|
||||||
|
status_choices=ClubMembership.StatusChoices.choices,
|
||||||
|
search=self.request.GET.get("q", ""),
|
||||||
|
selected_fee_status=self.request.GET.get("fee_status", "not_paid"),
|
||||||
|
selected_status=self.request.GET.get("status", "all"),
|
||||||
|
selected_team=self.request.GET.get("team", ""),
|
||||||
|
kpi_total=total,
|
||||||
|
kpi_paid=paid,
|
||||||
|
kpi_partial=partial,
|
||||||
|
kpi_unpaid=unpaid,
|
||||||
|
kpi_waived=waived,
|
||||||
|
kpi_paid_rate=round(100 * paid / total) if total else None,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Same reasoning as MemberListView: attached in Python from a single query,
|
||||||
|
# never resolved per-row via Family.guardians/.children (which would ignore
|
||||||
|
# this page's own club/season/filter scoping entirely).
|
||||||
|
memberships = list(context["memberships"])
|
||||||
|
members = [membership.member for membership in memberships]
|
||||||
|
family_memberships = FamilyMembership.objects.filter(member__in=members).select_related("family")
|
||||||
|
family_memberships_by_member_id = {}
|
||||||
|
for fm in family_memberships:
|
||||||
|
family_memberships_by_member_id.setdefault(fm.member_id, []).append(fm)
|
||||||
|
for membership in memberships:
|
||||||
|
membership.member.family_memberships_display = family_memberships_by_member_id.get(membership.member_id, [])
|
||||||
|
membership.remaining_balance_display = remaining_balance(membership)
|
||||||
|
# Nothing to collect on an already-settled or deliberately-exempted row.
|
||||||
|
if membership.fee_status in (ClubMembership.FeeStatus.PAID, ClubMembership.FeeStatus.WAIVED):
|
||||||
|
membership.record_payment_form = None
|
||||||
|
else:
|
||||||
|
membership.record_payment_form = RecordFeePaymentForm()
|
||||||
|
|
||||||
|
return context | {"memberships": memberships}
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipMarkPaidView(ClubAdminRequiredMixin, View):
|
||||||
|
"""Flag a batch of memberships as settled: active + paid, in one go. There's no
|
||||||
|
bank integration, so this is always a manual admin action -- the point of this
|
||||||
|
view is to make the manual action fast, not to replace it with automation.
|
||||||
|
|
||||||
|
Uses club.services.fees.mark_as_paid per row (a .save() loop under the hood,
|
||||||
|
never a bulk .update()): club/signals.py grants the MEMBER ClubRole via a
|
||||||
|
post_save signal on ClubMembership, which .update() would bypass entirely,
|
||||||
|
silently leaving a marked-paid member without their role. Same function backs
|
||||||
|
the per-row "Mark fully paid" button (MembershipMarkFullyPaidView) -- one place
|
||||||
|
decides how a membership becomes paid.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
ids = request.POST.getlist("membership_ids")
|
||||||
|
memberships = ClubMembership.objects.filter(pk__in=ids, club=request.club)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
with transaction.atomic():
|
||||||
|
for membership in memberships:
|
||||||
|
mark_as_paid(membership, recorded_by=request.user)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
notify(request, f"s|{_('Marked as paid')}|{_('%(count)d membership(s) updated.') % {'count': count} }")
|
||||||
|
|
||||||
|
next_url = request.POST.get("next")
|
||||||
|
if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}, require_https=request.is_secure()):
|
||||||
|
return redirect(next_url)
|
||||||
|
return redirect("management:membership_list")
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipMarkFullyPaidView(ClubAdminRequiredMixin, View):
|
||||||
|
"""The per-row, one-click version of MembershipMarkPaidView's bulk action --
|
||||||
|
no confirm modal, matching this app's convention of reserving those for
|
||||||
|
destructive deletes, not state changes."""
|
||||||
|
|
||||||
|
def post(self, request, pk):
|
||||||
|
membership = get_object_or_404(ClubMembership, pk=pk, club=request.club)
|
||||||
|
mark_as_paid(membership, recorded_by=request.user)
|
||||||
|
notify(request, f"s|{_('Marked as paid')}|{_('“%(member)s” is now active and paid.') % {'member': membership.member}}")
|
||||||
|
|
||||||
|
next_url = request.POST.get("next")
|
||||||
|
if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}, require_https=request.is_secure()):
|
||||||
|
return redirect(next_url)
|
||||||
|
return redirect("management:membership_list")
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipRecordPaymentView(ClubAdminRequiredMixin, View):
|
||||||
|
"""The per-row "Record payment" modal on the Memberships page -- any amount,
|
||||||
|
partial or in full, via club.services.fees.record_payment."""
|
||||||
|
|
||||||
|
def post(self, request, pk):
|
||||||
|
membership = get_object_or_404(ClubMembership, pk=pk, club=request.club)
|
||||||
|
form = RecordFeePaymentForm(request.POST)
|
||||||
|
|
||||||
|
next_url = request.POST.get("next")
|
||||||
|
redirect_url = next_url if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}, require_https=request.is_secure()) else reverse("management:membership_list")
|
||||||
|
|
||||||
|
if not form.is_valid():
|
||||||
|
for error in form.errors.values():
|
||||||
|
notify(request, f"e|{_('Could not record payment')}|{' '.join(error)}")
|
||||||
|
return redirect(redirect_url)
|
||||||
|
|
||||||
|
record_payment(
|
||||||
|
membership,
|
||||||
|
amount=form.cleaned_data["amount"],
|
||||||
|
method=form.cleaned_data["method"],
|
||||||
|
reference=form.cleaned_data["reference"],
|
||||||
|
note=form.cleaned_data["note"],
|
||||||
|
recorded_by=request.user,
|
||||||
|
)
|
||||||
|
notify(request, f"s|{_('Payment recorded')}|{_('%(amount)s recorded for “%(member)s”.') % {'amount': form.cleaned_data['amount'], 'member': membership.member}}")
|
||||||
|
return redirect(redirect_url)
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipExportPdfView(MembershipListView):
|
||||||
|
"""The exact same filtered queryset and KPI numbers as MembershipListView --
|
||||||
|
"export this page" means exactly that, never a differently-filtered list.
|
||||||
|
Reachable with whatever query string the on-screen list currently has."""
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
self.object_list = self.get_queryset()
|
||||||
|
context = self.get_context_data(club=request.club, generated_at=timezone.now())
|
||||||
|
|
||||||
|
try:
|
||||||
|
pdf = membership_list_pdf(context)
|
||||||
|
except PDFExportError as error:
|
||||||
|
# The native PDF libraries are missing: say so rather than 500, and
|
||||||
|
# land back on the same filtered list rather than a blank one.
|
||||||
|
notify(request, f"e|{_('PDF unavailable')}|{error}")
|
||||||
|
return redirect(f"{reverse('management:membership_list')}?{request.GET.urlencode()}")
|
||||||
|
|
||||||
|
season = context["selected_season"]
|
||||||
|
filename = f"memberships-{season}.pdf" if season else "memberships.pdf"
|
||||||
|
response = HttpResponse(pdf, content_type="application/pdf")
|
||||||
|
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class MemberImportTemplateView(ClubStaffRequiredMixin, View):
|
||||||
|
"""Anyone with management access can download the template -- filling it in
|
||||||
|
doesn't grant any authority, only the upload step (admin-only) does."""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
workbook = build_member_import_template()
|
||||||
|
response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||||
|
response["Content-Disposition"] = 'attachment; filename="member_import_template.xlsx"'
|
||||||
|
workbook.save(response)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class MemberImportView(ClubAdminRequiredMixin, View):
|
||||||
|
"""Step 1 of the mass-upload: upload a filled-in template, see exactly what
|
||||||
|
will be created before anything actually is. Row data -- already extracted to
|
||||||
|
plain values, see bulk_import.read_member_import_workbook -- rides in the
|
||||||
|
session to MemberImportConfirmView, which re-validates it rather than trusting
|
||||||
|
anything a client could tamper with."""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
return render(request, "management/member_import.html", {"form": MemberImportUploadForm()})
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
form = MemberImportUploadForm(request.POST, request.FILES)
|
||||||
|
if not form.is_valid():
|
||||||
|
return render(request, "management/member_import.html", {"form": form})
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = read_member_import_workbook(form.cleaned_data["file"])
|
||||||
|
except ValueError as exc:
|
||||||
|
form.add_error("file", str(exc))
|
||||||
|
return render(request, "management/member_import.html", {"form": form})
|
||||||
|
|
||||||
|
request.session["member_import_rows"] = rows
|
||||||
|
results = parse_member_import_rows(rows, request.club)
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"management/member_import_preview.html",
|
||||||
|
{
|
||||||
|
"results": results,
|
||||||
|
"valid_count": sum(1 for result in results if result["member"] is not None),
|
||||||
|
"skipped_count": sum(1 for result in results if result["member"] is None),
|
||||||
|
"season": current_season(request.club),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MemberImportConfirmView(ClubAdminRequiredMixin, View):
|
||||||
|
"""Step 2: no row data in the request at all, just a submit button -- there's
|
||||||
|
nothing here for a client to tamper with. Re-parses the rows stashed in the
|
||||||
|
session by MemberImportView, so what gets created is guaranteed to match what
|
||||||
|
the preview showed."""
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
rows = request.session.pop("member_import_rows", None)
|
||||||
|
if not rows:
|
||||||
|
notify(request, f"w|{_('Nothing to import')}|{_('Upload a file first.')}")
|
||||||
|
return redirect("management:member_import")
|
||||||
|
|
||||||
|
results = parse_member_import_rows(rows, request.club)
|
||||||
|
season = current_season(request.club)
|
||||||
|
|
||||||
|
created = 0
|
||||||
|
with transaction.atomic():
|
||||||
|
for result in results:
|
||||||
|
member = result["member"]
|
||||||
|
if member is None:
|
||||||
|
continue
|
||||||
|
member.save()
|
||||||
|
if season is not None:
|
||||||
|
ClubMembership.objects.create(club=request.club, member=member, season=season, signed_up_at=timezone.localdate(), **result["membership_kwargs"])
|
||||||
|
created += 1
|
||||||
|
|
||||||
|
skipped = len(results) - created
|
||||||
|
if season is None and created:
|
||||||
|
title = _("%(count)s member(s) created, but not rostered") % {"count": created}
|
||||||
|
body = _("There's no active season to sign them up for yet.")
|
||||||
|
notify(request, f"w|{title}|{body}")
|
||||||
|
else:
|
||||||
|
body = _("%(created)s created, %(skipped)s skipped.") % {"created": created, "skipped": skipped}
|
||||||
|
notify(request, f"s|{_('Members imported')}|{body}")
|
||||||
|
return redirect("management:member_list")
|
||||||
|
|
||||||
|
|
||||||
|
class MemberCreateView(ClubAdminRequiredMixin, CreateView):
|
||||||
|
model = Member
|
||||||
|
form_class = MemberForm
|
||||||
|
template_name = "management/member_form.html"
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
response = super().form_valid(form)
|
||||||
|
|
||||||
|
# Adding a member here means adding them to *this* club -- without a
|
||||||
|
# ClubMembership they'd never show up in members_visible_to() again.
|
||||||
|
season = current_season(self.request.club)
|
||||||
|
if season is not None:
|
||||||
|
ClubMembership.objects.create(
|
||||||
|
club=self.request.club,
|
||||||
|
member=self.object,
|
||||||
|
season=season,
|
||||||
|
status=ClubMembership.StatusChoices.ACTIVE,
|
||||||
|
signed_up_at=timezone.localdate(),
|
||||||
|
)
|
||||||
|
body = _("“%(member)s” added to the club for %(season)s.") % {"member": self.object, "season": season}
|
||||||
|
notify(self.request, f"s|{_('Member added')}|{body}")
|
||||||
|
else:
|
||||||
|
body = _("“%(member)s” was created, but there's no active season to sign them up for yet.") % {"member": self.object}
|
||||||
|
notify(self.request, f"w|{_('Member added, but not rostered')}|{body}")
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse("management:member_detail", args=[self.object.pk])
|
||||||
|
|
||||||
|
|
||||||
|
class MemberUpdateView(ClubAdminRequiredMixin, View):
|
||||||
|
"""Not a generic UpdateView: this page owns two forms on one submit -- the
|
||||||
|
member's own fields, and (when they're actually rostered this season) their
|
||||||
|
ClubMembership's license/status/fee status. A Member has no club of its own
|
||||||
|
without one, so this is the only place to see or change it."""
|
||||||
|
|
||||||
|
template_name = "management/member_form.html"
|
||||||
|
|
||||||
|
def get_member(self):
|
||||||
|
return get_object_or_404(members_visible_to(self.request.user, self.request.club), pk=self.kwargs["pk"])
|
||||||
|
|
||||||
|
def get_membership(self, member):
|
||||||
|
season = current_season(self.request.club)
|
||||||
|
if season is None:
|
||||||
|
return None
|
||||||
|
return ClubMembership.objects.filter(club=self.request.club, member=member, season=season).first() or ClubMembership(
|
||||||
|
club=self.request.club, member=member, season=season, signed_up_at=timezone.localdate()
|
||||||
|
)
|
||||||
|
|
||||||
|
def render_form(self, member, form, membership_form):
|
||||||
|
return render(self.request, self.template_name, {"object": member, "update_view": True, "form": form, "membership_form": membership_form})
|
||||||
|
|
||||||
|
def get(self, request, pk):
|
||||||
|
member = self.get_member()
|
||||||
|
membership = self.get_membership(member)
|
||||||
|
membership_form = ClubMembershipForm(instance=membership) if membership else None
|
||||||
|
return self.render_form(member, MemberForm(instance=member), membership_form)
|
||||||
|
|
||||||
|
def post(self, request, pk):
|
||||||
|
member = self.get_member()
|
||||||
|
membership = self.get_membership(member)
|
||||||
|
form = MemberForm(request.POST, instance=member)
|
||||||
|
membership_form = ClubMembershipForm(request.POST, instance=membership) if membership else None
|
||||||
|
|
||||||
|
if form.is_valid() and (membership_form is None or membership_form.is_valid()):
|
||||||
|
form.save()
|
||||||
|
if membership_form is not None:
|
||||||
|
membership_form.save()
|
||||||
|
notify(request, f"s|{_('Member updated')}|{_('“%(member)s” updated.') % {'member': member} }")
|
||||||
|
return redirect("management:member_detail", pk=member.pk)
|
||||||
|
|
||||||
|
return self.render_form(member, form, membership_form)
|
||||||
|
|
||||||
|
|
||||||
|
class MemberDetailView(ClubStaffRequiredMixin, DetailView):
|
||||||
|
template_name = "management/member_detail.html"
|
||||||
|
context_object_name = "member"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return members_visible_to(self.request.user, self.request.club)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
visible = members_visible_to(self.request.user, self.request.club)
|
||||||
|
my_family_ids = FamilyMembership.objects.filter(member=self.object).values_list("family_id", flat=True)
|
||||||
|
family_scoped_members = visible.filter(family_memberships__family_id__in=my_family_ids).distinct()
|
||||||
|
family_groups, _ = group_by_family(family_scoped_members)
|
||||||
|
|
||||||
|
return super().get_context_data(
|
||||||
|
family_groups=family_groups,
|
||||||
|
family_role_choices=FamilyMembership.FamilyRole.choices,
|
||||||
|
add_child_form=AddChildForm(),
|
||||||
|
add_parent_form=AddParentForm(),
|
||||||
|
attach_to_family_form=AttachToFamilyForm(club=self.request.club, member=self.object),
|
||||||
|
current_membership=ClubMembership.objects.filter(club=self.request.club, member=self.object, season=current_season(self.request.club)).first(),
|
||||||
|
membership_history=ClubMembership.objects.filter(club=self.request.club, member=self.object).select_related("season").order_by("-season__start_date"),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MemberAttachToFamilyView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormView):
|
||||||
|
"""Reachable only via the "Add to family" modal on a standalone member's page."""
|
||||||
|
|
||||||
|
form_class = AttachToFamilyForm
|
||||||
|
http_method_names = ["post"]
|
||||||
|
invalid_redirect_url_name = "management:member_detail"
|
||||||
|
|
||||||
|
def get_invalid_redirect_kwargs(self):
|
||||||
|
return {"pk": self.kwargs["pk"]}
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
member = get_object_or_404(members_visible_to(self.request.user, self.request.club), pk=self.kwargs["pk"])
|
||||||
|
return super().get_form_kwargs() | {"club": self.request.club, "member": member}
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
member = get_object_or_404(members_visible_to(self.request.user, self.request.club), pk=self.kwargs["pk"])
|
||||||
|
family = attach_to_family(member, role=form.cleaned_data["role"], family=form.cleaned_data["family"])
|
||||||
|
body = _("“%(member)s” is now part of %(family)s.") % {"member": member, "family": family}
|
||||||
|
notify(self.request, f"s|{_('Added to family')}|{body}")
|
||||||
|
return redirect("management:member_detail", pk=member.pk)
|
||||||
|
|
||||||
|
|
||||||
|
class MemberGrantLoginView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormView):
|
||||||
|
"""Reachable only via the "Grant login" modal on a login-less child's row in
|
||||||
|
_family_members_table.html. The form itself (management.forms.GrantLoginForm)
|
||||||
|
already rejects an email already in use, so form_valid only ever runs with a
|
||||||
|
genuinely free one."""
|
||||||
|
|
||||||
|
form_class = GrantLoginForm
|
||||||
|
http_method_names = ["post"]
|
||||||
|
invalid_redirect_url_name = "management:member_detail"
|
||||||
|
|
||||||
|
def get_invalid_redirect_kwargs(self):
|
||||||
|
return {"pk": self.kwargs["pk"]}
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
member = get_object_or_404(members_visible_to(self.request.user, self.request.club), pk=self.kwargs["pk"])
|
||||||
|
if member.user_id is not None:
|
||||||
|
# Already has one -- the row's button shouldn't have been there at all;
|
||||||
|
# a direct POST replay (e.g. a resubmitted form) is the only way here.
|
||||||
|
notify(self.request, f"w|{_('Already has a login')}|{_('“%(member)s” can already sign in.') % {'member': member} }")
|
||||||
|
else:
|
||||||
|
grant_login(member, form.cleaned_data["email"])
|
||||||
|
notify(self.request, f"s|{_('Login granted')}|{_('“%(member)s” can now sign in.') % {'member': member} }")
|
||||||
|
return redirect("management:member_detail", pk=member.pk)
|
||||||
|
|
||||||
|
|
||||||
|
class MemberDetachFromFamilyView(ClubAdminRequiredMixin, View):
|
||||||
|
def post(self, request, pk, family_pk):
|
||||||
|
member = get_object_or_404(members_visible_to(request.user, request.club), pk=pk)
|
||||||
|
family = get_object_or_404(Family, pk=family_pk, memberships__member=member)
|
||||||
|
# detach_from_family may delete `family` itself (left empty) -- str() it first,
|
||||||
|
# since Family.__str__ queries self.memberships, which needs a pk to still exist.
|
||||||
|
family_name = str(family)
|
||||||
|
detach_from_family(member, family)
|
||||||
|
body = _("“%(member)s” is no longer part of %(family)s.") % {"member": member, "family": family_name}
|
||||||
|
notify(request, f"w|{_('Removed from family')}|{body}")
|
||||||
|
return redirect("management:member_detail", pk=member.pk)
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyMembershipRoleUpdateView(ClubAdminRequiredMixin, View):
|
||||||
|
"""Reclassify one person's role within one specific family -- from the inline
|
||||||
|
dropdown in _family_members_table.html, reachable from both the member and
|
||||||
|
family detail pages since that partial is shared between them. Lands back on
|
||||||
|
whichever of those two pages the change came from: member_detail.html sends its
|
||||||
|
own URL as `next` so the admin doesn't get bounced off the member they were
|
||||||
|
looking at; family_detail.html sends nothing, since staying there is already
|
||||||
|
correct."""
|
||||||
|
|
||||||
|
def post(self, request, family_pk, member_pk):
|
||||||
|
family = get_object_or_404(families_of_club(request.club), pk=family_pk)
|
||||||
|
membership = get_object_or_404(FamilyMembership, family=family, member_id=member_pk)
|
||||||
|
|
||||||
|
role = request.POST.get("role")
|
||||||
|
if role not in FamilyMembership.FamilyRole.values:
|
||||||
|
title = _("Couldn't update role")
|
||||||
|
notify(request, f"e|{title}|{_('Not a valid role.')}")
|
||||||
|
else:
|
||||||
|
membership.role = role
|
||||||
|
membership.save(update_fields=["role"])
|
||||||
|
body = _("“%(member)s” is now %(role)s in %(family)s.") % {"member": membership.member, "role": membership.get_role_display(), "family": family}
|
||||||
|
notify(request, f"s|{_('Role updated')}|{body}")
|
||||||
|
|
||||||
|
next_url = request.POST.get("next")
|
||||||
|
if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}, require_https=request.is_secure()):
|
||||||
|
return redirect(next_url)
|
||||||
|
return redirect("management:family_detail", pk=family.pk)
|
||||||
|
|
||||||
|
|
||||||
|
class MemberDeleteView(ClubAdminRequiredMixin, View):
|
||||||
|
def post(self, request, pk):
|
||||||
|
member = get_object_or_404(members_visible_to(request.user, request.club), pk=pk)
|
||||||
|
name = str(member)
|
||||||
|
# FamilyMembership cascades away with the member -- note which families
|
||||||
|
# they were in before that happens, so an emptied one can be cleaned up
|
||||||
|
# after, same as detach_from_family does.
|
||||||
|
family_ids = list(Family.objects.filter(memberships__member=member).values_list("pk", flat=True))
|
||||||
|
|
||||||
|
try:
|
||||||
|
member.delete()
|
||||||
|
except ProtectedError:
|
||||||
|
title = _("Can't delete")
|
||||||
|
body = _("“%(member)s” is still referenced by orders or invoices, and can't be deleted.") % {"member": name}
|
||||||
|
notify(request, f"e|{title}|{body}")
|
||||||
|
return redirect("management:member_detail", pk=pk)
|
||||||
|
|
||||||
|
Family.objects.filter(pk__in=family_ids, memberships__isnull=True).delete()
|
||||||
|
|
||||||
|
body = _("“%(member)s” has been deleted.") % {"member": name}
|
||||||
|
notify(request, f"w|{_('Member deleted')}|{body}")
|
||||||
|
return redirect("management:member_list")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Teams (full tier) --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TeamListView(ClubStaffRequiredMixin, ListView):
|
||||||
|
template_name = "management/team_list.html"
|
||||||
|
context_object_name = "teams"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
teams = Team.objects.filter(club=self.request.club)
|
||||||
|
search = self.request.GET.get("q", "").strip()
|
||||||
|
if search:
|
||||||
|
teams = teams.filter(name__icontains=search)
|
||||||
|
return teams
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
return super().get_context_data(search=self.request.GET.get("q", ""), **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class TeamCreateView(ClubAdminRequiredMixin, CreateView):
|
||||||
|
model = Team
|
||||||
|
form_class = TeamForm
|
||||||
|
template_name = "management/team_form.html"
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
response = super().form_valid(form)
|
||||||
|
body = _("“%(team)s” created.") % {"team": self.object}
|
||||||
|
notify(self.request, f"s|{_('Team created')}|{body}")
|
||||||
|
return response
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse("management:team_detail", args=[self.object.pk])
|
||||||
|
|
||||||
|
|
||||||
|
class TeamUpdateView(ClubAdminRequiredMixin, UpdateView):
|
||||||
|
model = Team
|
||||||
|
form_class = TeamForm
|
||||||
|
template_name = "management/team_form.html"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Team.objects.filter(club=self.request.club)
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
response = super().form_valid(form)
|
||||||
|
body = _("“%(team)s” updated.") % {"team": self.object}
|
||||||
|
notify(self.request, f"s|{_('Team updated')}|{body}")
|
||||||
|
return response
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse("management:team_detail", args=[self.object.pk])
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
return super().get_context_data(update_view=True, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class TeamDetailView(ClubStaffRequiredMixin, DetailView):
|
||||||
|
template_name = "management/team_detail.html"
|
||||||
|
context_object_name = "team"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Team.objects.filter(club=self.request.club)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Club roles (full tier: assign / revoke, no update -- a role isn't edited, just
|
||||||
|
# granted or taken away) -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ClubRoleListView(ClubAdminRequiredMixin, ListView):
|
||||||
|
template_name = "management/role_list.html"
|
||||||
|
context_object_name = "roles"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return ClubRole.objects.filter(club=self.request.club).select_related("member")
|
||||||
|
|
||||||
|
|
||||||
|
class ClubRoleCreateView(ClubAdminRequiredMixin, CreateView):
|
||||||
|
model = ClubRole
|
||||||
|
form_class = ClubRoleAssignForm
|
||||||
|
template_name = "management/role_form.html"
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
return super().get_form_kwargs() | {"club": self.request.club}
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
# A member holds at most one ClubRole per club (the membership-status sync in
|
||||||
|
# club/signals.py already gave any active member an implicit MEMBER role) --
|
||||||
|
# so granting ADMIN/EDITOR promotes that existing row rather than inserting a
|
||||||
|
# second one, exactly like controlpanel.services.admins.grant_club_admin.
|
||||||
|
member, role = form.cleaned_data["member"], form.cleaned_data["role"]
|
||||||
|
self.object, created = ClubRole.objects.get_or_create(club=self.request.club, member=member, defaults={"role": role})
|
||||||
|
if not created and self.object.role != role:
|
||||||
|
self.object.role = role
|
||||||
|
self.object.save(update_fields=["role"])
|
||||||
|
|
||||||
|
body = _("“%(member)s” is now %(role)s.") % {"member": member, "role": self.object.get_role_display()}
|
||||||
|
notify(self.request, f"s|{_('Role granted')}|{body}")
|
||||||
|
return redirect(self.get_success_url())
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse("management:role_list")
|
||||||
|
|
||||||
|
|
||||||
|
class ClubRoleRevokeView(ClubAdminRequiredMixin, View):
|
||||||
|
def post(self, request, pk):
|
||||||
|
role = get_object_or_404(ClubRole, pk=pk, club=request.club)
|
||||||
|
member, role_label = role.member, role.get_role_display()
|
||||||
|
role.delete()
|
||||||
|
body = _("“%(member)s” is no longer %(role)s.") % {"member": member, "role": role_label}
|
||||||
|
notify(request, f"w|{_('Role revoked')}|{body}")
|
||||||
|
return redirect("management:role_list")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Everything else: correctly scoped and gated, but list-only for now ------------
|
||||||
|
|
||||||
|
|
||||||
|
class StubListMixin:
|
||||||
|
"""Shared shape for a placeholder list: proves out the query scoping and the
|
||||||
|
permission gate for an entity that doesn't have full CRUD yet -- the actual
|
||||||
|
create/edit UI is a follow-up, not part of this scaffold."""
|
||||||
|
|
||||||
|
template_name = "management/_generic_list.html"
|
||||||
|
page_title = ""
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
return super().get_context_data(page_title=self.page_title, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def families_of_club(club):
|
||||||
|
return Family.objects.filter(memberships__member__member_of__club=club).distinct()
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyCreateView(ClubAdminRequiredMixin, FormView):
|
||||||
|
"""One new family in one go: a parent (who gets a login) and a child (who
|
||||||
|
doesn't) -- see members.services.family.register_family."""
|
||||||
|
|
||||||
|
form_class = FamilyCreateForm
|
||||||
|
template_name = "management/family_form.html"
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
cd = form.cleaned_data
|
||||||
|
season = current_season(self.request.club)
|
||||||
|
self.family = register_family(
|
||||||
|
self.request.club,
|
||||||
|
season,
|
||||||
|
parent_email=cd["parent_email"],
|
||||||
|
parent_first_name=cd["parent_first_name"],
|
||||||
|
parent_last_name=cd["parent_last_name"],
|
||||||
|
child_first_name=cd["child_first_name"],
|
||||||
|
child_last_name=cd["child_last_name"],
|
||||||
|
child_date_of_birth=cd["child_date_of_birth"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if season is not None:
|
||||||
|
body = _("%(family)s added to the club for %(season)s.") % {"family": self.family, "season": season}
|
||||||
|
notify(self.request, f"s|{_('Family added')}|{body}")
|
||||||
|
else:
|
||||||
|
body = _("%(family)s was created, but there's no active season to sign them up for yet.") % {"family": self.family}
|
||||||
|
notify(self.request, f"w|{_('Family added, but not rostered')}|{body}")
|
||||||
|
|
||||||
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse("management:family_detail", args=[self.family.pk])
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyDetailView(ClubStaffRequiredMixin, DetailView):
|
||||||
|
"""The family overview: everyone in it, plus the add-parent/add-child actions.
|
||||||
|
Reached by clicking a family's name wherever one is shown (the member list's
|
||||||
|
Family column, another member's own Family panel) -- there's no standalone
|
||||||
|
"Families" nav entry; family is something you see through Members."""
|
||||||
|
|
||||||
|
template_name = "management/family_detail.html"
|
||||||
|
context_object_name = "family"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return families_of_club(self.request.club)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
visible = members_visible_to(self.request.user, self.request.club)
|
||||||
|
members = visible.filter(family_memberships__family=self.object).distinct()
|
||||||
|
# group_by_family scopes by member, not family -- a member visible here
|
||||||
|
# because they're in *this* family can also belong to another one, in
|
||||||
|
# which case groups has more than one entry. groups[0] would then pick
|
||||||
|
# whichever family happened to sort first, not necessarily this page's own.
|
||||||
|
groups, _ = group_by_family(members)
|
||||||
|
group = next((g for g in groups if g["family"] == self.object), None) or {"family": self.object, "guardians": [], "children": [], "others": [], "all": []}
|
||||||
|
|
||||||
|
return super().get_context_data(
|
||||||
|
group=group,
|
||||||
|
family_role_choices=FamilyMembership.FamilyRole.choices,
|
||||||
|
add_child_form=AddChildForm(),
|
||||||
|
add_parent_form=AddParentForm(),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyAddChildView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormView):
|
||||||
|
"""Reachable only via the "Add child" modal on the family overview / member
|
||||||
|
detail pages -- a family that needs one more child registered, most often a
|
||||||
|
sibling joining."""
|
||||||
|
|
||||||
|
form_class = AddChildForm
|
||||||
|
http_method_names = ["post"]
|
||||||
|
invalid_redirect_url_name = "management:family_detail"
|
||||||
|
|
||||||
|
def get_invalid_redirect_kwargs(self):
|
||||||
|
return {"pk": self.kwargs["pk"]}
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
family = get_object_or_404(families_of_club(self.request.club), pk=self.kwargs["pk"])
|
||||||
|
child = add_child_to_family(self.request.club, current_season(self.request.club), family, **form.cleaned_data)
|
||||||
|
body = _("“%(child)s” added to %(family)s.") % {"child": child, "family": family}
|
||||||
|
notify(self.request, f"s|{_('Child registered')}|{body}")
|
||||||
|
return redirect("management:family_detail", pk=family.pk)
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyAddParentView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormView):
|
||||||
|
"""Reachable only via the "Add parent" modal on the family overview / member
|
||||||
|
detail pages -- a family that needs one more parent/guardian registered."""
|
||||||
|
|
||||||
|
form_class = AddParentForm
|
||||||
|
http_method_names = ["post"]
|
||||||
|
invalid_redirect_url_name = "management:family_detail"
|
||||||
|
|
||||||
|
def get_invalid_redirect_kwargs(self):
|
||||||
|
return {"pk": self.kwargs["pk"]}
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
family = get_object_or_404(families_of_club(self.request.club), pk=self.kwargs["pk"])
|
||||||
|
parent = add_parent_to_family(self.request.club, current_season(self.request.club), family, **form.cleaned_data)
|
||||||
|
body = _("“%(parent)s” added to %(family)s.") % {"parent": parent, "family": family}
|
||||||
|
notify(self.request, f"s|{_('Parent registered')}|{body}")
|
||||||
|
return redirect("management:family_detail", pk=family.pk)
|
||||||
|
|
||||||
|
|
||||||
|
class PositionListView(ClubAdminRequiredMixin, StubListMixin, ListView):
|
||||||
|
page_title = _("Positions")
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Position.objects.filter(club=self.request.club)
|
||||||
|
|
||||||
|
|
||||||
|
class RosterListView(ClubStaffRequiredMixin, StubListMixin, ListView):
|
||||||
|
page_title = _("Roster")
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return TeamMembership.objects.filter(team__club=self.request.club)
|
||||||
|
|
||||||
|
|
||||||
|
class StaffListView(ClubStaffRequiredMixin, StubListMixin, ListView):
|
||||||
|
page_title = _("Staff")
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return StaffAssignment.objects.filter(team__club=self.request.club)
|
||||||
|
|
||||||
|
|
||||||
|
class EventListView(ClubStaffRequiredMixin, StubListMixin, ListView):
|
||||||
|
page_title = _("Events")
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Event.objects.filter(club=self.request.club)
|
||||||
|
|
||||||
|
|
||||||
|
class EventSeriesListView(ClubStaffRequiredMixin, StubListMixin, ListView):
|
||||||
|
page_title = _("Event series")
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return EventSeries.objects.filter(club=self.request.club)
|
||||||
|
|
||||||
|
|
||||||
|
class LocationListView(ClubStaffRequiredMixin, StubListMixin, ListView):
|
||||||
|
page_title = _("Locations")
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Location.objects.filter(club=self.request.club)
|
||||||
|
|
||||||
|
|
||||||
|
class OpponentListView(ClubStaffRequiredMixin, StubListMixin, ListView):
|
||||||
|
page_title = _("Opponents")
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Opponent.objects.filter(club=self.request.club)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductListView(ClubAdminRequiredMixin, StubListMixin, ListView):
|
||||||
|
page_title = _("Products")
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Product.objects.filter(club=self.request.club)
|
||||||
|
|
||||||
|
|
||||||
|
class OrderListView(ClubAdminRequiredMixin, StubListMixin, ListView):
|
||||||
|
page_title = _("Orders")
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Order.objects.filter(club=self.request.club)
|
||||||
|
|
||||||
|
|
||||||
|
class DiscountListView(ClubAdminRequiredMixin, StubListMixin, ListView):
|
||||||
|
page_title = _("Discounts")
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Discount.objects.filter(club=self.request.club)
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceListView(ClubAdminRequiredMixin, StubListMixin, ListView):
|
||||||
|
page_title = _("Invoices")
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Invoice.objects.filter(club=self.request.club)
|
||||||
|
|
||||||
|
|
||||||
|
class FormListView(ClubAdminRequiredMixin, StubListMixin, ListView):
|
||||||
|
page_title = _("Forms")
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return FormBuilderForm.objects.filter(club=self.request.club)
|
||||||
|
|
||||||
|
|
||||||
|
class SubmissionListView(ClubAdminRequiredMixin, StubListMixin, ListView):
|
||||||
|
page_title = _("Submissions")
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Submission.objects.filter(form__club=self.request.club, form_id=self.kwargs["pk"])
|
||||||
122
members/services/family.py
Normal file
122
members/services/family.py
Normal 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()
|
||||||
@@ -12,6 +12,7 @@ dependencies = [
|
|||||||
"django-storages[s3]>=1.14.6",
|
"django-storages[s3]>=1.14.6",
|
||||||
"django-waffle>=5.0.0",
|
"django-waffle>=5.0.0",
|
||||||
"gunicorn>=26.0.0",
|
"gunicorn>=26.0.0",
|
||||||
|
"openpyxl>=3.1.5",
|
||||||
"pillow>=12.3.0",
|
"pillow>=12.3.0",
|
||||||
"psycopg[binary]>=3.3.4",
|
"psycopg[binary]>=3.3.4",
|
||||||
"python-dateutil>=2.9.0.post0",
|
"python-dateutil>=2.9.0.post0",
|
||||||
@@ -56,7 +57,7 @@ ignore = [
|
|||||||
|
|
||||||
[tool.ruff.lint.isort]
|
[tool.ruff.lint.isort]
|
||||||
known-first-party = [
|
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]
|
[tool.uv.sources]
|
||||||
django-lucide = { git = "https://github.com/bsiebens/lucide" }
|
django-lucide = { git = "https://github.com/bsiebens/lucide" }
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ INSTALLED_APPS = [
|
|||||||
"waffle",
|
"waffle",
|
||||||
"features.apps.FeaturesConfig",
|
"features.apps.FeaturesConfig",
|
||||||
"controlpanel.apps.ControlpanelConfig",
|
"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:
|
# Feature flags (django-waffle). The Flag model is swappable, like AUTH_USER_MODEL:
|
||||||
@@ -183,6 +186,9 @@ TEMPLATES = [
|
|||||||
"django.contrib.messages.context_processors.messages",
|
"django.contrib.messages.context_processors.messages",
|
||||||
"club.context_processors.branding",
|
"club.context_processors.branding",
|
||||||
"features.context_processors.maintenance",
|
"features.context_processors.maintenance",
|
||||||
|
"management.context_processors.is_admin",
|
||||||
|
"management.context_processors.management_link",
|
||||||
|
"management.context_processors.active_nav_section",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ urlpatterns = [
|
|||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path("accounts/", include("allauth.urls")),
|
path("accounts/", include("allauth.urls")),
|
||||||
path("controlpanel/", include("controlpanel.urls")),
|
path("controlpanel/", include("controlpanel.urls")),
|
||||||
|
path("manage/", include("management.urls")),
|
||||||
# "/" resolves per tenant: a club subdomain lands on the club, the base domain
|
# "/" 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 "/".
|
# hands off to the control panel. This is why LOGIN_REDIRECT_URL can stay "/".
|
||||||
path("", root, name="root"),
|
path("", root, name="root"),
|
||||||
|
|||||||
@@ -34,6 +34,8 @@
|
|||||||
--font-weight-bold: 700;
|
--font-weight-bold: 700;
|
||||||
--tracking-wide: 0.025em;
|
--tracking-wide: 0.025em;
|
||||||
--tracking-wider: 0.05em;
|
--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-duration: 150ms;
|
||||||
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
--default-font-family: var(--font-sans);
|
--default-font-family: var(--font-sans);
|
||||||
@@ -3310,6 +3312,9 @@
|
|||||||
.border-info {
|
.border-info {
|
||||||
border-color: var(--color-info);
|
border-color: var(--color-info);
|
||||||
}
|
}
|
||||||
|
.border-neutral {
|
||||||
|
border-color: var(--color-neutral);
|
||||||
|
}
|
||||||
.border-success {
|
.border-success {
|
||||||
border-color: var(--color-success);
|
border-color: var(--color-success);
|
||||||
}
|
}
|
||||||
@@ -3429,9 +3434,6 @@
|
|||||||
.object-contain {
|
.object-contain {
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
.p-1 {
|
|
||||||
padding: var(--spacing);
|
|
||||||
}
|
|
||||||
.p-2 {
|
.p-2 {
|
||||||
padding: calc(var(--spacing) * 2);
|
padding: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
@@ -3666,6 +3668,9 @@
|
|||||||
text-decoration-line: none;
|
text-decoration-line: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.opacity-40 {
|
||||||
|
opacity: 40%;
|
||||||
|
}
|
||||||
.opacity-50 {
|
.opacity-50 {
|
||||||
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 {
|
.btn-neutral {
|
||||||
@layer daisyui.l1.l2 {
|
@layer daisyui.l1.l2 {
|
||||||
--btn-color: var(--color-neutral);
|
--btn-color: var(--color-neutral);
|
||||||
@@ -3748,6 +3764,13 @@
|
|||||||
--btn-soft-bg: initial;
|
--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 {
|
.btn-primary {
|
||||||
@layer daisyui.l1.l2 {
|
@layer daisyui.l1.l2 {
|
||||||
--btn-color: var(--color-primary);
|
--btn-color: var(--color-primary);
|
||||||
|
|||||||
@@ -64,6 +64,11 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{% if user.is_authenticated %}
|
{% 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 %}
|
{% 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>
|
<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 %}
|
{% endif %}
|
||||||
|
|||||||
23
uv.lock
generated
23
uv.lock
generated
@@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "fido2"
|
name = "fido2"
|
||||||
version = "2.2.1"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "26.2"
|
version = "26.2"
|
||||||
@@ -621,6 +642,7 @@ dependencies = [
|
|||||||
{ name = "django-storages", extra = ["s3"] },
|
{ name = "django-storages", extra = ["s3"] },
|
||||||
{ name = "django-waffle" },
|
{ name = "django-waffle" },
|
||||||
{ name = "gunicorn" },
|
{ name = "gunicorn" },
|
||||||
|
{ name = "openpyxl" },
|
||||||
{ name = "pillow" },
|
{ name = "pillow" },
|
||||||
{ name = "psycopg", extra = ["binary"] },
|
{ name = "psycopg", extra = ["binary"] },
|
||||||
{ name = "python-dateutil" },
|
{ name = "python-dateutil" },
|
||||||
@@ -647,6 +669,7 @@ requires-dist = [
|
|||||||
{ name = "django-storages", extras = ["s3"], specifier = ">=1.14.6" },
|
{ name = "django-storages", extras = ["s3"], specifier = ">=1.14.6" },
|
||||||
{ name = "django-waffle", specifier = ">=5.0.0" },
|
{ name = "django-waffle", specifier = ">=5.0.0" },
|
||||||
{ name = "gunicorn", specifier = ">=26.0.0" },
|
{ name = "gunicorn", specifier = ">=26.0.0" },
|
||||||
|
{ name = "openpyxl", specifier = ">=3.1.5" },
|
||||||
{ name = "pillow", specifier = ">=12.3.0" },
|
{ name = "pillow", specifier = ">=12.3.0" },
|
||||||
{ name = "psycopg", extras = ["binary"], specifier = ">=3.3.4" },
|
{ name = "psycopg", extras = ["binary"], specifier = ">=3.3.4" },
|
||||||
{ name = "python-dateutil", specifier = ">=2.9.0.post0" },
|
{ name = "python-dateutil", specifier = ">=2.9.0.post0" },
|
||||||
|
|||||||
Reference in New Issue
Block a user