From 062da00bb992d52dbdb69d62f6c31c7293905cd7 Mon Sep 17 00:00:00 2001 From: Bernard Siebens Date: Mon, 3 Aug 2026 17:01:15 +0200 Subject: [PATCH] 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. --- assets/app.css | 1 + club/admin.py | 22 +- ...paid_clubmembership_fee_amount_and_more.py | 50 + club/mixins.py | 51 + club/models.py | 36 +- club/services/access.py | 12 + club/services/fees.py | 69 + club/tests.py | 113 +- features/admin.py | 17 + management/__init__.py | 0 management/apps.py | 5 + management/bulk_import.py | 163 ++ management/context_processors.py | 88 + management/forms.py | 144 ++ management/pdf.py | 30 + .../management/_family_members_table.html | 92 + .../templates/management/_generic_list.html | 33 + .../templates/management/_nav_items.html | 49 + management/templates/management/base.html | 42 + .../templates/management/family_detail.html | 31 + .../templates/management/family_form.html | 43 + management/templates/management/home.html | 245 +++ .../templates/management/member_detail.html | 157 ++ .../templates/management/member_form.html | 62 + .../templates/management/member_import.html | 39 + .../management/member_import_preview.html | 68 + .../templates/management/member_list.html | 102 + .../templates/management/membership_list.html | 226 +++ .../management/membership_list_pdf.html | 99 + .../templates/management/role_form.html | 29 + .../templates/management/role_list.html | 44 + .../templates/management/team_detail.html | 19 + .../templates/management/team_form.html | 31 + .../templates/management/team_list.html | 39 + management/tests.py | 1644 +++++++++++++++++ management/urls.py | 56 + management/views.py | 966 ++++++++++ members/services/family.py | 122 ++ pyproject.toml | 3 +- rosterchief/settings.py | 6 + rosterchief/urls.py | 1 + static/css/app.css | 29 +- templates/_base.html | 5 + uv.lock | 23 + 44 files changed, 5095 insertions(+), 11 deletions(-) create mode 100644 club/migrations/0016_clubmembership_amount_paid_clubmembership_fee_amount_and_more.py create mode 100644 club/mixins.py create mode 100644 club/services/fees.py create mode 100644 features/admin.py create mode 100644 management/__init__.py create mode 100644 management/apps.py create mode 100644 management/bulk_import.py create mode 100644 management/context_processors.py create mode 100644 management/forms.py create mode 100644 management/pdf.py create mode 100644 management/templates/management/_family_members_table.html create mode 100644 management/templates/management/_generic_list.html create mode 100644 management/templates/management/_nav_items.html create mode 100644 management/templates/management/base.html create mode 100644 management/templates/management/family_detail.html create mode 100644 management/templates/management/family_form.html create mode 100644 management/templates/management/home.html create mode 100644 management/templates/management/member_detail.html create mode 100644 management/templates/management/member_form.html create mode 100644 management/templates/management/member_import.html create mode 100644 management/templates/management/member_import_preview.html create mode 100644 management/templates/management/member_list.html create mode 100644 management/templates/management/membership_list.html create mode 100644 management/templates/management/membership_list_pdf.html create mode 100644 management/templates/management/role_form.html create mode 100644 management/templates/management/role_list.html create mode 100644 management/templates/management/team_detail.html create mode 100644 management/templates/management/team_form.html create mode 100644 management/templates/management/team_list.html create mode 100644 management/tests.py create mode 100644 management/urls.py create mode 100644 management/views.py create mode 100644 members/services/family.py diff --git a/assets/app.css b/assets/app.css index c8f8cef..224321f 100644 --- a/assets/app.css +++ b/assets/app.css @@ -5,6 +5,7 @@ @source "../templates"; @source "../controlpanel"; @source "../billing"; +@source "../management"; /* daisyUI: light is the default, dark applies automatically when the OS asks for it. An explicit data-theme on (set by the toggle) overrides both. */ diff --git a/club/admin.py b/club/admin.py index 0a0f3bd..36a34d9 100644 --- a/club/admin.py +++ b/club/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin from django.utils.translation import gettext_lazy as _ -from .models import Club, ClubMembership, ClubRole, Season +from .models import Club, ClubMembership, ClubRole, FeePayment, Season @admin.register(Club) @@ -20,17 +20,33 @@ class SeasonAdmin(admin.ModelAdmin): ordering = ["club", "-start_date"] +class FeePaymentInline(admin.TabularInline): + model = FeePayment + extra = 0 + readonly_fields = ["recorded_by"] + + @admin.register(ClubMembership) class ClubMembershipAdmin(admin.ModelAdmin): - list_display = ["club__name", "member__last_name", "member__first_name", "season", "status", "fee_status", "license"] + list_display = ["club__name", "member__last_name", "member__first_name", "season", "status", "fee_status", "fee_amount", "amount_paid", "license"] search_fields = ["club__name", "member__last_name", "member__first_name", "license"] list_filter = ["club", "season", "status", "fee_status"] raw_id_fields = ["member"] + # Money is settled by club.services.fees, which re-derives fee_status from the payments. + readonly_fields = ["amount_paid", "fee_status"] fieldsets = [ [None, {"fields": ["club", "season", "member"]}], - [_("Membership"), {"fields": ["license", "status", "fee_status"]}], + [_("Membership"), {"fields": ["license", "status", "fee_status", "fee_amount", "amount_paid"]}], [_("Dates"), {"fields": ["signed_up_at", "activated_at"]}], ] + inlines = [FeePaymentInline] + + +@admin.register(FeePayment) +class FeePaymentAdmin(admin.ModelAdmin): + list_display = ["membership", "amount", "method", "paid_at", "recorded_by"] + list_filter = ["method"] + search_fields = ["membership__club__name", "membership__member__last_name", "reference"] @admin.register(ClubRole) diff --git a/club/migrations/0016_clubmembership_amount_paid_clubmembership_fee_amount_and_more.py b/club/migrations/0016_clubmembership_amount_paid_clubmembership_fee_amount_and_more.py new file mode 100644 index 0000000..eb2056a --- /dev/null +++ b/club/migrations/0016_clubmembership_amount_paid_clubmembership_fee_amount_and_more.py @@ -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'], + }, + ), + ] diff --git a/club/mixins.py b/club/mixins.py new file mode 100644 index 0000000..d653570 --- /dev/null +++ b/club/mixins.py @@ -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() diff --git a/club/models.py b/club/models.py index e3635fe..f401560 100644 --- a/club/models.py +++ b/club/models.py @@ -1,6 +1,8 @@ import datetime +from decimal import Decimal -from django.core.validators import FileExtensionValidator, RegexValidator +from django.conf import settings +from django.core.validators import FileExtensionValidator, MinValueValidator, RegexValidator from django.db import models from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -182,6 +184,9 @@ class ClubMembership(ClubScopedModel): status = models.CharField(_("status"), max_length=250, choices=StatusChoices.choices, default=StatusChoices.PENDING) fee_status = models.CharField(_("fee status"), max_length=250, choices=FeeStatus.choices, default=FeeStatus.UNPAID) + fee_amount = models.DecimalField(_("fee amount"), max_digits=10, decimal_places=2, default=Decimal("0.00"), blank=True) + amount_paid = models.DecimalField(_("amount paid"), max_digits=10, decimal_places=2, default=Decimal("0.00"), blank=True, help_text=_("Kept in step with payments by the fee service; not hand-edited.")) + signed_up_at = models.DateField(_("signed up at"), blank=True, null=True) activated_at = models.DateField(_("activated at"), blank=True, null=True) @@ -200,6 +205,35 @@ class ClubMembership(ClubScopedModel): validate_club_scope(self, self.club_id, same_club_fields=("season",)) +class FeePayment(UUIDModel): + """Money received against one membership's fee. Several may land on one + membership: a family paying in two installments must not read as unpaid, and + the part that did arrive has to be recorded somewhere. Not itself club-scoped + -- its club is reached through ``membership``, same as DuePayment/Due.""" + + class Method(models.TextChoices): + BANK_TRANSFER = "bank_transfer", _("bank transfer") + CASH = "cash", _("cash") + CARD = "card", _("card") + OTHER = "other", _("other") + + membership = models.ForeignKey(ClubMembership, on_delete=models.CASCADE, related_name="payments", verbose_name=_("membership")) + amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2, validators=[MinValueValidator(Decimal("0.01"))]) + method = models.CharField(_("method"), max_length=20, choices=Method.choices, default=Method.BANK_TRANSFER) + reference = models.CharField(_("reference"), max_length=255, blank=True, help_text=_("Bank reference, transaction id — whatever lets you find this again.")) + paid_at = models.DateTimeField(_("paid at"), default=timezone.now) + note = models.TextField(_("note"), blank=True) + recorded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="recorded_fee_payments", verbose_name=_("recorded by")) + + class Meta: + verbose_name = _("fee payment") + verbose_name_plural = _("fee payments") + ordering = ["-paid_at"] + + def __str__(self): + return f"{self.membership} — {self.amount}" + + class ClubRole(ClubScopedModel): class Roles(models.TextChoices): ADMIN = "admin", _("admin") diff --git a/club/services/access.py b/club/services/access.py index 495e247..ecdce87 100644 --- a/club/services/access.py +++ b/club/services/access.py @@ -49,6 +49,18 @@ def is_club_admin(user: User, club: Club) -> bool: return has_club_role(user, club, ClubRole.Roles.ADMIN) +def has_management_access(user: User, club: Club) -> bool: + """Anyone with real authority in the club: ADMIN/EDITOR, or *any* current-season + staff assignment (coach, team manager, physio, ...). + + Deliberately excludes the plain MEMBER role -- every signed-up player (or club + member generally) holds that automatically the moment their ClubMembership goes + active (club/signals.py), so it says nothing about whether someone is staff. + """ + elevated = ClubRole.objects.filter(member__user=user, club=club, role__in=(ClubRole.Roles.ADMIN, ClubRole.Roles.EDITOR)).exists() + return elevated or teams_staffed_by(user, club).exists() + + def is_coach_manager(user: User, club: Club) -> bool: """Derived from a current-season StaffAssignment in a *management* position.""" return StaffAssignment.objects.filter( diff --git a/club/services/fees.py b/club/services/fees.py new file mode 100644 index 0000000..5974410 --- /dev/null +++ b/club/services/fees.py @@ -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) diff --git a/club/tests.py b/club/tests.py index 124f3f4..030aa1d 100644 --- a/club/tests.py +++ b/club/tests.py @@ -1,6 +1,7 @@ import datetime import uuid from contextlib import contextmanager +from decimal import Decimal from allauth.mfa.models import Authenticator from django.contrib import admin as django_admin @@ -16,7 +17,7 @@ from events.models import Event from members.models import Family, FamilyMembership, Member from teams.models import Position, StaffAssignment, Team, TeamMembership -from .models import Club, ClubMembership, ClubRole, Season, club_logo_path +from .models import Club, ClubMembership, ClubRole, FeePayment, Season, club_logo_path from .services.access import ( COACH_MANAGER, can_edit_event, @@ -27,6 +28,7 @@ from .services.access import ( teams_managed_by, teams_staffed_by, ) +from .services.fees import mark_as_paid, record_payment, remaining_balance from .tenancy import ( ClubTenantMiddleware, get_current_club, @@ -440,14 +442,17 @@ class SeasonGetCurrentTests(TestCase): self.assertEqual(found.club, self.other) def test_defaults_to_today(self): + # self.other, not self.club -- setUp's self.season (2026-08-01 to 2027-05-31) + # would otherwise also cover "today" once real dates reach that window, + # colliding with the one created here. today = timezone.now().date() current = Season.objects.create( - club=self.club, + club=self.other, start_date=today - datetime.timedelta(days=10), end_date=today + datetime.timedelta(days=10), ) - with with_club(self.club): + with with_club(self.other): self.assertEqual(Season.get_current(), current) def test_requires_an_active_club(self): @@ -1083,3 +1088,105 @@ class RootViewTests(TestCase): response = self.client.get("/", HTTP_HOST="ajax-united.rosterchief.app") self.assertRedirects(response, f"{reverse('account_login')}?next=/", fetch_redirect_response=False) + + +class FeeServiceTests(TestCase): + """club.services.fees -- record_payment/mark_as_paid/remaining_balance, the + service layer behind the Memberships page's per-row payment actions.""" + + def setUp(self): + self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + self.season = make_season(self.club) + self.member = Member.objects.create(first_name="Jane", last_name="Doe") + self.membership = ClubMembership.objects.create( + club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_amount=Decimal("150.00") + ) + + def roles(self): + return ClubRole.objects.filter(club=self.club, member=self.member) + + def test_remaining_balance_starts_at_the_full_fee(self): + self.assertEqual(remaining_balance(self.membership), Decimal("150.00")) + + def test_remaining_balance_is_never_negative(self): + record_payment(self.membership, amount=Decimal("200.00")) + + self.assertEqual(remaining_balance(self.membership), Decimal("0.00")) + + def test_a_partial_payment_creates_a_record_and_updates_the_running_total(self): + payment = record_payment(self.membership, amount=Decimal("50.00"), method=FeePayment.Method.CASH, reference="R1", note="first installment") + + self.assertEqual(payment.membership, self.membership) + self.assertEqual(payment.amount, Decimal("50.00")) + self.membership.refresh_from_db() + self.assertEqual(self.membership.amount_paid, Decimal("50.00")) + self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PARTIALLY_PAID) + # Not yet settled -- status doesn't change on a partial payment. + self.assertEqual(self.membership.status, ClubMembership.StatusChoices.PENDING) + + def test_multiple_partial_payments_accumulate(self): + record_payment(self.membership, amount=Decimal("50.00")) + record_payment(self.membership, amount=Decimal("60.00")) + + self.membership.refresh_from_db() + self.assertEqual(self.membership.amount_paid, Decimal("110.00")) + self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PARTIALLY_PAID) + self.assertEqual(FeePayment.objects.filter(membership=self.membership).count(), 2) + + def test_reaching_the_full_amount_settles_and_activates(self): + record_payment(self.membership, amount=Decimal("100.00")) + record_payment(self.membership, amount=Decimal("50.00")) + + self.membership.refresh_from_db() + self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PAID) + self.assertEqual(self.membership.status, ClubMembership.StatusChoices.ACTIVE) + self.assertEqual(self.membership.activated_at, timezone.localdate()) + self.assertTrue(self.roles().filter(role=ClubRole.Roles.MEMBER).exists()) + + def test_settling_in_full_does_not_overwrite_an_earlier_activated_at(self): + earlier = datetime.date(2026, 1, 1) + self.membership.activated_at = earlier + self.membership.save() + + record_payment(self.membership, amount=Decimal("150.00")) + + self.membership.refresh_from_db() + self.assertEqual(self.membership.activated_at, earlier) + + def test_a_waived_membership_is_untouched_by_a_payment(self): + self.membership.fee_status = ClubMembership.FeeStatus.WAIVED + self.membership.save() + + record_payment(self.membership, amount=Decimal("50.00")) + + self.membership.refresh_from_db() + self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.WAIVED) + + def test_mark_as_paid_records_the_exact_remaining_balance(self): + record_payment(self.membership, amount=Decimal("100.00")) + + mark_as_paid(self.membership) + + self.membership.refresh_from_db() + self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PAID) + payment = FeePayment.objects.get(membership=self.membership, amount=Decimal("50.00")) + self.assertEqual(payment.note, "Marked as paid") + + def test_mark_as_paid_with_no_fee_amount_set_skips_creating_a_zero_payment(self): + # FeePayment.amount has a MinValueValidator(0.01) -- a $0 "payment" isn't a + # real transaction, so this must flip the flags directly instead. + unpriced = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="No", last_name="Price"), season=self.season, status=ClubMembership.StatusChoices.PENDING) + + mark_as_paid(unpriced) + + unpriced.refresh_from_db() + self.assertEqual(unpriced.fee_status, ClubMembership.FeeStatus.PAID) + self.assertEqual(unpriced.status, ClubMembership.StatusChoices.ACTIVE) + self.assertFalse(FeePayment.objects.filter(membership=unpriced).exists()) + + def test_recorded_by_is_stored_on_the_payment(self): + user = get_user_model().objects.create_user(email="admin-fees@example.com", password="pw-secret-123") + + payment = record_payment(self.membership, amount=Decimal("50.00"), recorded_by=user) + + self.assertEqual(payment.recorded_by, user) diff --git a/features/admin.py b/features/admin.py new file mode 100644 index 0000000..8fc6ab2 --- /dev/null +++ b/features/admin.py @@ -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"] diff --git a/management/__init__.py b/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/management/apps.py b/management/apps.py new file mode 100644 index 0000000..f4ad1a6 --- /dev/null +++ b/management/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ManagementConfig(AppConfig): + name = "management" diff --git a/management/bulk_import.py b/management/bulk_import.py new file mode 100644 index 0000000..4715219 --- /dev/null +++ b/management/bulk_import.py @@ -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 diff --git a/management/context_processors.py b/management/context_processors.py new file mode 100644 index 0000000..b746a39 --- /dev/null +++ b/management/context_processors.py @@ -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)} diff --git a/management/forms.py b/management/forms.py new file mode 100644 index 0000000..2596b72 --- /dev/null +++ b/management/forms.py @@ -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})) diff --git a/management/pdf.py b/management/pdf.py new file mode 100644 index 0000000..32624c4 --- /dev/null +++ b/management/pdf.py @@ -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) diff --git a/management/templates/management/_family_members_table.html b/management/templates/management/_family_members_table.html new file mode 100644 index 0000000..ef1c740 --- /dev/null +++ b/management/templates/management/_family_members_table.html @@ -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 %} +
+ + + + + + + + + + + + {% for person in group.all %} + + + + + + + + {% endfor %} + +
{% trans "Last name" %}{% trans "First name" %}{% trans "Email" %}{% trans "Type" %}
{{ person.last_name }}{{ person.first_name }}{{ person.contact_email|default:"-" }} + {% if is_club_admin %} +
+ {% csrf_token %} + {% if next_url %}{% endif %} + +
+ {% else %} + {{ person.role_in_family_display|capfirst }} + {% endif %} +
+ {% if is_club_admin %} +
+ {% if person.grant_login_form %} + + {% endif %} + {% lucide "pencil" size=14 %} {% trans "Edit" %} + + +
+ {% endif %} +
+
+ +{% 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 %} diff --git a/management/templates/management/_generic_list.html b/management/templates/management/_generic_list.html new file mode 100644 index 0000000..044a665 --- /dev/null +++ b/management/templates/management/_generic_list.html @@ -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 %} +
+
+
+ + + {% for object in object_list %} + + + + {% empty %} + + + + {% endfor %} + +
{{ object }}
{% trans "Nothing here yet." %}
+
+
+
+{% endblock panel %} diff --git a/management/templates/management/_nav_items.html b/management/templates/management/_nav_items.html new file mode 100644 index 0000000..1724403 --- /dev/null +++ b/management/templates/management/_nav_items.html @@ -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 %} +
  • + {% lucide "layout-dashboard" size=16 %} {% trans "Dashboard" %} +
  • + + +
  • {% lucide "users" size=16 %} {% trans "Members" %}
  • +{% if is_club_admin %} +
  • {% lucide "wallet" size=16 %} {% trans "Memberships" %}
  • +{% endif %} + +{% if is_club_admin %} + +
  • {% lucide "tags" size=16 %} {% trans "Positions" %}
  • +
  • {% lucide "shield-check" size=16 %} {% trans "Roles" %}
  • +{% endif %} + + +
  • {% lucide "shirt" size=16 %} {% trans "Teams" %}
  • +
  • {% lucide "clipboard-list" size=16 %} {% trans "Roster" %}
  • +
  • {% lucide "hard-hat" size=16 %} {% trans "Staff" %}
  • + + +
  • {% lucide "calendar" size=16 %} {% trans "Events" %}
  • +
  • {% lucide "repeat" size=16 %} {% trans "Event series" %}
  • +
  • {% lucide "map-pin" size=16 %} {% trans "Locations" %}
  • +
  • {% lucide "swords" size=16 %} {% trans "Opponents" %}
  • + +{% if is_club_admin %} + +
  • {% lucide "package" size=16 %} {% trans "Products" %}
  • +
  • {% lucide "shopping-cart" size=16 %} {% trans "Orders" %}
  • +
  • {% lucide "percent" size=16 %} {% trans "Discounts" %}
  • +
  • {% lucide "receipt" size=16 %} {% trans "Invoices" %}
  • + + +
  • {% lucide "clipboard-list" size=16 %} {% trans "Forms" %}
  • +{% endif %} diff --git a/management/templates/management/base.html b/management/templates/management/base.html new file mode 100644 index 0000000..9560dd1 --- /dev/null +++ b/management/templates/management/base.html @@ -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 %} + +{% endblock menu %} + +{% block main %} +
    +
    +

    + {% block heading %}{% trans "Management" %}{% endblock heading %} +

    + {% block subheading %}{% endblock subheading %} +
    + +
    + {% block actions %}{% endblock actions %} +
    +
    + + {# Below `lg` the sidebar is hidden, so the same links appear here rather than nowhere. #} + + + {% block panel %}{% endblock panel %} +{% endblock main %} diff --git a/management/templates/management/family_detail.html b/management/templates/management/family_detail.html new file mode 100644 index 0000000..0e8f06b --- /dev/null +++ b/management/templates/management/family_detail.html @@ -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 %} + + +{% endif %} +{% endblock actions %} + +{% block panel %} +
    +
    + {% include "management/_family_members_table.html" with group=group %} +
    +
    + +{% 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 %} diff --git a/management/templates/management/family_form.html b/management/templates/management/family_form.html new file mode 100644 index 0000000..4c00599 --- /dev/null +++ b/management/templates/management/family_form.html @@ -0,0 +1,43 @@ +{% extends "management/base.html" %} +{% load lucide ui i18n %} + +{% block heading %}{% trans "Add family" %}{% endblock heading %} + +{% block panel %} +
    +
    +
    + {% csrf_token %} + + {% for error in form.non_field_errors %} +
    + {{ error }} +
    + {% endfor %} + +

    {% lucide "user" size=18 %} {% trans "Parent / guardian" %}

    +

    {% trans "Gets a login -- they set a password via the reset link if this is a new email." %}

    +
    + {% form_field form.parent_first_name %} + {% form_field form.parent_last_name %} + {% form_field form.parent_email %} +
    + +
    + +

    {% lucide "baby" size=18 %} {% trans "Child" %}

    +

    {% trans "No login of their own -- the parent above manages things for them." %}

    +
    + {% form_field form.child_first_name %} + {% form_field form.child_last_name %} + {% form_field form.child_date_of_birth %} +
    + +
    + {% lucide "arrow-left" size=16 %} {% trans "Cancel" %} + +
    +
    +
    +
    +{% endblock panel %} diff --git a/management/templates/management/home.html b/management/templates/management/home.html new file mode 100644 index 0000000..60e600a --- /dev/null +++ b/management/templates/management/home.html @@ -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 %} +
    + {% lucide "calendar-x" size=20 %} + + {% trans "No season covers today, so this club cannot take a signup or schedule a match. Nothing errors — it is simply inert." %} + +
    + {% 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 %} +
    +
    +
    +
    {% lucide "user-x" size=16 %} {% trans "Teams without coach" %}
    +
    {{ attention.teams_without_manager }}
    +
    {% trans "Teams nobody can pick a squad for" %}
    +
    +
    + +
    +
    +
    {% lucide "user-minus" size=16 %} {% trans "Unrostered members" %}
    +
    {{ attention.unrostered }}
    +
    {% trans "Active members on no team" %}
    +
    +
    + +
    +
    +
    {% lucide "clock" size=16 %} {% trans "Pending" %}
    +
    {{ attention.pending_approvals }}
    +
    {% trans "Memberships awaiting approval" %}
    +
    +
    + +
    +
    +
    {% lucide "sparkles" size=16 %} {% trans "New members" %}
    +
    {{ attention.new_members }}
    +
    {% trans "First season at this club" %}
    +
    +
    + +
    +
    +
    {% lucide "repeat" size=16 %} {% trans "Renewal rate" %}
    +
    + {% if attention.renewal_rate is None %} + {% trans "N/A" %} + {% else %} + {{ attention.renewal_rate }}% + {% endif %} +
    +
    + {% if attention.renewal_rate is None %} + {% trans "No previous season" %} + {% else %} + + {% endif %} +
    +
    +
    + +
    +
    +
    {% lucide "user-check" size=16 %} {% trans "Attendance rate" %}
    +
    + {% if attention.attendance.turnout is None %} + {% trans "N/A" %} + {% else %} + {{ attention.attendance.turnout }}% + {% endif %} +
    +
    + {% if attention.attendance.turnout is None %} + {% trans "No events this season" %} + {% else %} + + {% endif %} +
    +
    +
    +
    + +
    +
    +
    +

    {% lucide "calendar" size=18 %} {% trans "Upcoming events" %}

    + {% lucide "arrow-right" size=14 %} {% trans "View all" %} +
    +
    + + + {% for event in upcoming_events %} + + + + + + {% empty %} + + + + {% endfor %} + +
    {{ event.start|date:"D j M, H:i" }}{{ event.title }}{{ event.get_kind_display|capfirst }}
    {% trans "Nothing scheduled." %}
    +
    +
    +
    + +
    +
    +
    +

    {% lucide "user-plus" size=18 %} {% trans "Signups per month" %}

    +

    {% trans "New members against returning ones." %}

    +
    + +
    +
    +
    + {% if is_club_admin %} +
    +
    +

    {% lucide "wallet" size=18 %} {% trans "Club fee status this season" %}

    +
    + +
    +
    +
    + {% endif %} +
    + +
    + {% for group in groups %} + {% if group.title != "Shop" or is_club_admin %} +
    +
    +

    {% lucide group.icon size=18 %} {{ group.title }}

    +
    + {% for label, value in group.stats %} +
    +
    {{ label }}
    +
    {% if group.title == "Shop" and label == "Outstanding" or label == "Revenue" %}€{% endif %}{{ value }}
    +
    + {% endfor %} +
    +
    +
    + {% endif %} + {% endfor %} +
    +{% endblock panel %} + +{% block extra_body %} + {% trans "New" as new_label %} + {% trans "Returning" as returning_label %} + {{ charts|json_script:"chart-data" }} + + +{% endblock extra_body %} diff --git a/management/templates/management/member_detail.html b/management/templates/management/member_detail.html new file mode 100644 index 0000000..07698a9 --- /dev/null +++ b/management/templates/management/member_detail.html @@ -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 %} + {% lucide "pencil" size=16 %} {% trans "Edit" %} + {% endif %} +{% endblock actions %} + +{% block panel %} +
    +
    +
    +

    {% lucide "user" size=18 %} {% trans "Personal information" %}

    +
    +
    +
    {% trans "Date of birth" %}
    +
    {{ member.date_of_birth|default:"-" }}
    +
    +
    +
    {% trans "Phone" %}
    +
    {{ member.phone.as_international|default:"-" }}
    +
    +
    +
    {% trans "Emergency phone" %}
    +
    {{ member.emergency_phone.as_international|default:"-" }}
    +
    +
    + +
    + {% if member.phone %} + {% lucide "phone" size=14 %} {% trans "Call" %} + {% endif %} + {% if member.emergency_phone %} + {% lucide "shield-alert" size=14 %} {% trans "Emergency call" %} + {% endif %} +
    +
    +
    + + {% 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 %} +
    +
    +

    {% lucide "id-card" size=18 %} {% trans "Club membership" %}

    + {% if current_membership %} +
    +
    +
    {% trans "License" %}
    +
    {{ current_membership.license|default:"-" }}
    +
    +
    +
    {% trans "Status" %}
    +
    {{ current_membership.get_status_display }}
    +
    +
    +
    {% trans "Fee status" %}
    +
    {{ current_membership.get_fee_status_display }}
    +
    +
    + {% else %} +

    {% trans "Not rostered for the current season." %}

    + {% endif %} + + {% if membership_history %} +
    +

    {% trans "Season history" %}

    +
    + + + + + + + + + + + {% for membership in membership_history %} + + + + + + + {% endfor %} + +
    {% trans "Season" %}{% trans "Status" %}{% trans "Fee status" %}{% trans "License" %}
    {{ membership.season.start_date|date:"Y" }} - {{ membership.season.end_date|date:"Y" }}{{ membership.get_status_display }}{{ membership.get_fee_status_display }}{{ membership.license|default:"-" }}
    +
    + {% endif %} +
    +
    +
    + + {% 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 %} +
    +
    +
    +

    + {% lucide "users" size=18 %} + {{ family_group.family }} +

    + {% if is_club_admin %} +
    + + + +
    + {% endif %} +
    + {% include "management/_family_members_table.html" with group=family_group next_url=request.path %} +
    +
    + + {% 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 %} +
    +
    +
    +

    {% lucide "users" size=18 %} {% trans "Family" %}

    + +
    + {% if not family_groups %} +

    {% trans "Not part of a family." %}

    + {% endif %} +
    +
    + + {% 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 %} diff --git a/management/templates/management/member_form.html b/management/templates/management/member_form.html new file mode 100644 index 0000000..ff8d828 --- /dev/null +++ b/management/templates/management/member_form.html @@ -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 %} + + {% endif %} +{% endblock actions %} + +{% block panel %} +
    +
    +
    + {% csrf_token %} + + {% for error in form.non_field_errors %} +
    + {{ error }} +
    + {% endfor %} + +
    + {% for field in form %} + {% form_field field %} + {% endfor %} +
    + + {% if membership_form %} +
    +

    {% lucide "id-card" size=18 %} {% trans "This season" %}

    + + {% for error in membership_form.non_field_errors %} +
    + {{ error }} +
    + {% endfor %} + +
    + {% for field in membership_form %} + {% form_field field %} + {% endfor %} +
    + {% endif %} + +
    + {% lucide "arrow-left" size=16 %} {% trans "Cancel" %} + +
    +
    +
    +
    + + {% 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 %} diff --git a/management/templates/management/member_import.html b/management/templates/management/member_import.html new file mode 100644 index 0000000..e6798fc --- /dev/null +++ b/management/templates/management/member_import.html @@ -0,0 +1,39 @@ +{% extends "management/base.html" %} +{% load lucide ui i18n %} + +{% block heading %}{% trans "Mass upload members" %}{% endblock heading %} + +{% block actions %} + {% lucide "download" size=16 %} {% trans "Download template" %} +{% endblock actions %} + +{% block panel %} +
    +
    +

    + {% 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 %} +

    + +
    + {% csrf_token %} + + {% for error in form.non_field_errors %} +
    + {{ error }} +
    + {% endfor %} + + {% for field in form %} + {% form_field field %} + {% endfor %} + +
    + {% lucide "arrow-left" size=16 %} {% trans "Cancel" %} + +
    +
    +
    +
    +{% endblock panel %} diff --git a/management/templates/management/member_import_preview.html b/management/templates/management/member_import_preview.html new file mode 100644 index 0000000..11fe6dc --- /dev/null +++ b/management/templates/management/member_import_preview.html @@ -0,0 +1,68 @@ +{% extends "management/base.html" %} +{% load lucide i18n %} + +{% block heading %}{% trans "Review import" %}{% endblock heading %} + +{% block panel %} + {% if not season %} +
    + {% lucide "triangle-alert" size=16 %} + {% trans "No active season — members will still be created, but won't be rostered for one until it exists." %} +
    + {% endif %} + +
    +
    +

    + {% 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 %} +

    + +
    + + + + + + + + + + + + {% for result in results %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
    {% trans "Row" %}{% trans "Last name" %}{% trans "First name" %}{% trans "Email" %}{% trans "Outcome" %}
    {{ result.line_number }}{{ result.raw.last_name }}{{ result.raw.first_name }}{{ result.raw.email }} + {% if result.member %} + {% trans "Will create" %} + {% else %} + {% trans "Skipped" %} +
    {{ result.errors|join:"; " }}
    + {% endif %} +
    {% trans "No rows found in the uploaded file." %}
    +
    + +
    + {% lucide "arrow-left" size=16 %} {% trans "Back" %} + {% if valid_count %} +
    + {% csrf_token %} + +
    + {% endif %} +
    +
    +
    +{% endblock panel %} diff --git a/management/templates/management/member_list.html b/management/templates/management/member_list.html new file mode 100644 index 0000000..e6444a6 --- /dev/null +++ b/management/templates/management/member_list.html @@ -0,0 +1,102 @@ +{% extends "management/base.html" %} +{% load lucide ui i18n %} + +{% block heading %}{% trans "Members" %}{% endblock heading %} + +{% block actions %} +{% lucide "download" size=16 %} {% trans "Download upload template" %} + {% if is_club_admin %} + {% lucide "upload" size=16 %} {% trans "Mass upload" %} + {% lucide "users" size=16 %} {% trans "Add family" %} + {% lucide "user-plus" size=16 %} {% trans "Add member" %} + {% endif %} +{% endblock actions %} + +{% block panel %} +
    + + + {% if search %} + {% lucide "x" size=16 %} {% trans "Clear filter" %} + {% endif %} +
    + +
    +
    +
    + + + + + + + + + + + + + {% for member in members %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
    {% trans "Last name" %}{% trans "First name" %}{% trans "Email" %}{% trans "Family" %}{% trans "Status" %}
    {{ member.last_name }}{{ member.first_name }}{{ member.contact_email|default:"-" }} + {% if member.family_memberships_display %} +
    + {% for fm in member.family_memberships_display %} + + {% endfor %} +
    + {% else %} + - + {% endif %} +
    + {% if member.current_membership %} + + {{ member.current_membership.get_status_display }} + + {% else %} + {% trans "unknown" %} + {% endif %} + + {% if is_club_admin %} +
    + {% lucide "pencil" size=14 %} {% trans "Edit" %} + +
    + {% endif %} +
    {% trans "No members yet." %}
    +
    +
    +
    + + {% 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 %} diff --git a/management/templates/management/membership_list.html b/management/templates/management/membership_list.html new file mode 100644 index 0000000..de2c81c --- /dev/null +++ b/management/templates/management/membership_list.html @@ -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 %} + {% lucide "file-down" size=16 %} {% trans "Export to PDF" %} +{% endblock actions %} + +{% block panel %} + {% if not current_season %} +
    + {% lucide "calendar-x" size=20 %} + {% trans "No season covers today, so there's nothing to show fee status for yet." %} +
    + {% else %} +
    +
    +
    +
    {% lucide "users" size=16 %} {% trans "Registered" %}
    +
    {{ kpi_total }}
    +
    {% trans "This season" %}
    +
    +
    +
    +
    +
    {% lucide "circle-check" size=16 %} {% trans "Paid" %}
    +
    {{ kpi_paid }}
    +
    +
    +
    +
    +
    {% lucide "circle-dashed" size=16 %} {% trans "Partially paid" %}
    +
    {{ kpi_partial }}
    +
    +
    +
    +
    +
    {% lucide "circle-x" size=16 %} {% trans "Unpaid" %}
    +
    {{ kpi_unpaid }}
    +
    +
    +
    +
    +
    {% lucide "circle-check" size=16 %} {% trans "Waived" %}
    +
    {{ kpi_waived }}
    +
    +
    +
    +
    +
    {% lucide "percent" size=16 %} {% trans "Paid rate" %}
    +
    + {% if kpi_paid_rate is None %}{% trans "N/A" %}{% else %}{{ kpi_paid_rate }}%{% endif %} +
    +
    +
    +
    + {% endif %} + +
    +
    + + + + + + + + + + + + {% lucide "x" size=16 %} {% trans "Reset" %} +
    +
    + +
    + {% csrf_token %} + + +
    +
    +
    + + {% blocktrans count counter=memberships|length %}{{ counter }} membership{% plural %}{{ counter }} memberships{% endblocktrans %} + + +
    + +
    + + + + + + + + + + + + + + + + + + {% for membership in memberships %} + + + + + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
    {% trans "Last name" %}{% trans "First name" %}{% trans "Email" %}{% trans "Family" %}{% trans "Status" %}{% trans "Fee status" %}{% trans "Owed" %}{% trans "Paid" %}{% trans "License" %}
    {{ membership.member.last_name }}{{ membership.member.first_name }}{{ membership.member.contact_email|default:"-" }} + {% if membership.member.family_memberships_display %} + {% for fm in membership.member.family_memberships_display %} + {{ fm.family }} {% if not forloop.last %},{% endif %} + {% endfor %} + {% else %} + - + {% endif %} + + + {{ membership.get_status_display }} + + + + {{ membership.get_fee_status_display }} + + € {{ membership.fee_amount }} + € {{ membership.amount_paid }} + {% if membership.record_payment_form and membership.fee_amount %} +
    {% blocktrans with remaining=membership.remaining_balance_display %}{{ remaining }} left{% endblocktrans %}
    + {% endif %} +
    {{ membership.license|default:"-" }} + {% if membership.record_payment_form %} +
    + + +
    + {% endif %} +
    {% trans "Nobody matches these filters." %}
    +
    +
    +
    +
    + + {% 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. #} +
    + {% csrf_token %} + +
    + {% 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 %} + +{% endblock extra_body %} diff --git a/management/templates/management/membership_list_pdf.html b/management/templates/management/membership_list_pdf.html new file mode 100644 index 0000000..c746a6c --- /dev/null +++ b/management/templates/management/membership_list_pdf.html @@ -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 %} + + + + + {% trans "Memberships" %} + + + +
    +
    +

    {{ club.name }}

    +
    {% trans "Membership fee status" %}{% if selected_season %} — {{ selected_season }}{% endif %}
    +
    +
    + {% blocktrans with generated=generated_at|date:"j F Y, H:i" %}Generated {{ generated }}{% endblocktrans %}
    + {% blocktrans count counter=memberships|length %}{{ counter }} member{% plural %}{{ counter }} members{% endblocktrans %} +
    +
    + + + + + + + + + + + + + + + + + {% for membership in memberships %} + + + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
    {% trans "Last name" %}{% trans "First name" %}{% trans "Email" %}{% trans "Family" %}{% trans "Status" %}{% trans "Fee status" %}{% trans "Owed" %}{% trans "Paid" %}{% trans "License" %}
    {{ membership.member.last_name }}{{ membership.member.first_name }}{{ membership.member.contact_email|default:"—" }} + {% for fm in membership.member.family_memberships_display %}{{ fm.family }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %} + + {{ membership.get_status_display }} + + {{ membership.get_fee_status_display }} + {{ membership.fee_amount }}{{ membership.amount_paid }}{{ membership.license|default:"—" }}
    {% trans "Nobody matches these filters." %}
    + + diff --git a/management/templates/management/role_form.html b/management/templates/management/role_form.html new file mode 100644 index 0000000..cfd5acc --- /dev/null +++ b/management/templates/management/role_form.html @@ -0,0 +1,29 @@ +{% extends "management/base.html" %} +{% load i18n lucide ui %} + +{% block heading %}{% trans "Grant role" %}{% endblock heading %} + +{% block panel %} +
    +
    +
    + {% csrf_token %} + + {% for error in form.non_field_errors %} +
    + {{ error }} +
    + {% endfor %} + + {% for field in form %} + {% form_field field %} + {% endfor %} + +
    + {% lucide "arrow-left" size=16 %} {% trans "Cancel" %} + +
    +
    +
    +
    +{% endblock panel %} diff --git a/management/templates/management/role_list.html b/management/templates/management/role_list.html new file mode 100644 index 0000000..ecccd27 --- /dev/null +++ b/management/templates/management/role_list.html @@ -0,0 +1,44 @@ +{% extends "management/base.html" %} +{% load i18n lucide %} + +{% block heading %}{% trans "Roles" %}{% endblock heading %} + +{% block actions %} + {% lucide "plus" size=16 %} {% trans "Grant role" %} +{% endblock actions %} + +{% block panel %} +
    +
    +
    + + + + + + + + + + {% for role in roles %} + + + + + + {% empty %} + + + + {% endfor %} + +
    {% trans "Member" %}{% trans "Role" %}
    {{ role.member }}{{ role.get_role_display }} +
    + {% csrf_token %} + +
    +
    {% trans "No roles granted yet." %}
    +
    +
    +
    +{% endblock panel %} diff --git a/management/templates/management/team_detail.html b/management/templates/management/team_detail.html new file mode 100644 index 0000000..db51ea6 --- /dev/null +++ b/management/templates/management/team_detail.html @@ -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 %} + {% lucide "pencil" size=16 %} {% trans "Edit" %} + {% endif %} +{% endblock actions %} + +{% block panel %} +
    +
    +

    {% trans "Roster and staff assignments for this team are managed from the Roster and Staff sections." %}

    +
    +
    +{% endblock panel %} diff --git a/management/templates/management/team_form.html b/management/templates/management/team_form.html new file mode 100644 index 0000000..c3fde72 --- /dev/null +++ b/management/templates/management/team_form.html @@ -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 %} +
    +
    +
    + {% csrf_token %} + + {% for error in form.non_field_errors %} +
    + {{ error }} +
    + {% endfor %} + +
    + {% for field in form %} + {% form_field field %} + {% endfor %} +
    + +
    + {% lucide "arrow-left" size=16 %} {% trans "Cancel" %} + +
    +
    +
    +
    +{% endblock panel %} diff --git a/management/templates/management/team_list.html b/management/templates/management/team_list.html new file mode 100644 index 0000000..b5d744f --- /dev/null +++ b/management/templates/management/team_list.html @@ -0,0 +1,39 @@ +{% extends "management/base.html" %} +{% load i18n lucide %} + +{% block heading %}{% trans "Teams" %}{% endblock heading %} + +{% block actions %} + {% if is_club_admin %} + {% lucide "plus" size=16 %} {% trans "New team" %} + {% endif %} +{% endblock actions %} + +{% block panel %} +
    +
    +
    + + + + + + + + + {% for team in teams %} + + + + + {% empty %} + + + + {% endfor %} + +
    {% trans "Name" %}{% trans "Short name" %}
    {{ team.name }}{{ team.short_name }}
    {% trans "No teams yet." %}
    +
    +
    +
    +{% endblock panel %} diff --git a/management/tests.py b/management/tests.py new file mode 100644 index 0000000..446fb3f --- /dev/null +++ b/management/tests.py @@ -0,0 +1,1644 @@ +import datetime +import sys +from decimal import Decimal +from io import BytesIO +from unittest import mock + +import openpyxl +from allauth.mfa.models import Authenticator +from django.contrib.auth import get_user_model +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase, override_settings +from django.urls import reverse +from django.utils import timezone + +from club.models import Club, ClubMembership, ClubRole, FeePayment, Season +from events.models import Event +from management.bulk_import import TEMPLATE_COLUMNS +from management.pdf import PDFExportError, render_pdf +from members.models import Family, FamilyMembership, Member +from shop.models import Order +from teams.models import Position, StaffAssignment, Team, TeamMembership + +User = get_user_model() + +XLSX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + + +def make_import_workbook(rows): + """An in-memory .xlsx upload -- header matching the real template, plus + whatever data rows a test wants to exercise.""" + workbook = openpyxl.Workbook() + sheet = workbook.active + sheet.append(TEMPLATE_COLUMNS) + for row in rows: + sheet.append(row) + + buffer = BytesIO() + workbook.save(buffer) + buffer.seek(0) + return SimpleUploadedFile("members.xlsx", buffer.read(), content_type=XLSX_CONTENT_TYPE) + + +def enrol_mfa(user): + return Authenticator.objects.create(user=user, type=Authenticator.Type.TOTP, data={"secret": "JBSWY3DPEHPK3PXP"}) + + +def make_season(club): + # Must genuinely cover *today*: current_season()/teams_staffed_by() key off + # Season.covering(club, timezone.localdate()), not just any season row. + today = timezone.localdate() + return Season.objects.create(club=club, start_date=today - datetime.timedelta(days=30), end_date=today + datetime.timedelta(days=300)) + + +@override_settings( + ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", + ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "rival-fc.rosterchief.app", "testserver"], +) +class ManagementTestBase(TestCase): + def setUp(self): + self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + self.season = make_season(self.club) + + self.admin_user = User.objects.create_user(email="admin@example.com", password="pw-secret-123") + self.admin_member = Member.objects.create(user=self.admin_user, first_name="Ada", last_name="Admin") + ClubMembership.objects.create(club=self.club, member=self.admin_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + ClubRole.objects.filter(club=self.club, member=self.admin_member).update(role=ClubRole.Roles.ADMIN) + enrol_mfa(self.admin_user) + + def club_get(self, name, *args): + return self.client.get(reverse(f"management:{name}", args=args), HTTP_HOST="ajax-united.rosterchief.app") + + def club_post(self, name, data, *args): + return self.client.post(reverse(f"management:{name}", args=args), data, HTTP_HOST="ajax-united.rosterchief.app") + + +class AccessTests(ManagementTestBase): + def test_anonymous_is_sent_to_login(self): + response = self.club_get("home") + + self.assertEqual(response.status_code, 302) + self.assertIn(reverse("account_login"), response.url) + + def test_a_club_member_with_no_role_or_staff_assignment_gets_403(self): + plain_user = User.objects.create_user(email="plain@example.com", password="pw-secret-123") + self.client.force_login(plain_user) + + self.assertEqual(self.club_get("home").status_code, 403) + + def test_a_plain_active_club_member_gets_403(self): + # An active ClubMembership auto-grants a MEMBER ClubRole (club/signals.py) -- + # every signed-up player has one. That alone must not be enough to get in, or + # players would reach the staff UI they're explicitly excluded from. + player_user = User.objects.create_user(email="player@example.com", password="pw-secret-123") + player_member = Member.objects.create(user=player_user, first_name="Paul", last_name="Player") + ClubMembership.objects.create(club=self.club, member=player_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + self.client.force_login(player_user) + + self.assertEqual(self.club_get("home").status_code, 403) + + def test_a_club_admin_can_reach_it(self): + self.client.force_login(self.admin_user) + + self.assertEqual(self.club_get("home").status_code, 200) + + def test_it_does_not_exist_on_the_base_domain(self): + # The management UI manages one club; the mirror image of controlpanel + # refusing to exist on a club subdomain. + self.client.force_login(self.admin_user) + + response = self.client.get(reverse("management:home"), HTTP_HOST="rosterchief.app") + + self.assertEqual(response.status_code, 404) + + def test_staff_with_only_a_staff_assignment_can_reach_staff_pages(self): + # No ClubRole at all -- authority comes purely from a current-season + # StaffAssignment, per club.services.access. + coach_user = User.objects.create_user(email="coach@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + team = Team.objects.create(club=self.club, name="U12", short_name="U12") + position = Position.objects.create(club=self.club, name="Coach", short_name="C", staff_position=True, management_position=True) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) + self.client.force_login(coach_user) + + self.assertEqual(self.club_get("member_list").status_code, 200) + + def test_staff_without_admin_role_cannot_reach_admin_only_pages(self): + coach_user = User.objects.create_user(email="coach2@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cody", last_name="Coach") + team = Team.objects.create(club=self.club, name="U13", short_name="U13") + position = Position.objects.create(club=self.club, name="Coach2", short_name="C2", staff_position=True, management_position=True) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) + self.client.force_login(coach_user) + + self.assertEqual(self.club_get("position_list").status_code, 403) + self.assertEqual(self.club_post("member_create", {"first_name": "X", "last_name": "Y"}).status_code, 403) + + +class NavLinkTests(ManagementTestBase): + """The global navbar's "Management" link, next to Django admin -- see + management.context_processors.management_link and templates/_base.html.""" + + def test_a_club_admin_sees_the_link_on_the_club_subdomain(self): + self.client.force_login(self.admin_user) + + self.assertContains(self.club_get("home"), reverse("management:home")) + + def test_a_plain_active_club_member_does_not_see_the_link(self): + player_user = User.objects.create_user(email="player2@example.com", password="pw-secret-123") + player_member = Member.objects.create(user=player_user, first_name="Pia", last_name="Player") + ClubMembership.objects.create(club=self.club, member=player_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + self.client.login(email="player2@example.com", password="pw-secret-123") + + # 403 on /manage/ itself, but the link must not appear on pages this user *can* + # reach either -- assert against a page outside the gate: the account view. + response = self.client.get(reverse("mfa_index"), HTTP_HOST="ajax-united.rosterchief.app") + + self.assertNotContains(response, reverse("management:home")) + + def test_the_link_is_absent_on_the_base_domain_even_for_a_club_admin(self): + # has_management_access requires a resolved club; the control panel/base domain + # has none, so the link -- which points at a single club's management app -- + # correctly never appears there regardless of who's signed in. + self.client.force_login(self.admin_user) + + response = self.client.get(reverse("mfa_index"), HTTP_HOST="rosterchief.app") + + self.assertNotContains(response, reverse("management:home")) + + +class ActiveNavHighlightTests(ManagementTestBase): + """The sidebar/mobile nav highlights whichever section the current page + belongs to -- see management.context_processors.active_nav_section.""" + + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + + def test_the_members_list_page_highlights_members(self): + response = self.club_get("member_list") + + self.assertContains(response, f'class="menu-active" href="{reverse("management:member_list")}"') + self.assertNotContains(response, f'class="menu-active" href="{reverse("management:home")}"') + + def test_a_member_detail_sub_page_still_highlights_members(self): + # member_detail has no nav entry of its own -- it belongs to the Members + # section, same as member_list, member_update, family_detail, etc. + member = Member.objects.create(first_name="Sub", last_name="Page") + ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + + response = self.club_get("member_detail", member.pk) + + self.assertContains(response, f'class="menu-active" href="{reverse("management:member_list")}"') + + def test_the_dashboard_highlights_dashboard_only(self): + response = self.club_get("home") + + self.assertContains(response, f'class="menu-active" href="{reverse("management:home")}"') + self.assertNotContains(response, f'class="menu-active" href="{reverse("management:member_list")}"') + + +class MemberManagementTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + + def test_member_list_is_scoped_to_the_club(self): + other_club = Club.objects.create(name="Rival FC", slug="rival-fc") + other_season = make_season(other_club) + other_member = Member.objects.create(first_name="Other", last_name="Person") + ClubMembership.objects.create(club=other_club, member=other_member, season=other_season, status=ClubMembership.StatusChoices.ACTIVE) + + response = self.club_get("member_list") + + self.assertNotContains(response, "Other Person") + + def test_creating_a_member_also_signs_them_up_for_the_current_season(self): + response = self.club_post("member_create", {"first_name": "New", "last_name": "Player", "email": "new@example.com"}) + + member = Member.objects.get(first_name="New", last_name="Player") + self.assertRedirects(response, reverse("management:member_detail", args=[member.pk])) + self.assertTrue(ClubMembership.objects.filter(club=self.club, member=member, season=self.season).exists()) + + def test_updating_a_member(self): + member = Member.objects.create(first_name="Old", last_name="Name") + ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + + # This member has a current-season membership, so its section renders too -- + # one combined submit, so its (required) fields must come along. + self.club_post( + "member_update", + {"first_name": "New", "last_name": "Name", "status": ClubMembership.StatusChoices.ACTIVE, "fee_status": ClubMembership.FeeStatus.UNPAID}, + member.pk, + ) + + member.refresh_from_db() + self.assertEqual(member.first_name, "New") + + +class TeamManagementTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + + def test_team_list_is_scoped_to_the_club(self): + other_club = Club.objects.create(name="Rival FC", slug="rival-fc") + Team.objects.create(club=other_club, name="Rival Team", short_name="RT") + + response = self.club_get("team_list") + + self.assertNotContains(response, "Rival Team") + + def test_creating_a_team(self): + response = self.club_post("team_create", {"name": "U15", "short_name": "U15"}) + + team = Team.objects.get(club=self.club, name="U15") + self.assertRedirects(response, reverse("management:team_detail", args=[team.pk])) + + +class ClubRoleManagementTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + self.member = Member.objects.create(first_name="Future", last_name="Editor") + # An active membership already grants an implicit MEMBER role (club/signals.py) -- + # granting EDITOR must promote that row, not insert a second one. + ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + + def test_granting_a_role_promotes_the_existing_membership_role(self): + self.club_post("role_create", {"member": str(self.member.pk), "role": ClubRole.Roles.EDITOR}) + + role = ClubRole.objects.get(club=self.club, member=self.member) + self.assertEqual(role.role, ClubRole.Roles.EDITOR) + self.assertEqual(ClubRole.objects.filter(club=self.club, member=self.member).count(), 1) + + def test_revoking_a_role(self): + role = ClubRole.objects.get(club=self.club, member=self.member) + + self.club_post("role_revoke", {}, role.pk) + + self.assertFalse(ClubRole.objects.filter(pk=role.pk).exists()) + + +class FamilyManagementTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + + def test_registering_a_family_creates_a_login_parent_and_a_login_less_child(self): + response = self.club_post( + "family_create", + { + "parent_first_name": "Pat", + "parent_last_name": "Parent", + "parent_email": "pat.parent@example.com", + "child_first_name": "Cody", + "child_last_name": "Child", + "child_date_of_birth": "2015-04-01", + }, + ) + + family = Family.objects.get(memberships__member__first_name="Cody") + self.assertRedirects(response, reverse("management:family_detail", args=[family.pk])) + + parent = Member.objects.get(first_name="Pat", last_name="Parent") + child = Member.objects.get(first_name="Cody", last_name="Child") + self.assertEqual(parent.user.email, "pat.parent@example.com") + self.assertFalse(parent.user.has_usable_password(), "should set a password via the reset link, not be given one") + self.assertIsNone(child.user) + + self.assertEqual(FamilyMembership.objects.get(family=family, member=parent).role, FamilyMembership.FamilyRole.PARENT) + self.assertEqual(FamilyMembership.objects.get(family=family, member=child).role, FamilyMembership.FamilyRole.CHILD) + + # Both get signed up for the current season, same as a plain MemberCreateView. + self.assertTrue(ClubMembership.objects.filter(club=self.club, member=parent, season=self.season).exists()) + self.assertTrue(ClubMembership.objects.filter(club=self.club, member=child, season=self.season).exists()) + + def test_reusing_an_existing_login_by_email(self): + # A parent who's already a Member elsewhere (an existing login) must be + # reused, not duplicated, when registered onto a second family. + existing_user = User.objects.create_user(email="existing@example.com", password="pw-secret-123") + existing_member = Member.objects.create(user=existing_user, first_name="Existing", last_name="Parent") + + self.club_post( + "family_create", + { + "parent_first_name": "Ignored", + "parent_last_name": "Ignored", + "parent_email": "existing@example.com", + "child_first_name": "New", + "child_last_name": "Kid", + }, + ) + + self.assertEqual(Member.objects.filter(user=existing_user).count(), 1) + family = Family.objects.get(memberships__member__first_name="New") + self.assertIn(existing_member, family.guardians) + + def make_existing_family(self): + # A family only counts as "of this club" once at least one of its members + # has actually signed up (families_of_club, management/views.py) -- exactly + # what registering the first child through this app already does. + family = Family.objects.create() + first_kid = Member.objects.create(first_name="First", last_name="Kid") + FamilyMembership.objects.create(family=family, member=first_kid, role=FamilyMembership.FamilyRole.CHILD) + ClubMembership.objects.create(club=self.club, member=first_kid, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + return family + + def test_adding_a_child_to_an_existing_family(self): + family = self.make_existing_family() + + response = self.club_post("family_add_child", {"first_name": "Second", "last_name": "Kid", "date_of_birth": "2018-01-01"}, family.pk) + + self.assertRedirects(response, reverse("management:family_detail", args=[family.pk])) + self.assertEqual(family.children.count(), 2) + self.assertTrue(ClubMembership.objects.filter(club=self.club, member__first_name="Second", season=self.season).exists()) + + def test_adding_a_parent_to_an_existing_family_is_idempotent(self): + family = self.make_existing_family() + + self.club_post("family_add_parent", {"email": "new.parent@example.com", "first_name": "New", "last_name": "Parent"}, family.pk) + self.club_post("family_add_parent", {"email": "new.parent@example.com", "first_name": "", "last_name": ""}, family.pk) + + self.assertEqual(family.guardians.count(), 1) + + +class MemberListFamilyColumnTests(ManagementTestBase): + """The member list is one flat table -- family is a column (each member's family/ + role attached in Python, management.views.MemberListView), not a grouping.""" + + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + + def make_family(self, parent_name, child_name): + family = Family.objects.create() + parent = Member.objects.create(first_name=parent_name, last_name="Guardian") + child = Member.objects.create(first_name=child_name, last_name="Kid") + FamilyMembership.objects.create(family=family, member=parent, role=FamilyMembership.FamilyRole.PARENT) + FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD) + ClubMembership.objects.create(club=self.club, member=parent, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + ClubMembership.objects.create(club=self.club, member=child, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + return family, parent, child + + def test_a_family_renders_as_one_group_on_the_member_list(self): + family, parent, child = self.make_family("Pat", "Cody") + + response = self.club_get("member_list") + + self.assertContains(response, str(family)) + self.assertContains(response, str(parent)) + self.assertContains(response, str(child)) + + def test_a_member_with_no_family_has_an_empty_family_column(self): + loner = Member.objects.create(first_name="Lone", last_name="Member") + ClubMembership.objects.create(club=self.club, member=loner, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + + response = self.club_get("member_list") + + members_by_pk = {member.pk: member for member in response.context["members"]} + self.assertEqual(members_by_pk[loner.pk].family_memberships_display, []) + + def test_a_family_members_column_shows_the_family_and_role(self): + family, parent, child = self.make_family("Pat", "Cody") + + response = self.club_get("member_list") + + members_by_pk = {member.pk: member for member in response.context["members"]} + parent_fms = members_by_pk[parent.pk].family_memberships_display + child_fms = members_by_pk[child.pk].family_memberships_display + self.assertEqual([fm.family for fm in parent_fms], [family]) + self.assertEqual(parent_fms[0].role, "parent") + self.assertEqual(child_fms[0].role, "child") + + def test_a_member_in_two_families_shows_both_on_the_member_list(self): + family, parent, _child = self.make_family("Pat", "Cody") + other_family = Family.objects.create() + FamilyMembership.objects.create(family=other_family, member=parent, role=FamilyMembership.FamilyRole.OTHER) + + response = self.club_get("member_list") + + members_by_pk = {member.pk: member for member in response.context["members"]} + parent_fms = members_by_pk[parent.pk].family_memberships_display + self.assertEqual({fm.family for fm in parent_fms}, {family, other_family}) + + def test_families_of_other_clubs_do_not_appear(self): + other_club = Club.objects.create(name="Rival FC", slug="rival-fc") + other_season = make_season(other_club) + other_member = Member.objects.create(first_name="Other", last_name="Kid") + ClubMembership.objects.create(club=other_club, member=other_member, season=other_season, status=ClubMembership.StatusChoices.ACTIVE) + other_family = Family.objects.create() + FamilyMembership.objects.create(family=other_family, member=other_member, role=FamilyMembership.FamilyRole.CHILD) + + response = self.club_get("member_list") + + self.assertNotContains(response, str(other_family)) + self.assertNotContains(response, "Other Kid") + + def test_a_non_admin_never_sees_a_family_mate_outside_their_own_visibility(self): + # group_by_family must bucket the already-scoped members_visible_to() result, + # never Family.guardians/.children directly -- those ignore scoping entirely + # and would leak a family-mate a non-admin has no other reason to see. + _family, parent, child = self.make_family("Pat", "Cody") + + coach_user = User.objects.create_user(email="coach3@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + team = Team.objects.create(club=self.club, name="U14", short_name="U14") + position = Position.objects.create(club=self.club, name="Coach3", short_name="C3", staff_position=True, management_position=True) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) + self.client.force_login(coach_user) + + response = self.club_get("member_list") + + self.assertNotContains(response, str(parent)) + self.assertNotContains(response, str(child)) + + +class MemberListStatusColumnTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + + def test_current_season_status_is_shown(self): + member = Member.objects.create(first_name="Sam", last_name="Pending") + ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.PENDING) + + response = self.club_get("member_list") + + members_by_pk = {m.pk: m for m in response.context["members"]} + self.assertEqual(members_by_pk[member.pk].current_membership.status, ClubMembership.StatusChoices.PENDING) + self.assertContains(response, "Pending") + + def test_a_member_with_no_current_season_membership_shows_a_dash(self): + ClubMembership.objects.filter(club=self.club, member=self.admin_member).delete() + + response = self.club_get("member_list") + + members_by_pk = {m.pk: m for m in response.context["members"]} + self.assertIsNone(members_by_pk[self.admin_member.pk].current_membership) + + def test_a_lapsed_season_membership_does_not_count_as_current(self): + lapsed_season = Season.objects.create(club=self.club, start_date=self.season.start_date - datetime.timedelta(days=400), end_date=self.season.start_date - datetime.timedelta(days=40)) + member = Member.objects.create(first_name="Old", last_name="Season") + ClubMembership.objects.create(club=self.club, member=member, season=lapsed_season, status=ClubMembership.StatusChoices.ACTIVE) + + response = self.club_get("member_list") + + members_by_pk = {m.pk: m for m in response.context["members"]} + self.assertIsNone(members_by_pk[member.pk].current_membership) + + +class MemberGrantLoginTests(ManagementTestBase): + """A login-less child getting their own account -- see + members.services.family.grant_login and management.forms.GrantLoginForm.""" + + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + self.family = Family.objects.create() + self.child = Member.objects.create(first_name="Cody", last_name="Kid") + FamilyMembership.objects.create(family=self.family, member=self.child, role=FamilyMembership.FamilyRole.CHILD) + ClubMembership.objects.create(club=self.club, member=self.child, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + + def test_family_page_offers_the_button_for_a_login_less_child(self): + response = self.club_get("family_detail", self.family.pk) + + self.assertContains(response, reverse("management:member_grant_login", args=[self.child.pk])) + + def test_no_button_once_the_child_already_has_a_login(self): + self.child.user = User.objects.create_user(email="already@example.com", password="pw-secret-123") + self.child.save() + + response = self.club_get("family_detail", self.family.pk) + + self.assertNotContains(response, reverse("management:member_grant_login", args=[self.child.pk])) + + def test_no_button_for_a_parent(self): + parent = Member.objects.create(first_name="Pat", last_name="Parent") + FamilyMembership.objects.create(family=self.family, member=parent, role=FamilyMembership.FamilyRole.PARENT) + ClubMembership.objects.create(club=self.club, member=parent, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + + response = self.club_get("family_detail", self.family.pk) + + self.assertNotContains(response, reverse("management:member_grant_login", args=[parent.pk])) + + def test_the_form_is_prefilled_with_the_childs_contact_email_if_set(self): + self.child.email = "cody.kid@example.com" + self.child.save() + + response = self.club_get("family_detail", self.family.pk) + + self.assertContains(response, "cody.kid@example.com") + + def test_granting_a_login_creates_a_usable_account(self): + response = self.club_post("member_grant_login", {"email": "cody.kid@example.com"}, self.child.pk) + + self.assertRedirects(response, reverse("management:member_detail", args=[self.child.pk])) + self.child.refresh_from_db() + self.assertIsNotNone(self.child.user) + self.assertEqual(self.child.user.email, "cody.kid@example.com") + self.assertFalse(self.child.user.has_usable_password()) + + def test_an_email_already_in_use_is_rejected(self): + User.objects.create_user(email="taken@example.com", password="pw-secret-123") + + response = self.club_post("member_grant_login", {"email": "taken@example.com"}, self.child.pk) + + self.assertRedirects(response, reverse("management:member_detail", args=[self.child.pk])) + self.child.refresh_from_db() + self.assertIsNone(self.child.user) + + def test_non_admin_gets_403(self): + coach_user = User.objects.create_user(email="coach-grant@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + team = Team.objects.create(club=self.club, name="U18", short_name="U18") + position = Position.objects.create(club=self.club, name="Coach7", short_name="C7", staff_position=True) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) + self.client.force_login(coach_user) + + response = self.club_post("member_grant_login", {"email": "cody.kid@example.com"}, self.child.pk) + + self.assertEqual(response.status_code, 403) + self.child.refresh_from_db() + self.assertIsNone(self.child.user) + + +class MemberFamilyAttachDetachTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + self.standalone = Member.objects.create(first_name="Stan", last_name="Alone") + ClubMembership.objects.create(club=self.club, member=self.standalone, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + + def test_attaching_to_a_new_family(self): + response = self.club_post("member_attach_family", {"role": FamilyMembership.FamilyRole.PARENT, "family": ""}, self.standalone.pk) + + self.assertRedirects(response, reverse("management:member_detail", args=[self.standalone.pk])) + family = Family.objects.get(memberships__member=self.standalone) + self.assertIn(self.standalone, family.guardians) + + def test_attaching_to_an_existing_family(self): + family = Family.objects.create() + kid = Member.objects.create(first_name="Existing", last_name="Kid") + ClubMembership.objects.create(club=self.club, member=kid, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + FamilyMembership.objects.create(family=family, member=kid, role=FamilyMembership.FamilyRole.CHILD) + + self.club_post("member_attach_family", {"role": FamilyMembership.FamilyRole.PARENT, "family": str(family.pk)}, self.standalone.pk) + + self.assertIn(self.standalone, family.guardians) + self.assertEqual(Family.objects.filter(memberships__member=self.standalone).count(), 1) + + def test_detaching_from_family_removes_it_when_left_empty(self): + family = Family.objects.create() + FamilyMembership.objects.create(family=family, member=self.standalone, role=FamilyMembership.FamilyRole.CHILD) + + response = self.club_post("member_detach_family", {}, self.standalone.pk, family.pk) + + self.assertRedirects(response, reverse("management:member_detail", args=[self.standalone.pk])) + self.assertFalse(FamilyMembership.objects.filter(member=self.standalone).exists()) + self.assertFalse(Family.objects.filter(pk=family.pk).exists()) + + def test_detaching_from_family_keeps_it_when_others_remain(self): + family = Family.objects.create() + sibling = Member.objects.create(first_name="Sibling", last_name="Kid") + ClubMembership.objects.create(club=self.club, member=sibling, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + FamilyMembership.objects.create(family=family, member=self.standalone, role=FamilyMembership.FamilyRole.CHILD) + FamilyMembership.objects.create(family=family, member=sibling, role=FamilyMembership.FamilyRole.CHILD) + + self.club_post("member_detach_family", {}, self.standalone.pk, family.pk) + + self.assertTrue(Family.objects.filter(pk=family.pk).exists()) + self.assertIn(sibling, family.children) + + def test_detaching_from_one_family_keeps_membership_in_another(self): + family = Family.objects.create() + other_family = Family.objects.create() + FamilyMembership.objects.create(family=family, member=self.standalone, role=FamilyMembership.FamilyRole.CHILD) + FamilyMembership.objects.create(family=other_family, member=self.standalone, role=FamilyMembership.FamilyRole.OTHER) + + self.club_post("member_detach_family", {}, self.standalone.pk, family.pk) + + self.assertFalse(FamilyMembership.objects.filter(member=self.standalone, family=family).exists()) + self.assertTrue(FamilyMembership.objects.filter(member=self.standalone, family=other_family).exists()) + + def test_a_member_can_join_a_second_family(self): + family = Family.objects.create() + FamilyMembership.objects.create(family=family, member=self.standalone, role=FamilyMembership.FamilyRole.CHILD) + + response = self.club_post("member_attach_family", {"role": FamilyMembership.FamilyRole.OTHER, "family": ""}, self.standalone.pk) + + self.assertRedirects(response, reverse("management:member_detail", args=[self.standalone.pk])) + self.assertEqual(FamilyMembership.objects.filter(member=self.standalone).count(), 2) + self.assertTrue(FamilyMembership.objects.filter(member=self.standalone, family=family).exists()) + + def test_member_detail_shows_a_card_per_family(self): + family = Family.objects.create() + other_family = Family.objects.create() + FamilyMembership.objects.create(family=family, member=self.standalone, role=FamilyMembership.FamilyRole.CHILD) + FamilyMembership.objects.create(family=other_family, member=self.standalone, role=FamilyMembership.FamilyRole.OTHER) + + response = self.club_get("member_detail", self.standalone.pk) + + self.assertEqual(len(response.context["family_groups"]), 2) + self.assertContains(response, str(family)) + self.assertContains(response, str(other_family)) + # "Add to family" stays available even though the member is already in two. + self.assertContains(response, "Add to family") + + def test_attach_form_excludes_families_already_joined(self): + family = Family.objects.create() + FamilyMembership.objects.create(family=family, member=self.standalone, role=FamilyMembership.FamilyRole.CHILD) + + response = self.club_get("member_detail", self.standalone.pk) + + self.assertNotIn(family, response.context["attach_to_family_form"].fields["family"].queryset) + + +class MemberDeleteTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + self.member = Member.objects.create(first_name="Doomed", last_name="Member") + ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + + def test_deleting_a_member(self): + response = self.club_post("member_delete", {}, self.member.pk) + + self.assertRedirects(response, reverse("management:member_list")) + self.assertFalse(Member.objects.filter(pk=self.member.pk).exists()) + + def test_deleting_the_last_member_of_a_family_cleans_it_up(self): + family = Family.objects.create() + FamilyMembership.objects.create(family=family, member=self.member, role=FamilyMembership.FamilyRole.CHILD) + + self.club_post("member_delete", {}, self.member.pk) + + self.assertFalse(Family.objects.filter(pk=family.pk).exists()) + + def test_a_member_referenced_by_an_order_cannot_be_deleted(self): + # shop.Order.purchaser is PROTECT -- deleting must fail gracefully, not 500. + Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("10.00")) + + response = self.club_post("member_delete", {}, self.member.pk) + + self.assertRedirects(response, reverse("management:member_detail", args=[self.member.pk])) + self.assertTrue(Member.objects.filter(pk=self.member.pk).exists()) + + +class MemberClubMembershipFormTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + self.member = Member.objects.create(first_name="Fee", last_name="Payer") + self.membership = ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + + def test_editing_a_member_also_updates_their_current_membership(self): + response = self.club_post( + "member_update", + { + "first_name": "Fee", + "last_name": "Payer", + "license": "BE-9999", + "status": ClubMembership.StatusChoices.ACTIVE, + "fee_status": ClubMembership.FeeStatus.PAID, + }, + self.member.pk, + ) + + self.assertRedirects(response, reverse("management:member_detail", args=[self.member.pk])) + self.membership.refresh_from_db() + self.assertEqual(self.membership.license, "BE-9999") + self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PAID) + + def test_membership_section_is_fillable_when_not_yet_rostered_this_season(self): + member = Member.objects.create(first_name="Unrostered", last_name="Member") + # Visible (has *a* membership in this club), but not for the current season -- + # this is exactly the case that should now offer a fillable section to sign + # them up, rather than hiding it because nothing exists yet. + old_season = Season.objects.create(club=self.club, start_date=datetime.date(2020, 8, 1), end_date=datetime.date(2021, 5, 31)) + ClubMembership.objects.create(club=self.club, member=member, season=old_season, status=ClubMembership.StatusChoices.LAPSED) + + response = self.club_get("member_update", member.pk) + + self.assertContains(response, "This season") + self.assertFalse(ClubMembership.objects.filter(club=self.club, member=member, season=self.season).exists()) + + def test_saving_the_membership_section_signs_up_a_previously_unrostered_member(self): + member = Member.objects.create(first_name="Unrostered", last_name="Member") + # A ClubRole, not a ClubMembership -- gives the admin visibility into this + # member without a season-bound row already existing, which is exactly the + # "not signed up for anything yet" case this test means to exercise. + ClubRole.objects.create(club=self.club, member=member, role=ClubRole.Roles.MEMBER) + + response = self.club_post( + "member_update", + {"first_name": "Unrostered", "last_name": "Member", "status": ClubMembership.StatusChoices.ACTIVE, "fee_status": ClubMembership.FeeStatus.PAID}, + member.pk, + ) + + self.assertRedirects(response, reverse("management:member_detail", args=[member.pk])) + membership = ClubMembership.objects.get(club=self.club, member=member, season=self.season) + self.assertEqual(membership.status, ClubMembership.StatusChoices.ACTIVE) + self.assertEqual(membership.fee_status, ClubMembership.FeeStatus.PAID) + + def test_membership_section_absent_with_no_season_at_all(self): + # A fresh club, never given a season -- there's nothing sensible to sign up + # for, so unlike the "not rostered yet" case above, this stays hidden. + empty_club = Club.objects.create(name="No Season FC", slug="no-season-fc") + admin_member = Member.objects.create(first_name="Empty", last_name="Admin") + ClubRole.objects.create(club=empty_club, member=admin_member, role=ClubRole.Roles.ADMIN) + admin_user = User.objects.create_user(email="noseasonadmin@example.com", password="pw-secret-123") + admin_member.user = admin_user + admin_member.save() + enrol_mfa(admin_user) + self.client.force_login(admin_user) + member = Member.objects.create(first_name="No", last_name="Season") + ClubRole.objects.create(club=empty_club, member=member, role=ClubRole.Roles.MEMBER) + + with override_settings(ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "no-season-fc.rosterchief.app", "testserver"]): + response = self.client.get(reverse("management:member_update", args=[member.pk]), HTTP_HOST="no-season-fc.rosterchief.app") + + self.assertNotContains(response, "This season") + + def test_detail_page_shows_season_history(self): + old_season = Season.objects.create(club=self.club, start_date=datetime.date(2020, 8, 1), end_date=datetime.date(2021, 5, 31)) + ClubMembership.objects.create(club=self.club, member=self.member, season=old_season, status=ClubMembership.StatusChoices.LAPSED, fee_status=ClubMembership.FeeStatus.UNPAID) + + response = self.club_get("member_detail", self.member.pk) + + self.assertContains(response, f"{self.season.start_date:%Y} - {self.season.end_date:%Y}") + self.assertContains(response, f"{old_season.start_date:%Y} - {old_season.end_date:%Y}") + self.assertContains(response, "lapsed") + + def test_formatted_phone_number_is_shown(self): + self.member.phone = "+32476123456" + self.member.save() + + response = self.club_get("member_detail", self.member.pk) + + self.assertContains(response, "+32 476 12 34 56") + + +class FamilyDetailViewTests(ManagementTestBase): + """The family overview page, reached by clicking a family's name -- there's no + standalone "Families" nav entry or list.""" + + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + self.family = Family.objects.create() + self.parent = Member.objects.create(first_name="Pat", last_name="Guardian") + self.child = Member.objects.create(first_name="Cody", last_name="Kid") + FamilyMembership.objects.create(family=self.family, member=self.parent, role=FamilyMembership.FamilyRole.PARENT) + FamilyMembership.objects.create(family=self.family, member=self.child, role=FamilyMembership.FamilyRole.CHILD) + ClubMembership.objects.create(club=self.club, member=self.parent, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + ClubMembership.objects.create(club=self.club, member=self.child, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + + def test_shows_every_member_of_the_family(self): + response = self.club_get("family_detail", self.family.pk) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Guardian") + self.assertContains(response, "Kid") + + def test_a_family_from_another_club_404s(self): + other_club = Club.objects.create(name="Rival FC", slug="rival-fc") + other_family = Family.objects.create() + other_member = Member.objects.create(first_name="Other", last_name="Kid") + other_season = make_season(other_club) + ClubMembership.objects.create(club=other_club, member=other_member, season=other_season, status=ClubMembership.StatusChoices.ACTIVE) + FamilyMembership.objects.create(family=other_family, member=other_member, role=FamilyMembership.FamilyRole.CHILD) + + response = self.club_get("family_detail", other_family.pk) + + self.assertEqual(response.status_code, 404) + + def test_only_admins_see_edit_and_delete_actions(self): + # Give the coach visibility into this family's child (rostered on their team), + # so the assertion below actually exercises the admin-only button gating -- + # not just "the coach can't see this row at all". + coach_user = User.objects.create_user(email="coach4@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + team = Team.objects.create(club=self.club, name="U15", short_name="U15") + staff_position = Position.objects.create(club=self.club, name="Coach4", short_name="C4", staff_position=True, management_position=True) + player_position = Position.objects.create(club=self.club, name="Player4", short_name="P4", staff_position=False) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=staff_position) + TeamMembership.objects.create(team=team, member=self.child, season=self.season, position=player_position) + self.client.force_login(coach_user) + + response = self.club_get("family_detail", self.family.pk) + + self.assertContains(response, "Kid") + # The edit icon links straight to member_detail, same as the row's own name + # link -- not admin-exclusive, so member_delete is the actual gating signal. + self.assertNotContains(response, reverse("management:member_delete", args=[self.child.pk])) + + def test_admin_sees_a_remove_from_family_action_per_row(self): + response = self.club_get("family_detail", self.family.pk) + + self.assertContains(response, reverse("management:member_detach_family", args=[self.parent.pk, self.family.pk])) + self.assertContains(response, reverse("management:member_detach_family", args=[self.child.pk, self.family.pk])) + + def test_non_admin_does_not_see_the_remove_from_family_action(self): + coach_user = User.objects.create_user(email="coach6@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + team = Team.objects.create(club=self.club, name="U17", short_name="U17") + staff_position = Position.objects.create(club=self.club, name="Coach6", short_name="C6", staff_position=True, management_position=True) + player_position = Position.objects.create(club=self.club, name="Player6", short_name="P6", staff_position=False) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=staff_position) + TeamMembership.objects.create(team=team, member=self.child, season=self.season, position=player_position) + self.client.force_login(coach_user) + + response = self.club_get("family_detail", self.family.pk) + + self.assertNotContains(response, reverse("management:member_detach_family", args=[self.child.pk, self.family.pk])) + + def test_removing_a_family_member_from_the_row_button_detaches_only_them(self): + response = self.club_post("member_detach_family", {}, self.child.pk, self.family.pk) + + self.assertRedirects(response, reverse("management:member_detail", args=[self.child.pk])) + self.assertFalse(FamilyMembership.objects.filter(family=self.family, member=self.child).exists()) + self.assertTrue(FamilyMembership.objects.filter(family=self.family, member=self.parent).exists()) + + +class FamilyMembershipRoleUpdateTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + self.family = Family.objects.create() + self.member = Member.objects.create(first_name="Cody", last_name="Kid") + self.membership = FamilyMembership.objects.create(family=self.family, member=self.member, role=FamilyMembership.FamilyRole.CHILD) + ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + + def test_admin_can_change_the_role(self): + response = self.club_post("family_membership_role_update", {"role": FamilyMembership.FamilyRole.GUARDIAN}, self.family.pk, self.member.pk) + + self.assertRedirects(response, reverse("management:family_detail", args=[self.family.pk])) + self.membership.refresh_from_db() + self.assertEqual(self.membership.role, FamilyMembership.FamilyRole.GUARDIAN) + + def test_only_changes_the_role_in_the_targeted_family(self): + other_family = Family.objects.create() + other_membership = FamilyMembership.objects.create(family=other_family, member=self.member, role=FamilyMembership.FamilyRole.OTHER) + + self.club_post("family_membership_role_update", {"role": FamilyMembership.FamilyRole.PARENT}, self.family.pk, self.member.pk) + + self.membership.refresh_from_db() + other_membership.refresh_from_db() + self.assertEqual(self.membership.role, FamilyMembership.FamilyRole.PARENT) + self.assertEqual(other_membership.role, FamilyMembership.FamilyRole.OTHER) + + def test_an_invalid_role_is_rejected(self): + response = self.club_post("family_membership_role_update", {"role": "not-a-role"}, self.family.pk, self.member.pk) + + self.assertRedirects(response, reverse("management:family_detail", args=[self.family.pk])) + self.membership.refresh_from_db() + self.assertEqual(self.membership.role, FamilyMembership.FamilyRole.CHILD) + + def test_non_admin_gets_403(self): + coach_user = User.objects.create_user(email="coach-role@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + team = Team.objects.create(club=self.club, name="U16", short_name="U16") + position = Position.objects.create(club=self.club, name="Coach5", short_name="C5", staff_position=True) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) + self.client.force_login(coach_user) + + response = self.club_post("family_membership_role_update", {"role": FamilyMembership.FamilyRole.PARENT}, self.family.pk, self.member.pk) + + self.assertEqual(response.status_code, 403) + self.membership.refresh_from_db() + self.assertEqual(self.membership.role, FamilyMembership.FamilyRole.CHILD) + + def test_a_family_from_another_club_404s(self): + other_club = Club.objects.create(name="Rival FC", slug="rival-fc") + other_season = make_season(other_club) + other_family = Family.objects.create() + other_member = Member.objects.create(first_name="Other", last_name="Kid") + ClubMembership.objects.create(club=other_club, member=other_member, season=other_season, status=ClubMembership.StatusChoices.ACTIVE) + FamilyMembership.objects.create(family=other_family, member=other_member, role=FamilyMembership.FamilyRole.CHILD) + + response = self.club_post("family_membership_role_update", {"role": FamilyMembership.FamilyRole.PARENT}, other_family.pk, other_member.pk) + + self.assertEqual(response.status_code, 404) + + def test_the_dropdown_reflects_the_role_specific_to_that_family(self): + # Same member, a different role in a second family -- the "others" bucket + # used to read person.family_memberships.first(), which could silently show + # a role from the wrong family. This is exactly that scenario. + other_family = Family.objects.create() + FamilyMembership.objects.create(family=other_family, member=self.member, role=FamilyMembership.FamilyRole.OTHER) + + this_family_response = self.club_get("family_detail", self.family.pk) + other_family_response = self.club_get("family_detail", other_family.pk) + + self.assertContains(this_family_response, 'value="child" selected') + self.assertContains(other_family_response, 'value="other" selected') + + def test_redirects_to_next_when_changed_from_the_member_page(self): + next_url = reverse("management:member_detail", args=[self.member.pk]) + + response = self.club_post("family_membership_role_update", {"role": FamilyMembership.FamilyRole.GUARDIAN, "next": next_url}, self.family.pk, self.member.pk) + + self.assertRedirects(response, next_url) + + def test_ignores_an_unsafe_next_url(self): + response = self.club_post( + "family_membership_role_update", {"role": FamilyMembership.FamilyRole.GUARDIAN, "next": "https://evil.example.com/steal"}, self.family.pk, self.member.pk + ) + + self.assertRedirects(response, reverse("management:family_detail", args=[self.family.pk])) + + def test_member_detail_page_sends_its_own_url_as_next(self): + response = self.club_get("member_detail", self.member.pk) + + self.assertContains(response, f'name="next" value="{reverse("management:member_detail", args=[self.member.pk])}"') + + def test_family_detail_page_sends_no_next(self): + response = self.club_get("family_detail", self.family.pk) + + self.assertNotContains(response, 'name="next"') + + +class MembershipListViewTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + # The base class's own admin ClubMembership (fee_status defaults to UNPAID) + # would otherwise pollute every count below -- ClubRole (not ClubMembership) + # is what actually makes them an admin, so this is safe to drop. + ClubMembership.objects.filter(club=self.club, member=self.admin_member).delete() + + def make_membership(self, first_name, last_name, *, status=ClubMembership.StatusChoices.ACTIVE, fee_status=ClubMembership.FeeStatus.UNPAID, season=None, license=""): + member = Member.objects.create(first_name=first_name, last_name=last_name) + return ClubMembership.objects.create(club=self.club, member=member, season=season or self.season, status=status, fee_status=fee_status, license=license) + + def test_kpi_counts_for_the_current_season(self): + self.make_membership("Paid", "One", fee_status=ClubMembership.FeeStatus.PAID) + self.make_membership("Partial", "One", fee_status=ClubMembership.FeeStatus.PARTIALLY_PAID) + self.make_membership("Unpaid", "One", fee_status=ClubMembership.FeeStatus.UNPAID) + self.make_membership("Unpaid", "Two", fee_status=ClubMembership.FeeStatus.UNPAID) + self.make_membership("Waived", "One", fee_status=ClubMembership.FeeStatus.WAIVED) + + response = self.club_get("membership_list") + + self.assertEqual(response.context["kpi_total"], 5) + self.assertEqual(response.context["kpi_paid"], 1) + self.assertEqual(response.context["kpi_partial"], 1) + self.assertEqual(response.context["kpi_unpaid"], 2) + self.assertEqual(response.context["kpi_waived"], 1) + self.assertEqual(response.context["kpi_paid_rate"], 20) + + def test_default_list_excludes_paid_but_includes_waived(self): + paid = self.make_membership("Paid", "One", fee_status=ClubMembership.FeeStatus.PAID) + unpaid = self.make_membership("Unpaid", "One", fee_status=ClubMembership.FeeStatus.UNPAID) + waived = self.make_membership("Waived", "One", fee_status=ClubMembership.FeeStatus.WAIVED) + + response = self.club_get("membership_list") + + ids = {m.pk for m in response.context["memberships"]} + self.assertNotIn(paid.pk, ids) + self.assertIn(unpaid.pk, ids) + self.assertIn(waived.pk, ids) + + def test_fee_status_filter_narrows_to_one_value(self): + unpaid = self.make_membership("Unpaid", "One", fee_status=ClubMembership.FeeStatus.UNPAID) + waived = self.make_membership("Waived", "One", fee_status=ClubMembership.FeeStatus.WAIVED) + + response = self.client.get(reverse("management:membership_list") + "?fee_status=waived", HTTP_HOST="ajax-united.rosterchief.app") + + ids = {m.pk for m in response.context["memberships"]} + self.assertNotIn(unpaid.pk, ids) + self.assertIn(waived.pk, ids) + + def test_fee_status_all_includes_paid(self): + paid = self.make_membership("Paid", "One", fee_status=ClubMembership.FeeStatus.PAID) + + response = self.client.get(reverse("management:membership_list") + "?fee_status=all", HTTP_HOST="ajax-united.rosterchief.app") + + ids = {m.pk for m in response.context["memberships"]} + self.assertIn(paid.pk, ids) + + def test_status_filter(self): + active = self.make_membership("Active", "One", status=ClubMembership.StatusChoices.ACTIVE) + pending = self.make_membership("Pending", "One", status=ClubMembership.StatusChoices.PENDING) + + response = self.client.get(reverse("management:membership_list") + "?status=pending", HTTP_HOST="ajax-united.rosterchief.app") + + ids = {m.pk for m in response.context["memberships"]} + self.assertNotIn(active.pk, ids) + self.assertIn(pending.pk, ids) + + def test_team_filter(self): + on_team = self.make_membership("OnTeam", "Kid") + off_team = self.make_membership("OffTeam", "Kid") + team = Team.objects.create(club=self.club, name="U10", short_name="U10") + position = Position.objects.create(club=self.club, name="Player", short_name="P") + TeamMembership.objects.create(team=team, member=on_team.member, season=self.season, position=position) + + response = self.client.get(reverse("management:membership_list") + f"?team={team.pk}", HTTP_HOST="ajax-united.rosterchief.app") + + ids = {m.pk for m in response.context["memberships"]} + self.assertIn(on_team.pk, ids) + self.assertNotIn(off_team.pk, ids) + + def test_search_filter(self): + match = self.make_membership("Findme", "Person") + other = self.make_membership("Other", "Person") + + response = self.client.get(reverse("management:membership_list") + "?q=Findme", HTTP_HOST="ajax-united.rosterchief.app") + + ids = {m.pk for m in response.context["memberships"]} + self.assertIn(match.pk, ids) + self.assertNotIn(other.pk, ids) + + def test_search_matches_by_family_surname(self): + # Searching "Smith" should find a family member even when their own name + # isn't Smith -- e.g. a parent with a different surname than their kid. + family = Family.objects.create() + smith_kid = self.make_membership("Junior", "Smith") + FamilyMembership.objects.create(family=family, member=smith_kid.member, role=FamilyMembership.FamilyRole.CHILD) + other_parent = self.make_membership("Alex", "Jones") + FamilyMembership.objects.create(family=family, member=other_parent.member, role=FamilyMembership.FamilyRole.PARENT) + unrelated = self.make_membership("Nobody", "Related") + + response = self.client.get(reverse("management:membership_list") + "?q=Smith", HTTP_HOST="ajax-united.rosterchief.app") + + ids = {m.pk for m in response.context["memberships"]} + self.assertIn(smith_kid.pk, ids) + self.assertIn(other_parent.pk, ids) + self.assertNotIn(unrelated.pk, ids) + + def test_search_matches_an_explicit_family_name(self): + family = Family.objects.create(name="The Andersons") + membership = self.make_membership("Pat", "Vandermeer") + FamilyMembership.objects.create(family=family, member=membership.member, role=FamilyMembership.FamilyRole.PARENT) + unrelated = self.make_membership("Nobody", "Related") + + response = self.client.get(reverse("management:membership_list") + "?q=Anderson", HTTP_HOST="ajax-united.rosterchief.app") + + ids = {m.pk for m in response.context["memberships"]} + self.assertIn(membership.pk, ids) + self.assertNotIn(unrelated.pk, ids) + + def test_family_column_shows_every_family_a_member_belongs_to(self): + family = Family.objects.create() + other_family = Family.objects.create() + membership = self.make_membership("Multi", "Family") + FamilyMembership.objects.create(family=family, member=membership.member, role=FamilyMembership.FamilyRole.CHILD) + FamilyMembership.objects.create(family=other_family, member=membership.member, role=FamilyMembership.FamilyRole.OTHER) + + response = self.club_get("membership_list") + + by_pk = {m.pk: m for m in response.context["memberships"]} + family_ids = {fm.family_id for fm in by_pk[membership.pk].member.family_memberships_display} + self.assertEqual(family_ids, {family.pk, other_family.pk}) + + def test_season_filter_switches_away_from_current(self): + old_season = Season.objects.create(club=self.club, start_date=datetime.date(2020, 8, 1), end_date=datetime.date(2021, 5, 31)) + current_membership = self.make_membership("Current", "Season") + old_membership = self.make_membership("Old", "Season", season=old_season, fee_status=ClubMembership.FeeStatus.UNPAID) + + response = self.client.get(reverse("management:membership_list") + f"?season={old_season.pk}", HTTP_HOST="ajax-united.rosterchief.app") + + ids = {m.pk for m in response.context["memberships"]} + self.assertIn(old_membership.pk, ids) + self.assertNotIn(current_membership.pk, ids) + # KPIs stay pinned to the *current* season regardless of the list's own filter. + self.assertEqual(response.context["current_season"], self.season) + + def test_non_admin_gets_403(self): + coach_user = User.objects.create_user(email="coach-membership@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + team = Team.objects.create(club=self.club, name="U19", short_name="U19") + position = Position.objects.create(club=self.club, name="Coach8", short_name="C8", staff_position=True) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) + self.client.force_login(coach_user) + + response = self.club_get("membership_list") + + self.assertEqual(response.status_code, 403) + + def test_nav_entry_is_admin_only(self): + admin_response = self.club_get("home") + self.assertContains(admin_response, reverse("management:membership_list")) + + +class MembershipMarkPaidTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + self.member = Member.objects.create(first_name="Owed", last_name="Fee") + self.membership = ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.UNPAID) + + def test_marking_paid_sets_active_and_paid(self): + response = self.club_post("membership_mark_paid", {"membership_ids": [str(self.membership.pk)]}) + + self.assertRedirects(response, reverse("management:membership_list")) + self.membership.refresh_from_db() + self.assertEqual(self.membership.status, ClubMembership.StatusChoices.ACTIVE) + self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PAID) + self.assertEqual(self.membership.activated_at, timezone.localdate()) + + def test_marking_paid_grants_the_member_role(self): + # The whole reason this loops and calls .save() instead of .update() -- + # club/signals.py grants MEMBER via a post_save signal. + self.assertFalse(ClubRole.objects.filter(club=self.club, member=self.member, role=ClubRole.Roles.MEMBER).exists()) + + self.club_post("membership_mark_paid", {"membership_ids": [str(self.membership.pk)]}) + + self.assertTrue(ClubRole.objects.filter(club=self.club, member=self.member, role=ClubRole.Roles.MEMBER).exists()) + + def test_does_not_overwrite_an_existing_activated_at(self): + earlier = datetime.date(2026, 1, 1) + self.membership.activated_at = earlier + self.membership.save() + + self.club_post("membership_mark_paid", {"membership_ids": [str(self.membership.pk)]}) + + self.membership.refresh_from_db() + self.assertEqual(self.membership.activated_at, earlier) + + def test_does_not_touch_unselected_rows(self): + other = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="Not", last_name="Selected"), season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.UNPAID) + + self.club_post("membership_mark_paid", {"membership_ids": [str(self.membership.pk)]}) + + other.refresh_from_db() + self.assertEqual(other.status, ClubMembership.StatusChoices.PENDING) + self.assertEqual(other.fee_status, ClubMembership.FeeStatus.UNPAID) + + def test_a_membership_from_another_club_is_ignored(self): + other_club = Club.objects.create(name="Rival FC", slug="rival-fc") + other_season = make_season(other_club) + other_member = Member.objects.create(first_name="Other", last_name="Club") + other_membership = ClubMembership.objects.create(club=other_club, member=other_member, season=other_season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.UNPAID) + + self.club_post("membership_mark_paid", {"membership_ids": [str(other_membership.pk)]}) + + other_membership.refresh_from_db() + self.assertEqual(other_membership.status, ClubMembership.StatusChoices.PENDING) + + def test_redirects_to_a_safe_next(self): + next_url = reverse("management:membership_list") + "?status=pending" + + response = self.club_post("membership_mark_paid", {"membership_ids": [str(self.membership.pk)], "next": next_url}) + + self.assertRedirects(response, next_url) + + def test_ignores_an_unsafe_next(self): + response = self.club_post("membership_mark_paid", {"membership_ids": [str(self.membership.pk)], "next": "https://evil.example.com/"}) + + self.assertRedirects(response, reverse("management:membership_list")) + + def test_nothing_selected_is_a_harmless_no_op(self): + response = self.club_post("membership_mark_paid", {}) + + self.assertRedirects(response, reverse("management:membership_list")) + self.membership.refresh_from_db() + self.assertEqual(self.membership.status, ClubMembership.StatusChoices.PENDING) + + def test_non_admin_gets_403(self): + coach_user = User.objects.create_user(email="coach-markpaid@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + team = Team.objects.create(club=self.club, name="U20", short_name="U20") + position = Position.objects.create(club=self.club, name="Coach9", short_name="C9", staff_position=True) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) + self.client.force_login(coach_user) + + response = self.club_post("membership_mark_paid", {"membership_ids": [str(self.membership.pk)]}) + + self.assertEqual(response.status_code, 403) + self.membership.refresh_from_db() + self.assertEqual(self.membership.status, ClubMembership.StatusChoices.PENDING) + + +class MembershipRecordPaymentTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + self.member = Member.objects.create(first_name="Owed", last_name="Fee") + self.membership = ClubMembership.objects.create( + club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.UNPAID, fee_amount=Decimal("150.00") + ) + + def test_recording_a_partial_payment(self): + response = self.club_post("membership_record_payment", {"amount": "50.00", "method": FeePayment.Method.CASH, "reference": "R1"}, self.membership.pk) + + self.assertRedirects(response, reverse("management:membership_list")) + self.membership.refresh_from_db() + self.assertEqual(self.membership.amount_paid, Decimal("50.00")) + self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PARTIALLY_PAID) + payment = FeePayment.objects.get(membership=self.membership) + self.assertEqual(payment.reference, "R1") + self.assertEqual(payment.recorded_by, self.admin_user) + + def test_recording_the_full_remaining_amount_settles_it(self): + self.club_post("membership_record_payment", {"amount": "150.00", "method": FeePayment.Method.BANK_TRANSFER}, self.membership.pk) + + self.membership.refresh_from_db() + self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PAID) + self.assertEqual(self.membership.status, ClubMembership.StatusChoices.ACTIVE) + + def test_an_invalid_amount_is_rejected_without_recording_anything(self): + response = self.club_post("membership_record_payment", {"amount": "0", "method": FeePayment.Method.CASH}, self.membership.pk) + + self.assertRedirects(response, reverse("management:membership_list")) + self.assertFalse(FeePayment.objects.filter(membership=self.membership).exists()) + self.membership.refresh_from_db() + self.assertEqual(self.membership.amount_paid, Decimal("0.00")) + + def test_non_admin_gets_403(self): + coach_user = User.objects.create_user(email="coach-recordpay@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + team = Team.objects.create(club=self.club, name="U22", short_name="U22") + position = Position.objects.create(club=self.club, name="Coach11", short_name="C11", staff_position=True) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) + self.client.force_login(coach_user) + + response = self.club_post("membership_record_payment", {"amount": "50.00", "method": FeePayment.Method.CASH}, self.membership.pk) + + self.assertEqual(response.status_code, 403) + self.assertFalse(FeePayment.objects.filter(membership=self.membership).exists()) + + +class MembershipMarkFullyPaidTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + self.member = Member.objects.create(first_name="Owed", last_name="Fee") + self.membership = ClubMembership.objects.create( + club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.UNPAID, fee_amount=Decimal("150.00") + ) + + def test_settles_the_remaining_balance_in_one_click(self): + response = self.club_post("membership_mark_fully_paid", {}, self.membership.pk) + + self.assertRedirects(response, reverse("management:membership_list")) + 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.amount_paid, Decimal("150.00")) + + def test_a_membership_from_another_club_404s(self): + other_club = Club.objects.create(name="Rival FC", slug="rival-fc") + other_season = make_season(other_club) + other_member = Member.objects.create(first_name="Other", last_name="Club") + other_membership = ClubMembership.objects.create(club=other_club, member=other_member, season=other_season, fee_amount=Decimal("100.00")) + + response = self.club_post("membership_mark_fully_paid", {}, other_membership.pk) + + self.assertEqual(response.status_code, 404) + + def test_redirects_to_a_safe_next(self): + next_url = reverse("management:membership_list") + "?status=pending" + + response = self.club_post("membership_mark_fully_paid", {"next": next_url}, self.membership.pk) + + self.assertRedirects(response, next_url) + + def test_non_admin_gets_403(self): + coach_user = User.objects.create_user(email="coach-fullypaid@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + team = Team.objects.create(club=self.club, name="U23", short_name="U23") + position = Position.objects.create(club=self.club, name="Coach12", short_name="C12", staff_position=True) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) + self.client.force_login(coach_user) + + response = self.club_post("membership_mark_fully_paid", {}, self.membership.pk) + + self.assertEqual(response.status_code, 403) + self.membership.refresh_from_db() + self.assertEqual(self.membership.status, ClubMembership.StatusChoices.PENDING) + + +class MembershipListButtonVisibilityTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + + def make_membership(self, fee_status): + member = Member.objects.create(first_name=fee_status, last_name="Row") + return ClubMembership.objects.create(club=self.club, member=member, season=self.season, fee_status=fee_status, fee_amount=Decimal("100.00")) + + def test_buttons_hidden_for_a_paid_row(self): + membership = self.make_membership(ClubMembership.FeeStatus.PAID) + + response = self.client.get(reverse("management:membership_list") + "?fee_status=all", HTTP_HOST="ajax-united.rosterchief.app") + + self.assertNotContains(response, reverse("management:membership_mark_fully_paid", args=[membership.pk])) + + def test_buttons_hidden_for_a_waived_row(self): + membership = self.make_membership(ClubMembership.FeeStatus.WAIVED) + + response = self.client.get(reverse("management:membership_list") + "?fee_status=all", HTTP_HOST="ajax-united.rosterchief.app") + + self.assertNotContains(response, reverse("management:membership_mark_fully_paid", args=[membership.pk])) + + def test_buttons_shown_for_an_unpaid_row(self): + membership = self.make_membership(ClubMembership.FeeStatus.UNPAID) + + response = self.client.get(reverse("management:membership_list") + "?fee_status=all", HTTP_HOST="ajax-united.rosterchief.app") + + self.assertContains(response, reverse("management:membership_mark_fully_paid", args=[membership.pk])) + + +class RenderPdfTests(TestCase): + """management.pdf.render_pdf itself -- see billing/tests.py's equivalent + coverage of billing.services.invoices.render_pdf, same lazy-import shape.""" + + def test_the_pdf_library_is_only_needed_when_a_pdf_is_asked_for(self): + with mock.patch.dict(sys.modules, {"weasyprint": mock.MagicMock()}): + sys.modules["weasyprint"].HTML.return_value.write_pdf.return_value = b"%PDF-1.7" + + self.assertEqual(render_pdf("

    hi

    "), b"%PDF-1.7") + + def test_a_missing_pdf_library_says_what_is_missing(self): + with mock.patch.dict(sys.modules, {"weasyprint": None}), self.assertRaises(PDFExportError): + render_pdf("

    hi

    ") + + +class MembershipExportPdfTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + self.member = Member.objects.create(first_name="Print", last_name="Me") + ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE, fee_status=ClubMembership.FeeStatus.UNPAID) + + def test_downloads_as_a_pdf(self): + with mock.patch("management.views.membership_list_pdf", return_value=b"%PDF-fake") as renderer: + response = self.club_get("membership_export_pdf") + + self.assertEqual(response["Content-Type"], "application/pdf") + self.assertIn(".pdf", response["Content-Disposition"]) + self.assertEqual(response.content, b"%PDF-fake") + renderer.assert_called_once() + + def test_export_uses_the_same_filters_as_the_page(self): + # Same fixture as the "on-team" filter test for the page itself -- the PDF + # must reflect whatever's filtered, not the whole club. + other = Member.objects.create(first_name="Other", last_name="Person") + ClubMembership.objects.create(club=self.club, member=other, season=self.season, status=ClubMembership.StatusChoices.ACTIVE, fee_status=ClubMembership.FeeStatus.UNPAID) + + with mock.patch("management.views.membership_list_pdf", return_value=b"%PDF-fake") as renderer: + self.client.get(reverse("management:membership_export_pdf") + "?q=Print", HTTP_HOST="ajax-united.rosterchief.app") + + context = renderer.call_args[0][0] + names = {m.member.last_name for m in context["memberships"]} + self.assertEqual(names, {"Me"}) + + def test_a_missing_pdf_library_is_reported_rather_than_a_500(self): + # WeasyPrint needs native libs. Without them the button must explain itself. + with mock.patch("management.views.membership_list_pdf", side_effect=PDFExportError("PDF rendering needs the native pango/cairo libraries.")): + response = self.club_get("membership_export_pdf") + response = self.client.get(response.url, HTTP_HOST="ajax-united.rosterchief.app") + + self.assertContains(response, "pango") + + def test_non_admin_gets_403(self): + coach_user = User.objects.create_user(email="coach-export@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + team = Team.objects.create(club=self.club, name="U21", short_name="U21") + position = Position.objects.create(club=self.club, name="Coach10", short_name="C10", staff_position=True) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) + self.client.force_login(coach_user) + + response = self.club_get("membership_export_pdf") + + self.assertEqual(response.status_code, 403) + + +class MemberListRowActionsTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.member = Member.objects.create(first_name="Row", last_name="Actions") + ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + + def test_admin_sees_edit_and_delete_buttons(self): + self.client.force_login(self.admin_user) + + response = self.club_get("member_list") + + self.assertContains(response, reverse("management:member_delete", args=[self.member.pk])) + + def test_non_admin_does_not_see_edit_and_delete_buttons(self): + # Roster self.member on the coach's team so this actually tests the + # admin-only button gating, not just "the coach can't see this row at all". + coach_user = User.objects.create_user(email="coach5@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + team = Team.objects.create(club=self.club, name="U16", short_name="U16") + staff_position = Position.objects.create(club=self.club, name="Coach5", short_name="C5", staff_position=True, management_position=True) + player_position = Position.objects.create(club=self.club, name="Player5", short_name="P5", staff_position=False) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=staff_position) + TeamMembership.objects.create(team=team, member=self.member, season=self.season, position=player_position) + self.client.force_login(coach_user) + + response = self.club_get("member_list") + + self.assertContains(response, "Row") + self.assertNotContains(response, reverse("management:member_delete", args=[self.member.pk])) + + +class HomeViewTests(ManagementTestBase): + """The dashboard reuses controlpanel.services.statistics' club_attention/ + club_charts/club_statistics -- already club-scoped, so directly usable for this + club's own staff. Financial pieces (fee chart, Shop stat group) are admin-only, + same line the nav already draws around the Shop section.""" + + def make_coach(self, email): + coach_user = User.objects.create_user(email=email, password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + team = Team.objects.create(club=self.club, name=f"Team-{email}", short_name="T") + position = Position.objects.create(club=self.club, name=f"Coach-{email}", short_name="C", staff_position=True, management_position=True) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) + return coach_user + + def test_admin_sees_the_financial_sections(self): + self.client.force_login(self.admin_user) + + response = self.club_get("home") + + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'id="fees-chart"') + self.assertContains(response, "Open carts") + + def test_non_admin_staff_does_not_see_the_financial_sections(self): + self.client.force_login(self.make_coach("coach6@example.com")) + + response = self.club_get("home") + + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, 'id="fees-chart"') + self.assertNotContains(response, "Open carts") + + def test_upcoming_events_are_listed_in_order_and_future_only(self): + now = timezone.now() + past = Event.objects.create(club=self.club, kind=Event.EventKind.TRAINING, title="Past training", start=now - datetime.timedelta(days=1)) + soon = Event.objects.create(club=self.club, kind=Event.EventKind.TRAINING, title="Sooner training", start=now + datetime.timedelta(days=1)) + later = Event.objects.create(club=self.club, kind=Event.EventKind.MATCH, title="Later match", start=now + datetime.timedelta(days=5)) + self.client.force_login(self.admin_user) + + response = self.club_get("home") + + body = response.content.decode() + self.assertNotIn(past.title, body) + self.assertLess(body.index(soon.title), body.index(later.title)) + + def test_loads_fine_with_no_season_and_no_events(self): + # A fresh club, never given a season -- ClubMembership.season is PROTECT, so + # this is a new club rather than deleting self.season out from under setUp's + # own ClubMembership. + empty_club = Club.objects.create(name="Empty FC", slug="empty-fc") + admin_member = Member.objects.create(first_name="Empty", last_name="Admin") + ClubRole.objects.create(club=empty_club, member=admin_member, role=ClubRole.Roles.ADMIN) + admin_user = User.objects.create_user(email="emptyadmin@example.com", password="pw-secret-123") + admin_member.user = admin_user + admin_member.save() + enrol_mfa(admin_user) + self.client.force_login(admin_user) + + with override_settings(ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "empty-fc.rosterchief.app", "testserver"]): + response = self.client.get(reverse("management:home"), HTTP_HOST="empty-fc.rosterchief.app") + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "cannot take a signup") + self.assertContains(response, "Nothing scheduled.") + + +class MemberBulkImportTests(ManagementTestBase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + + def make_non_admin_staff(self): + coach_user = User.objects.create_user(email="coach-import@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + team = Team.objects.create(club=self.club, name="U14", short_name="U14") + position = Position.objects.create(club=self.club, name="CoachImport", short_name="CI", staff_position=True) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) + return coach_user + + def test_template_download_has_expected_headers_and_dropdowns(self): + response = self.club_get("member_import_template") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], XLSX_CONTENT_TYPE) + + workbook = openpyxl.load_workbook(BytesIO(response.content)) + sheet = workbook.active + header = [cell.value for cell in sheet[1]] + self.assertEqual(header, TEMPLATE_COLUMNS) + self.assertTrue(sheet.data_validations.dataValidation) + + def test_non_admin_can_download_template_but_not_upload(self): + coach_user = self.make_non_admin_staff() + self.client.force_login(coach_user) + + template_response = self.club_get("member_import_template") + upload_response = self.club_post("member_import", {"file": make_import_workbook([["Jamie", "Kid", "", "jamie@example.com", "", "", "", "", ""]])}) + + self.assertEqual(template_response.status_code, 200) + self.assertEqual(upload_response.status_code, 403) + + def test_anonymous_is_redirected_to_login_for_both(self): + self.client.logout() + + self.assertEqual(self.club_get("member_import_template").status_code, 302) + self.assertEqual(self.club_get("member_import").status_code, 302) + + def test_a_clean_row_previews_as_will_create(self): + upload = make_import_workbook([["Jamie", "Kid", "2010-01-01", "jamie.kid@example.com", "+32470111111", "", "", "", ""]]) + + response = self.club_post("member_import", {"file": upload}) + + self.assertEqual(response.context["valid_count"], 1) + self.assertEqual(response.context["skipped_count"], 0) + result = response.context["results"][0] + self.assertIsNotNone(result["member"]) + self.assertEqual(result["membership_kwargs"]["status"], ClubMembership.StatusChoices.ACTIVE) + self.assertEqual(result["membership_kwargs"]["fee_status"], ClubMembership.FeeStatus.UNPAID) + + def test_a_row_missing_a_required_name_is_skipped(self): + upload = make_import_workbook([["", "Noname", "", "", "", "", "", "", ""]]) + + response = self.club_post("member_import", {"file": upload}) + + self.assertEqual(response.context["valid_count"], 0) + result = response.context["results"][0] + self.assertIsNone(result["member"]) + self.assertTrue(result["errors"]) + + def test_an_invalid_email_is_skipped(self): + upload = make_import_workbook([["Bad", "Email", "", "not-an-email", "", "", "", "", ""]]) + + response = self.club_post("member_import", {"file": upload}) + + result = response.context["results"][0] + self.assertIsNone(result["member"]) + + def test_an_invalid_status_value_is_skipped(self): + upload = make_import_workbook([["Bad", "Status", "", "bad.status@example.com", "", "", "", "not-a-status", ""]]) + + response = self.club_post("member_import", {"file": upload}) + + result = response.context["results"][0] + self.assertIsNone(result["member"]) + self.assertTrue(any("status" in error.lower() for error in result["errors"])) + + def test_a_duplicate_email_within_the_file_is_flagged_on_the_second_row(self): + upload = make_import_workbook( + [ + ["First", "Dup", "", "dup@example.com", "", "", "", "", ""], + ["Second", "Dup", "", "dup@example.com", "", "", "", "", ""], + ] + ) + + response = self.club_post("member_import", {"file": upload}) + + results = response.context["results"] + self.assertIsNotNone(results[0]["member"]) + self.assertIsNone(results[1]["member"]) + self.assertIn("Duplicate email in this file.", results[1]["errors"]) + + def test_an_email_already_in_the_club_is_skipped(self): + upload = make_import_workbook([["Ada", "Admin", "", self.admin_user.email, "", "", "", "", ""]]) + + response = self.club_post("member_import", {"file": upload}) + + result = response.context["results"][0] + self.assertIsNone(result["member"]) + self.assertIn("Already a member of this club.", result["errors"]) + + def test_confirm_creates_only_the_valid_rows_with_defaults(self): + upload = make_import_workbook( + [ + ["Jamie", "Kid", "2010-01-01", "jamie.kid@example.com", "", "", "LIC-1", "", ""], + ["", "Noname", "", "", "", "", "", "", ""], + ] + ) + self.club_post("member_import", {"file": upload}) + + response = self.club_post("member_import_confirm", {}) + + self.assertRedirects(response, reverse("management:member_list")) + member = Member.objects.get(email="jamie.kid@example.com") + membership = ClubMembership.objects.get(club=self.club, member=member) + self.assertEqual(membership.status, ClubMembership.StatusChoices.ACTIVE) + self.assertEqual(membership.fee_status, ClubMembership.FeeStatus.UNPAID) + self.assertEqual(membership.license, "LIC-1") + self.assertFalse(Member.objects.filter(last_name="Noname").exists()) + + def test_confirm_without_a_prior_upload_creates_nothing(self): + response = self.club_post("member_import_confirm", {}) + + self.assertRedirects(response, reverse("management:member_import")) + self.assertEqual(Member.objects.filter(last_name="Kid").count(), 0) + + def test_confirm_is_admin_only(self): + coach_user = self.make_non_admin_staff() + upload = make_import_workbook([["Jamie", "Kid", "", "jamie.kid2@example.com", "", "", "", "", ""]]) + self.club_post("member_import", {"file": upload}) + + self.client.force_login(coach_user) + response = self.club_post("member_import_confirm", {}) + + self.assertEqual(response.status_code, 403) diff --git a/management/urls.py b/management/urls.py new file mode 100644 index 0000000..b567e9f --- /dev/null +++ b/management/urls.py @@ -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//mark-fully-paid/", views.MembershipMarkFullyPaidView.as_view(), name="membership_mark_fully_paid"), + path("memberships//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//", views.MemberDetailView.as_view(), name="member_detail"), + path("members//edit/", views.MemberUpdateView.as_view(), name="member_update"), + path("members//delete/", views.MemberDeleteView.as_view(), name="member_delete"), + path("members//attach-family/", views.MemberAttachToFamilyView.as_view(), name="member_attach_family"), + path("members//grant-login/", views.MemberGrantLoginView.as_view(), name="member_grant_login"), + path("members//detach-family//", views.MemberDetachFromFamilyView.as_view(), name="member_detach_family"), + path("families/new/", views.FamilyCreateView.as_view(), name="family_create"), + path("families//", views.FamilyDetailView.as_view(), name="family_detail"), + path("families//add-child/", views.FamilyAddChildView.as_view(), name="family_add_child"), + path("families//add-parent/", views.FamilyAddParentView.as_view(), name="family_add_parent"), + path("families//members//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//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//", views.TeamDetailView.as_view(), name="team_detail"), + path("teams//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//submissions/", views.SubmissionListView.as_view(), name="submission_list"), +] diff --git a/management/views.py b/management/views.py new file mode 100644 index 0000000..8422dea --- /dev/null +++ b/management/views.py @@ -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"]) diff --git a/members/services/family.py b/members/services/family.py new file mode 100644 index 0000000..8dc4916 --- /dev/null +++ b/members/services/family.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml index f45a80e..eebf55e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "django-storages[s3]>=1.14.6", "django-waffle>=5.0.0", "gunicorn>=26.0.0", + "openpyxl>=3.1.5", "pillow>=12.3.0", "psycopg[binary]>=3.3.4", "python-dateutil>=2.9.0.post0", @@ -56,7 +57,7 @@ ignore = [ [tool.ruff.lint.isort] known-first-party = [ - "billing", "authentication", "club", "members", "teams", "events", "formbuilder", "shop", "controlpanel", "news", "pages", "home", "search", "rosterchief"] + "billing", "authentication", "club", "members", "teams", "events", "formbuilder", "shop", "controlpanel", "management", "news", "pages", "home", "search", "rosterchief"] [tool.uv.sources] django-lucide = { git = "https://github.com/bsiebens/lucide" } diff --git a/rosterchief/settings.py b/rosterchief/settings.py index 8d4929f..90230db 100644 --- a/rosterchief/settings.py +++ b/rosterchief/settings.py @@ -73,6 +73,9 @@ INSTALLED_APPS = [ "waffle", "features.apps.FeaturesConfig", "controlpanel.apps.ControlpanelConfig", + # Club-facing UI for team managers, coaches and admins -- not controlpanel (platform + # staff managing every club) and not the future parent/player app. + "management.apps.ManagementConfig", ] # Feature flags (django-waffle). The Flag model is swappable, like AUTH_USER_MODEL: @@ -183,6 +186,9 @@ TEMPLATES = [ "django.contrib.messages.context_processors.messages", "club.context_processors.branding", "features.context_processors.maintenance", + "management.context_processors.is_admin", + "management.context_processors.management_link", + "management.context_processors.active_nav_section", ], }, }, diff --git a/rosterchief/urls.py b/rosterchief/urls.py index aa8fa5c..47d6748 100644 --- a/rosterchief/urls.py +++ b/rosterchief/urls.py @@ -24,6 +24,7 @@ urlpatterns = [ path("admin/", admin.site.urls), path("accounts/", include("allauth.urls")), path("controlpanel/", include("controlpanel.urls")), + path("manage/", include("management.urls")), # "/" resolves per tenant: a club subdomain lands on the club, the base domain # hands off to the control panel. This is why LOGIN_REDIRECT_URL can stay "/". path("", root, name="root"), diff --git a/static/css/app.css b/static/css/app.css index 73ae2ed..4191754 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -34,6 +34,8 @@ --font-weight-bold: 700; --tracking-wide: 0.025em; --tracking-wider: 0.05em; + --ease-out: cubic-bezier(0, 0, 0.2, 1); + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); --default-transition-duration: 150ms; --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); --default-font-family: var(--font-sans); @@ -3310,6 +3312,9 @@ .border-info { border-color: var(--color-info); } + .border-neutral { + border-color: var(--color-neutral); + } .border-success { border-color: var(--color-success); } @@ -3429,9 +3434,6 @@ .object-contain { object-fit: contain; } - .p-1 { - padding: var(--spacing); - } .p-2 { padding: calc(var(--spacing) * 2); } @@ -3666,6 +3668,9 @@ text-decoration-line: none; } } + .opacity-40 { + opacity: 40%; + } .opacity-50 { opacity: 50%; } @@ -3726,6 +3731,17 @@ } } } + .select-sm { + @layer daisyui.l1.l2 { + --sl-size-mul: 8; + --font-size-min: 0.75rem; + --option-px: 2.5; + .floating-label:has(&) { + --top-mul: 4; + --font-size: 0.75rem; + } + } + } .btn-neutral { @layer daisyui.l1.l2 { --btn-color: var(--color-neutral); @@ -3748,6 +3764,13 @@ --btn-soft-bg: initial; } } + .btn-info { + @layer daisyui.l1.l2 { + --btn-color: var(--color-info); + --btn-fg: var(--color-info-content); + --btn-soft-bg: initial; + } + } .btn-primary { @layer daisyui.l1.l2 { --btn-color: var(--color-primary); diff --git a/templates/_base.html b/templates/_base.html index 9069285..0d416a9 100644 --- a/templates/_base.html +++ b/templates/_base.html @@ -64,6 +64,11 @@ {% if user.is_authenticated %} + {% if has_management_access %} + {# Only ever true on a club subdomain -- see management.context_processors.management_link. #} + {% lucide "layout-dashboard" %} + {% endif %} + {% if user.is_superuser %} {% lucide "shield-cog" %} {% endif %} diff --git a/uv.lock b/uv.lock index bbb70be..09e0762 100644 --- a/uv.lock +++ b/uv.lock @@ -370,6 +370,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/d2/6f0d664bd35a3fdd0403655c7c32ec290704923f11541ef356b180cd8fbf/django_waffle-5.0.0-py3-none-any.whl", hash = "sha256:3312851d9d926b76b9e90712355781700a383b82b5bf2b61e1f1be97532c0f3d", size = 48137, upload-time = "2025-06-12T07:38:53.698Z" }, ] +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + [[package]] name = "fido2" version = "2.2.1" @@ -435,6 +444,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -621,6 +642,7 @@ dependencies = [ { name = "django-storages", extra = ["s3"] }, { name = "django-waffle" }, { name = "gunicorn" }, + { name = "openpyxl" }, { name = "pillow" }, { name = "psycopg", extra = ["binary"] }, { name = "python-dateutil" }, @@ -647,6 +669,7 @@ requires-dist = [ { name = "django-storages", extras = ["s3"], specifier = ">=1.14.6" }, { name = "django-waffle", specifier = ">=5.0.0" }, { name = "gunicorn", specifier = ">=26.0.0" }, + { name = "openpyxl", specifier = ">=3.1.5" }, { name = "pillow", specifier = ">=12.3.0" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.3.4" }, { name = "python-dateutil", specifier = ">=2.9.0.post0" },