Compare commits
7 Commits
c42963c447
...
9bc5377cc5
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bc5377cc5 | |||
| 7c874aa87b | |||
| 5283262e6b | |||
| 65a2f741f6 | |||
| 975426a17f | |||
| 9a616c20e4 | |||
| 2d43b0b903 |
@@ -25,8 +25,8 @@ class TierPriceAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(Subscription)
|
@admin.register(Subscription)
|
||||||
class SubscriptionAdmin(admin.ModelAdmin):
|
class SubscriptionAdmin(admin.ModelAdmin):
|
||||||
list_display = ["club", "tier", "auto_archive"]
|
list_display = ["club", "tier", "auto_renew", "auto_archive"]
|
||||||
list_filter = ["tier", "auto_archive"]
|
list_filter = ["tier", "auto_renew", "auto_archive"]
|
||||||
search_fields = ["club__name"]
|
search_fields = ["club__name"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
57
billing/management/commands/renew_subscriptions.py
Normal file
57
billing/management/commands/renew_subscriptions.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""Issue the next billing period for clubs whose current one is running out.
|
||||||
|
|
||||||
|
Unlike archive_overdue_clubs, this ACTS by default and only previews with --dry-run. The
|
||||||
|
asymmetry is deliberate and runs the other way: archiving switches off a paying customer, so
|
||||||
|
not acting is the safe failure. Here, not acting means a club keeps using the platform for
|
||||||
|
free — and because nothing is owed, no dashboard number goes red and the archive job never
|
||||||
|
fires either. A missed renewal is silent, and silence is the expensive failure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.core.management.base import CommandError
|
||||||
|
|
||||||
|
from billing.models import RENEWAL_LEAD_DAYS
|
||||||
|
from billing.services import BillingError
|
||||||
|
from billing.services.dues import renew, subscriptions_due_for_renewal
|
||||||
|
from features.commands import MaintenanceAwareCommand
|
||||||
|
|
||||||
|
|
||||||
|
class Command(MaintenanceAwareCommand):
|
||||||
|
help = "Open the next billing period for clubs whose current period ends soon."
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Report what would be issued, and issue nothing.")
|
||||||
|
parser.add_argument("--lead-days", type=int, default=RENEWAL_LEAD_DAYS, help=f"Issue this many days before the period ends (default {RENEWAL_LEAD_DAYS}).")
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
due_for_renewal = subscriptions_due_for_renewal(lead_days=options["lead_days"])
|
||||||
|
|
||||||
|
if not due_for_renewal:
|
||||||
|
self.stdout.write(self.style.SUCCESS("Nothing to renew."))
|
||||||
|
return
|
||||||
|
|
||||||
|
failures = []
|
||||||
|
for subscription in due_for_renewal:
|
||||||
|
club = subscription.club
|
||||||
|
|
||||||
|
if options["dry_run"]:
|
||||||
|
self.stdout.write(f"would renew {club} — {subscription.tier}, current period ends {subscription.latest_period_end or 'never opened'}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
due = renew(subscription)
|
||||||
|
except BillingError as error:
|
||||||
|
# One unpriced tier must not stop every other club from being billed.
|
||||||
|
failures.append(f"{club}: {error}")
|
||||||
|
self.stdout.write(self.style.ERROR(f"{club} — {error}"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"{club} — {due.period_start} to {due.period_end}, {due.amount} ({due.invoice.number})"))
|
||||||
|
|
||||||
|
if options["dry_run"]:
|
||||||
|
self.stdout.write(self.style.WARNING(f"\nDry run: {len(due_for_renewal)} club(s) would be renewed."))
|
||||||
|
return
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
# Non-zero, so cron mails you: a club that could not be billed is revenue quietly
|
||||||
|
# not being collected.
|
||||||
|
raise CommandError(f"{len(failures)} club(s) could not be renewed:\n " + "\n ".join(failures))
|
||||||
18
billing/migrations/0002_subscription_auto_renew.py
Normal file
18
billing/migrations/0002_subscription_auto_renew.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-07-14 22:35
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('billing', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='subscription',
|
||||||
|
name='auto_renew',
|
||||||
|
field=models.BooleanField(default=True, help_text='Issue the next period automatically before this one ends. Off means you invoice this club by hand.', verbose_name='auto renew'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -22,6 +22,11 @@ ZERO = Decimal("0.00")
|
|||||||
#: A club stays live for six weeks past the end of an unpaid period before it is archived.
|
#: A club stays live for six weeks past the end of an unpaid period before it is archived.
|
||||||
GRACE_DAYS = 45
|
GRACE_DAYS = 45
|
||||||
|
|
||||||
|
#: The next period is issued this long before the current one ends, so the invoice reaches the
|
||||||
|
#: club — and can be paid — before the old period lapses. Grace then only matters for genuine
|
||||||
|
#: non-payers, rather than for everyone who takes a fortnight to pay a bank transfer.
|
||||||
|
RENEWAL_LEAD_DAYS = 30
|
||||||
|
|
||||||
|
|
||||||
def add_one_year(day: date) -> date:
|
def add_one_year(day: date) -> date:
|
||||||
"""The day one year on. 29 February has no counterpart in a common year, so it falls
|
"""The day one year on. 29 February has no counterpart in a common year, so it falls
|
||||||
@@ -93,6 +98,7 @@ class Subscription(UUIDModel):
|
|||||||
|
|
||||||
club = models.OneToOneField("club.Club", on_delete=models.CASCADE, related_name="subscription", verbose_name=_("club"))
|
club = models.OneToOneField("club.Club", on_delete=models.CASCADE, related_name="subscription", verbose_name=_("club"))
|
||||||
tier = models.ForeignKey(Tier, on_delete=models.PROTECT, related_name="subscriptions", verbose_name=_("tier"))
|
tier = models.ForeignKey(Tier, on_delete=models.PROTECT, related_name="subscriptions", verbose_name=_("tier"))
|
||||||
|
auto_renew = models.BooleanField(_("auto renew"), default=True, help_text=_("Issue the next period automatically before this one ends. Off means you invoice this club by hand."))
|
||||||
auto_archive = models.BooleanField(_("auto archive"), default=True, help_text=_("Archive this club when a period goes unpaid past its grace period."))
|
auto_archive = models.BooleanField(_("auto archive"), default=True, help_text=_("Archive this club when a period goes unpaid past its grace period."))
|
||||||
notes = models.TextField(_("notes"), blank=True)
|
notes = models.TextField(_("notes"), blank=True)
|
||||||
|
|
||||||
|
|||||||
@@ -6,17 +6,17 @@ from datetime import date, timedelta
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Sum
|
from django.db.models import DateField, OuterRef, Subquery, Sum
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from billing.models import ZERO, Due, DuePayment, Subscription, Tier
|
from billing.models import RENEWAL_LEAD_DAYS, ZERO, Due, DuePayment, Subscription, Tier
|
||||||
from billing.services import BillingError
|
from billing.services import BillingError
|
||||||
from billing.services.invoices import issue_invoice
|
from billing.services.invoices import issue_invoice
|
||||||
|
|
||||||
|
|
||||||
def subscribe(club, tier: Tier, *, start: date | None = None, auto_archive: bool = True) -> Subscription:
|
def subscribe(club, tier: Tier, *, start: date | None = None, auto_archive: bool = True, auto_renew: bool = True) -> Subscription:
|
||||||
"""Put a club on a tier and open its first period."""
|
"""Put a club on a tier and open its first period."""
|
||||||
subscription, _created = Subscription.objects.update_or_create(club=club, defaults={"tier": tier, "auto_archive": auto_archive})
|
subscription, _created = Subscription.objects.update_or_create(club=club, defaults={"tier": tier, "auto_archive": auto_archive, "auto_renew": auto_renew})
|
||||||
open_period(club, start=start)
|
open_period(club, start=start)
|
||||||
|
|
||||||
return subscription
|
return subscription
|
||||||
@@ -151,3 +151,31 @@ def reactivate(club, *, start: date | None = None) -> Due:
|
|||||||
club.restore()
|
club.restore()
|
||||||
|
|
||||||
return open_period(club, start=start)
|
return open_period(club, start=start)
|
||||||
|
|
||||||
|
|
||||||
|
def subscriptions_due_for_renewal(today: date | None = None, lead_days: int = RENEWAL_LEAD_DAYS):
|
||||||
|
"""Clubs whose next period should be issued now.
|
||||||
|
|
||||||
|
Idempotent by construction: a club that has just been renewed has a latest period ending a
|
||||||
|
year out, which is past the horizon, so it cannot be picked up twice. Running the job twice
|
||||||
|
a day is harmless.
|
||||||
|
|
||||||
|
A subscription with no period at all (its only due was cancelled) counts too — a club on a
|
||||||
|
plan and billed for nothing is the leak this whole job exists to close.
|
||||||
|
"""
|
||||||
|
today = today or timezone.localdate()
|
||||||
|
horizon = today + timedelta(days=lead_days)
|
||||||
|
|
||||||
|
latest_period_end = Subquery(
|
||||||
|
Due.objects.filter(club=OuterRef("club")).exclude(status=Due.Status.CANCELLED).order_by("-period_end").values("period_end")[:1],
|
||||||
|
output_field=DateField(),
|
||||||
|
)
|
||||||
|
|
||||||
|
subscriptions = Subscription.objects.filter(auto_renew=True, club__archived_at__isnull=True).select_related("club", "tier").annotate(latest_period_end=latest_period_end).order_by("club__name")
|
||||||
|
|
||||||
|
return [subscription for subscription in subscriptions if subscription.latest_period_end is None or subscription.latest_period_end <= horizon]
|
||||||
|
|
||||||
|
|
||||||
|
def renew(subscription: Subscription) -> Due:
|
||||||
|
"""Open the club's next period, continuing from the last one."""
|
||||||
|
return open_period(subscription.club)
|
||||||
|
|||||||
155
billing/tests.py
155
billing/tests.py
@@ -5,6 +5,7 @@ from io import StringIO
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
|
from django.core.management.base import CommandError
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ from club.models import Club
|
|||||||
|
|
||||||
from .models import GRACE_DAYS, Due, Invoice, Subscription, Tier, TierPrice, add_one_year
|
from .models import GRACE_DAYS, Due, Invoice, Subscription, Tier, TierPrice, add_one_year
|
||||||
from .services import BillingError
|
from .services import BillingError
|
||||||
from .services.dues import archivable_clubs, dues_in_grace, dues_overdue, next_period_start, open_period, reactivate, record_payment, remove_payment, subscribe, waive
|
from .services.dues import archivable_clubs, dues_in_grace, dues_overdue, next_period_start, open_period, reactivate, record_payment, remove_payment, renew, subscribe, subscriptions_due_for_renewal, waive
|
||||||
from .services.invoices import invoice_pdf, issue_invoice, render_pdf
|
from .services.invoices import invoice_pdf, issue_invoice, render_pdf
|
||||||
|
|
||||||
|
|
||||||
@@ -335,3 +336,155 @@ class ModelStringTests(BillingTestBase):
|
|||||||
self.assertIn("10.00", str(payment))
|
self.assertIn("10.00", str(payment))
|
||||||
self.assertIn("INV-", str(due.invoice))
|
self.assertIn("INV-", str(due.invoice))
|
||||||
self.assertIn("Standard", str(subscribe(Club.objects.create(name="PSV"), self.tier)))
|
self.assertIn("Standard", str(subscribe(Club.objects.create(name="PSV"), self.tier)))
|
||||||
|
|
||||||
|
|
||||||
|
class RenewalTests(BillingTestBase):
|
||||||
|
"""The leak this closes: a club whose period lapses with its last due PAID owes nothing,
|
||||||
|
so dues_overdue() is empty, so archive_overdue_clubs never fires — and the club keeps
|
||||||
|
using the platform for free while every number on the dashboard stays green."""
|
||||||
|
|
||||||
|
def ending_in(self, days, **kwargs):
|
||||||
|
"""A club whose current period ends `days` from now."""
|
||||||
|
club = Club.objects.create(name=f"Club {days}")
|
||||||
|
subscribe(club, self.tier, start=self.today - datetime.timedelta(days=365 - days), **kwargs)
|
||||||
|
return club
|
||||||
|
|
||||||
|
def test_a_club_nearing_its_end_date_is_picked_up(self):
|
||||||
|
club = self.ending_in(20)
|
||||||
|
|
||||||
|
due = [s.club for s in subscriptions_due_for_renewal()]
|
||||||
|
|
||||||
|
self.assertIn(club, due)
|
||||||
|
|
||||||
|
def test_a_club_with_a_period_beyond_the_horizon_is_left_alone(self):
|
||||||
|
club = self.ending_in(200)
|
||||||
|
|
||||||
|
self.assertNotIn(club, [s.club for s in subscriptions_due_for_renewal()])
|
||||||
|
|
||||||
|
def test_renewing_continues_from_the_last_period(self):
|
||||||
|
club = self.ending_in(20)
|
||||||
|
first = club.dues.first()
|
||||||
|
|
||||||
|
renew(club.subscription)
|
||||||
|
|
||||||
|
latest = club.dues.order_by("-period_start").first()
|
||||||
|
self.assertEqual(latest.period_start, first.period_end + datetime.timedelta(days=1))
|
||||||
|
self.assertEqual(club.dues.count(), 2)
|
||||||
|
|
||||||
|
def test_running_twice_does_not_bill_twice(self):
|
||||||
|
# Idempotent by construction: once renewed, the club's latest period ends a year out,
|
||||||
|
# which is past the horizon.
|
||||||
|
club = self.ending_in(20)
|
||||||
|
|
||||||
|
call_command("renew_subscriptions", stdout=StringIO())
|
||||||
|
call_command("renew_subscriptions", stdout=StringIO())
|
||||||
|
|
||||||
|
self.assertEqual(club.dues.count(), 2)
|
||||||
|
|
||||||
|
def test_a_club_that_opted_out_is_not_renewed(self):
|
||||||
|
club = self.ending_in(20, auto_renew=False)
|
||||||
|
|
||||||
|
self.assertNotIn(club, [s.club for s in subscriptions_due_for_renewal()])
|
||||||
|
|
||||||
|
def test_an_archived_club_is_not_renewed(self):
|
||||||
|
# Reactivation is the way back, and it opens a period of its own.
|
||||||
|
club = self.ending_in(20)
|
||||||
|
club.archive()
|
||||||
|
|
||||||
|
self.assertNotIn(club, [s.club for s in subscriptions_due_for_renewal()])
|
||||||
|
|
||||||
|
def test_the_new_period_is_billed_at_the_price_in_force_then(self):
|
||||||
|
club = self.ending_in(20)
|
||||||
|
TierPrice.objects.create(tier=self.tier, active_from=self.today, amount=Decimal("900.00"))
|
||||||
|
|
||||||
|
due = renew(club.subscription)
|
||||||
|
|
||||||
|
self.assertEqual(due.amount, Decimal("900.00")) # the new rate
|
||||||
|
self.assertEqual(club.dues.order_by("period_start").first().amount, Decimal("500.00")) # the old one, untouched
|
||||||
|
|
||||||
|
def test_the_new_period_is_invoiced(self):
|
||||||
|
club = self.ending_in(20)
|
||||||
|
|
||||||
|
due = renew(club.subscription)
|
||||||
|
|
||||||
|
self.assertTrue(due.invoice.number.startswith("INV-"))
|
||||||
|
|
||||||
|
def test_a_dry_run_issues_nothing(self):
|
||||||
|
club = self.ending_in(20)
|
||||||
|
out = StringIO()
|
||||||
|
|
||||||
|
call_command("renew_subscriptions", "--dry-run", stdout=out)
|
||||||
|
|
||||||
|
self.assertEqual(club.dues.count(), 1)
|
||||||
|
self.assertIn("would renew", out.getvalue())
|
||||||
|
|
||||||
|
def test_the_command_issues_by_default(self):
|
||||||
|
# The opposite asymmetry to archiving: NOT acting is the expensive failure here,
|
||||||
|
# because a club that is never billed is never chased either.
|
||||||
|
club = self.ending_in(20)
|
||||||
|
|
||||||
|
call_command("renew_subscriptions", stdout=StringIO())
|
||||||
|
|
||||||
|
self.assertEqual(club.dues.count(), 2)
|
||||||
|
|
||||||
|
def test_an_unpriced_tier_fails_loudly_without_stopping_the_others(self):
|
||||||
|
priced = self.ending_in(20)
|
||||||
|
broken = Club.objects.create(name="Unpriced FC")
|
||||||
|
subscribe(broken, self.tier, start=self.today - datetime.timedelta(days=350))
|
||||||
|
# Its next period starts beyond the last price... by removing every price, it cannot bill.
|
||||||
|
TierPrice.objects.all().delete()
|
||||||
|
cheap = Tier.objects.create(name="Cheap")
|
||||||
|
TierPrice.objects.create(tier=cheap, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("100.00"))
|
||||||
|
priced.subscription.tier = cheap
|
||||||
|
priced.subscription.save()
|
||||||
|
|
||||||
|
with self.assertRaises(CommandError):
|
||||||
|
call_command("renew_subscriptions", stdout=StringIO(), stderr=StringIO())
|
||||||
|
|
||||||
|
# ...and the club that COULD be billed still was.
|
||||||
|
self.assertEqual(priced.dues.count(), 2)
|
||||||
|
|
||||||
|
def test_a_subscription_with_no_period_at_all_is_renewed(self):
|
||||||
|
club = Club.objects.create(name="Orphan FC")
|
||||||
|
Subscription.objects.create(club=club, tier=self.tier)
|
||||||
|
|
||||||
|
self.assertIn(club, [s.club for s in subscriptions_due_for_renewal()])
|
||||||
|
|
||||||
|
def test_it_says_so_when_there_is_nothing_to_renew(self):
|
||||||
|
self.assertIn("Nothing to renew", self.run_renewal())
|
||||||
|
|
||||||
|
def run_renewal(self, *args):
|
||||||
|
out = StringIO()
|
||||||
|
call_command("renew_subscriptions", *args, stdout=out)
|
||||||
|
return out.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
class RenewedButUnpaidTests(BillingTestBase):
|
||||||
|
"""A club auto-renewed that never pays the new fee flows through the ordinary
|
||||||
|
unpaid -> grace -> overdue -> archive path. Renewal creates a normal Due; it does not
|
||||||
|
create a special case, and the safety net that the never-billed club slipped past now
|
||||||
|
fires, because there IS an unpaid due."""
|
||||||
|
|
||||||
|
def lapsed_club(self):
|
||||||
|
"""A club on its first, PAID period — far enough back that a renewal from its end is
|
||||||
|
itself already past grace, so only the renewal's payment state decides the outcome."""
|
||||||
|
club = Club.objects.create(name="Renewed FC")
|
||||||
|
subscribe(club, self.tier, start=self.today - datetime.timedelta(days=800))
|
||||||
|
first = club.dues.first()
|
||||||
|
record_payment(first, first.amount) # the FIRST period is settled; only the renewal is in question
|
||||||
|
return club
|
||||||
|
|
||||||
|
def test_an_unpaid_renewal_becomes_overdue_and_archivable(self):
|
||||||
|
club = self.lapsed_club()
|
||||||
|
renewed = renew(club.subscription) # continues from the first period's end, unpaid
|
||||||
|
|
||||||
|
self.assertTrue(renewed.is_overdue(self.today))
|
||||||
|
self.assertIn(renewed, dues_overdue(self.today))
|
||||||
|
self.assertIn(club, [d.club for d in archivable_clubs(self.today)])
|
||||||
|
|
||||||
|
def test_a_paid_renewal_is_not_chased(self):
|
||||||
|
club = self.lapsed_club()
|
||||||
|
renewed = renew(club.subscription)
|
||||||
|
record_payment(renewed, renewed.amount)
|
||||||
|
|
||||||
|
self.assertNotIn(club, [d.club for d in archivable_clubs(self.today)])
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ class SubscriptionForm(forms.ModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Subscription
|
model = Subscription
|
||||||
fields = ["tier", "auto_archive", "notes"]
|
fields = ["tier", "auto_renew", "auto_archive", "notes"]
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from waffle import get_waffle_flag_model
|
|||||||
|
|
||||||
from authentication.middleware import ELEVATED_ROLES
|
from authentication.middleware import ELEVATED_ROLES
|
||||||
from billing.models import Due, DuePayment, Subscription
|
from billing.models import Due, DuePayment, Subscription
|
||||||
from billing.services.dues import dues_in_grace, dues_overdue
|
from billing.services.dues import dues_in_grace, dues_overdue, subscriptions_due_for_renewal
|
||||||
from club.models import Club, ClubMembership, ClubRole, Season
|
from club.models import Club, ClubMembership, ClubRole, Season
|
||||||
from events.models import Attendance, Event
|
from events.models import Attendance, Event
|
||||||
from members.models import Member
|
from members.models import Member
|
||||||
@@ -84,6 +84,9 @@ def clubs_with_health(queryset=None, today=None, now=None):
|
|||||||
dues_owed=_subquery(Due.objects.filter(status__in=Due.OWING), Sum(F("amount") - F("amount_paid")), DecimalField(max_digits=10, decimal_places=2)),
|
dues_owed=_subquery(Due.objects.filter(status__in=Due.OWING), Sum(F("amount") - F("amount_paid")), DecimalField(max_digits=10, decimal_places=2)),
|
||||||
dues_grace_until=Subquery(Due.objects.filter(club=OuterRef("pk"), status__in=Due.OWING).order_by("grace_until").values("grace_until")[:1]),
|
dues_grace_until=Subquery(Due.objects.filter(club=OuterRef("pk"), status__in=Due.OWING).order_by("grace_until").values("grace_until")[:1]),
|
||||||
dues_period_end=Subquery(Due.objects.filter(club=OuterRef("pk"), status__in=Due.OWING).order_by("period_end").values("period_end")[:1]),
|
dues_period_end=Subquery(Due.objects.filter(club=OuterRef("pk"), status__in=Due.OWING).order_by("period_end").values("period_end")[:1]),
|
||||||
|
# How far a fully-paid club is covered: the furthest-out PAID period end — the day
|
||||||
|
# grace would start if nothing is renewed. Null when the club owes, or was never billed.
|
||||||
|
paid_until=Subquery(Due.objects.filter(club=OuterRef("pk"), status=Due.Status.PAID).order_by("-period_end").values("period_end")[:1]),
|
||||||
)
|
)
|
||||||
.annotate(teams_without_coach=F("team_count") - F("teams_managed"))
|
.annotate(teams_without_coach=F("team_count") - F("teams_managed"))
|
||||||
.order_by("name")
|
.order_by("name")
|
||||||
@@ -141,7 +144,7 @@ def onboarding_funnel():
|
|||||||
return [
|
return [
|
||||||
{"label": "Clubs", "count": total, "icon": "building-2"},
|
{"label": "Clubs", "count": total, "icon": "building-2"},
|
||||||
{"label": "With members", "count": sum(1 for club in clubs if club.member_count), "icon": "users"},
|
{"label": "With members", "count": sum(1 for club in clubs if club.member_count), "icon": "users"},
|
||||||
{"label": "With a team", "count": sum(1 for club in clubs if club.team_count), "icon": "shield"},
|
{"label": "With a team", "count": sum(1 for club in clubs if club.team_count), "icon": "trophy"},
|
||||||
{"label": "With events", "count": sum(1 for club in clubs if club.event_count), "icon": "calendar-days"},
|
{"label": "With events", "count": sum(1 for club in clubs if club.event_count), "icon": "calendar-days"},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -172,6 +175,10 @@ def platform_attention():
|
|||||||
"dues_in_grace": dues_in_grace().count(),
|
"dues_in_grace": dues_in_grace().count(),
|
||||||
"dues_overdue": dues_overdue().count(),
|
"dues_overdue": dues_overdue().count(),
|
||||||
"clubs_unbilled": Club.objects.active().filter(subscription__isnull=True).count(),
|
"clubs_unbilled": Club.objects.active().filter(subscription__isnull=True).count(),
|
||||||
|
# Normally ~0: the renewal job keeps it there. A number that sits here means cron is
|
||||||
|
# dead, and a club is about to use the platform for free — silently, because nothing is
|
||||||
|
# owed, so no other number on this page would go red.
|
||||||
|
"renewals_pending": len(subscriptions_due_for_renewal()),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,65 +13,104 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Club</th>
|
<th>Club</th>
|
||||||
|
<th></th>
|
||||||
<th class="text-right">Members</th>
|
<th class="text-right">Members</th>
|
||||||
<th class="text-right">Unpaid</th>
|
|
||||||
<th class="text-right">Owed</th>
|
|
||||||
<th>Plan</th>
|
|
||||||
<th class="text-right">Dues</th>
|
|
||||||
<th class="text-right">Teams</th>
|
|
||||||
<th class="text-right">Upcoming</th>
|
|
||||||
<th class="text-right">Admins</th>
|
<th class="text-right">Admins</th>
|
||||||
|
<th class="text-right">Teams</th>
|
||||||
|
<th class="text-right">Events</th>
|
||||||
|
<th class="text-right">Plan</th>
|
||||||
|
<th class="text-right">Dues</th>
|
||||||
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for club in clubs %}
|
{% for club in clubs %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a class="link link-hover font-medium" href="{% url 'controlpanel:club_detail' club.pk %}">{{ club.name }}</a>
|
<div class="flex flex-row items-center gap-4">
|
||||||
<div class="mt-1 flex flex-wrap items-center gap-1">
|
<div>
|
||||||
<span class="text-xs opacity-60">{{ club.slug }}</span>
|
{% if club.logo %}
|
||||||
|
<img class="h-12 w-12 object-contain" src="{{ club.logo.url }}" alt="{{ club.name }}">
|
||||||
|
{% else %}
|
||||||
|
<div class="avatar avatar-placeholder">
|
||||||
|
<div class="w-12 rounded-full bg-neutral text-neutral-content">
|
||||||
|
<span>{{ club.initials }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<a class="link link-hover font-semibold tracking-wide" href="{% url "controlpanel:club_detail" club.pk %}">{{ club.name }}</a>
|
||||||
|
<div class="text-xs opacity-60">{{ club.slug }}.rosterchief.app</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<div class="flex flex-row gap-2">
|
||||||
{% if club.is_archived %}
|
{% if club.is_archived %}
|
||||||
{% comment %}
|
<span class="badge badge-warning">{% lucide "archive" size=14 %} archived</span>
|
||||||
An archived club's subdomain does not resolve, so "dormant" and
|
|
||||||
"no season" would be noise: of course nothing is scheduled.
|
|
||||||
{% endcomment %}
|
|
||||||
<span class="badge badge-warning badge-xs gap-1">{% lucide "archive" size=10 %} Archived</span>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if not club.has_season %}<span class="badge badge-warning badge-xs gap-1">{% lucide "calendar-x" size=10 %} No season</span>{% endif %}
|
{% if not club.has_season %}
|
||||||
{% if not club.upcoming_events %}<span class="badge badge-ghost badge-xs gap-1">{% lucide "moon-star" size=10 %} Dormant</span>{% endif %}
|
<span class="badge badge-warning">{% lucide "calendar-x" size=14 %} no seasons</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not club.upcoming_events %}
|
||||||
|
<span class="badge badge-ghost badge-outline">{% lucide "moon-star" size=14 %}dormant</span>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td class="text-right tabular-nums">{{ club.active_members }}</td>
|
<td class="text-right tabular-nums">{{ club.active_members }}</td>
|
||||||
<td class="text-right tabular-nums {% if club.unpaid_members %}text-warning{% endif %}">{{ club.unpaid_members }}</td>
|
|
||||||
<td class="text-right tabular-nums {% if club.outstanding %}font-semibold text-error{% endif %}">€{{ club.outstanding|floatformat:2 }}</td>
|
|
||||||
<td>
|
|
||||||
{% if club.tier_name %}
|
|
||||||
<span class="text-sm">{{ club.tier_name }}</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge badge-warning badge-xs">Not billed</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="text-right tabular-nums">
|
<td class="text-right tabular-nums">
|
||||||
{% if not club.dues_owed %}
|
<div class="flex flex-row gap-2 items-center justify-end">
|
||||||
{% if club.tier_name %}<span class="badge badge-success badge-xs">Paid</span>{% endif %}
|
{% if not club.admin_count %}
|
||||||
{% else %}
|
<span class="text-error">{% lucide "triangle-alert" size=16 %}</span>
|
||||||
<span class="font-semibold">€{{ club.dues_owed|floatformat:2 }}</span>
|
|
||||||
{% if club.dues_grace_until < today %}
|
|
||||||
<span class="badge badge-error badge-xs">Overdue</span>
|
|
||||||
{% elif club.dues_period_end < today %}
|
|
||||||
<span class="badge badge-warning badge-xs">Grace</span>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
<span class="{% if not club.admin_count %}font-bold text-error{% endif %}">{{ club.admin_count }}</span>
|
||||||
</td>
|
</div>
|
||||||
<td class="text-right tabular-nums">
|
|
||||||
{{ club.team_count }}
|
|
||||||
{% if club.teams_without_coach %}
|
|
||||||
<span class="badge badge-error badge-xs ml-1" title="Teams with nobody able to pick the squad">{{ club.teams_without_coach }} no coach</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
</td>
|
||||||
|
<td class="text-right tabular-nums">{{ club.team_count }}</td>
|
||||||
<td class="text-right tabular-nums">{{ club.upcoming_events }}</td>
|
<td class="text-right tabular-nums">{{ club.upcoming_events }}</td>
|
||||||
<td class="text-right tabular-nums {% if not club.admin_count %}text-error{% endif %}">{{ club.admin_count }}</td>
|
|
||||||
|
<td class="text-right">
|
||||||
|
{% if club.tier_name %}
|
||||||
|
<span class="badge badge-accent">{{ club.tier_name|lower }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-ghost badge-outline">n/a</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="text-right">
|
||||||
|
<div class="flex flex-row gap-2 items-center justify-end">
|
||||||
|
{% if not club.dues_owed %}
|
||||||
|
{% if club.tier_name %}
|
||||||
|
{% comment %} paid_until is the current period's end — the day grace would start if nothing renews. On its own row under the badge. {% endcomment %}
|
||||||
|
<div class="flex flex-col items-end gap-1">
|
||||||
|
<span class="badge badge-success">paid</span>
|
||||||
|
{% if club.paid_until %}
|
||||||
|
<span class="whitespace-nowrap text-xs opacity-60">until {{ club.paid_until|date:"j M Y" }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-ghost badge-outline">n/a</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="font-semibold">€{{ club.dues_owed|floatformat:2 }}</span>
|
||||||
|
{% if club.dues_grace_until < today %}
|
||||||
|
<span class="badge badge-error">overdue</span>
|
||||||
|
{% elif club.dues_period_end < today %}
|
||||||
|
<span class="badge badge-warning">grace</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<a class="btn btn-sm gap-2" href="{% url "controlpanel:club_detail" club.pk %}">{% lucide "pencil" size=14 %} Edit</a>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
37
controlpanel/templates/controlpanel/_nav_items.html
Normal file
37
controlpanel/templates/controlpanel/_nav_items.html
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{% load lucide %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
The panel's navigation, in one place: the sidebar renders it on a wide screen and the
|
||||||
|
collapsed menu renders it on a narrow one. Two copies of a link list is how a new section
|
||||||
|
ends up reachable on a desktop and invisible on a phone.
|
||||||
|
|
||||||
|
`menu-active` is daisyUI 5's active state; hover and focus come with `.menu` itself.
|
||||||
|
{% endcomment %}
|
||||||
|
<li>
|
||||||
|
<a class="{% if nav == 'dashboard' %}menu-active{% endif %}" href="{% url 'controlpanel:dashboard' %}">
|
||||||
|
{% lucide "layout-dashboard" size=16 %} Dashboard
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="{% if nav == 'clubs' %}menu-active{% endif %}" href="{% url 'controlpanel:club_list' %}">
|
||||||
|
{% lucide "building-2" size=16 %} Clubs
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="{% if nav == 'billing' %}menu-active{% endif %}" href="{% url 'controlpanel:billing' %}">
|
||||||
|
{% lucide "receipt-euro" size=16 %} Billing
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="{% if nav == 'features' %}menu-active{% endif %}" href="{% url 'controlpanel:features' %}">
|
||||||
|
{% lucide "toggle-right" size=16 %} Features
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% if user.is_superuser %}
|
||||||
|
{# Superusers only, exactly as the view is gated: a link staff cannot follow is a lie. #}
|
||||||
|
<li>
|
||||||
|
<a class="{% if nav == 'admins' %}menu-active{% endif %}" href="{% url 'controlpanel:admins' %}">
|
||||||
|
{% lucide "user-cog" size=16 %} Admins
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
@@ -5,15 +5,27 @@
|
|||||||
{% block panel_title %}Control panel{% endblock panel_title %} · RosterChief
|
{% block panel_title %}Control panel{% endblock panel_title %} · RosterChief
|
||||||
{% endblock title %}
|
{% endblock title %}
|
||||||
|
|
||||||
|
{% block menu %}
|
||||||
|
{% comment %}
|
||||||
|
Outside <main>, so it never scrolls with the content. Its own overflow-y-auto is for
|
||||||
|
the day the menu itself grows taller than the screen.
|
||||||
|
{% endcomment %}
|
||||||
|
<aside class="hidden w-64 shrink-0 overflow-y-auto border-r border-base-300 bg-base-100 lg:block">
|
||||||
|
<ul class="menu w-full gap-1 p-3 mt-4">
|
||||||
|
{% include "controlpanel/_nav_items.html" %}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
{% endblock menu %}
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
{% if maintenance_on %}
|
{% if maintenance_on %}
|
||||||
<div class="alert alert-error mb-6">
|
<div class="alert alert-error mb-6">
|
||||||
{% lucide "wrench" size=20 %}
|
{% lucide "wrench" size=20 %}
|
||||||
<span>
|
<span>
|
||||||
<strong>The platform is closed for maintenance.</strong>
|
<strong>The platform is currently closed for maintenance.</strong>
|
||||||
Clubs see a maintenance page and the scheduled jobs are standing down.
|
Clubs see a maintenance page and the scheduled jobs are standing down.
|
||||||
</span>
|
</span>
|
||||||
<a class="btn btn-sm" href="{% url 'controlpanel:features' %}">Reopen</a>
|
<a class="btn btn-sm gap-2 btn-error btn-soft" href="{% url 'controlpanel:features' %}">{% lucide "unlock" size=16 %} Reopen platform</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
|
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||||
@@ -28,15 +40,11 @@
|
|||||||
{% block actions %}{% endblock actions %}
|
{% block actions %}{% endblock actions %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div role="tablist" class="tabs-boxed tabs mb-6 w-fit">
|
{# Below `lg` the sidebar is hidden, so the same links appear here rather than nowhere. #}
|
||||||
<a role="tab" href="{% url 'controlpanel:dashboard' %}" class="tab gap-2 {% if nav == 'dashboard' %}tab-active{% endif %}">{% lucide "layout-dashboard" size=16 %} Dashboard</a>
|
<ul class="menu menu-horizontal mb-6 w-full gap-1 overflow-x-auto rounded-box bg-base-100 lg:hidden">
|
||||||
<a role="tab" href="{% url 'controlpanel:club_list' %}" class="tab gap-2 {% if nav == 'clubs' %}tab-active{% endif %}">{% lucide "building-2" size=16 %} Clubs</a>
|
{% include "controlpanel/_nav_items.html" %}
|
||||||
<a role="tab" href="{% url 'controlpanel:billing' %}" class="tab gap-2 {% if nav == 'billing' %}tab-active{% endif %}">{% lucide "receipt-euro" size=16 %} Billing</a>
|
</ul>
|
||||||
<a role="tab" href="{% url 'controlpanel:features' %}" class="tab gap-2 {% if nav == 'features' %}tab-active{% endif %}">{% lucide "toggle-right" size=16 %} Features</a>
|
|
||||||
{% if user.is_superuser %}
|
|
||||||
<a role="tab" href="{% url 'controlpanel:admins' %}" class="tab gap-2 {% if nav == 'admins' %}tab-active{% endif %}">{% lucide "user-cog" size=16 %} Admins</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% block panel %}{% endblock panel %}
|
{% block panel %}{% endblock panel %}
|
||||||
{% endblock main %}
|
{% endblock main %}
|
||||||
|
|||||||
@@ -234,6 +234,11 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-sm opacity-70">
|
<p class="text-sm opacity-70">
|
||||||
On <strong>{{ subscription.tier.name }}</strong>.
|
On <strong>{{ subscription.tier.name }}</strong>.
|
||||||
|
{% if subscription.auto_renew %}
|
||||||
|
Renews automatically 30 days before the period ends.
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-warning badge-sm">Auto-renew off</span> — you must open each period by hand, or this club uses the platform for free.
|
||||||
|
{% endif %}
|
||||||
{% if subscription.auto_archive %}
|
{% if subscription.auto_archive %}
|
||||||
Archived automatically when a period goes unpaid past its grace period.
|
Archived automatically when a period goes unpaid past its grace period.
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -2,52 +2,70 @@
|
|||||||
{% load static lucide %}
|
{% load static lucide %}
|
||||||
|
|
||||||
{% block heading %}RosterChief Platform Dashboard{% endblock heading %}
|
{% block heading %}RosterChief Platform Dashboard{% endblock heading %}
|
||||||
{% block subheading %}Welcome back {{ user.member.first_name }}!{% endblock subheading %}
|
{% block subheading %}Welcome back {{ user.member.first_name }} · {% now "d b Y" %}{% endblock subheading %}
|
||||||
|
|
||||||
{% block actions %}
|
{% block actions %}
|
||||||
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:club_create' %}">{% lucide "plus" size=16 %} Create new club</a>
|
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:club_create' %}">{% lucide "plus" size=16 %} Create new club</a>
|
||||||
{% endblock actions %}
|
{% endblock actions %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
{% comment %}
|
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-6">
|
||||||
Needs attention first: these are the numbers that are supposed to be zero. A club with
|
<div class="card bg-base-100 shadow border-l-4 border-info">
|
||||||
no current season cannot take a signup or schedule a match — and it fails silently,
|
|
||||||
nothing errors — while an admin without a second factor is locked out of their own
|
|
||||||
club. Both are work queues, not statistics. The vanity totals sit further down.
|
|
||||||
{% endcomment %}
|
|
||||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<div class="card bg-base-100 shadow {% if attention.clubs_without_season %}border-l-4 border-warning{% endif %}">
|
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "calendar-x" size=16 %} No current season</div>
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "building-2" size=16 %} Clubs</div>
|
||||||
<div class="text-3xl font-bold tabular-nums">{{ attention.clubs_without_season }}</div>
|
<div class="text-4xl font-bold tabular-nums font-mono">{{ totals.clubs }}</div>
|
||||||
|
<div class="text-xs opacity-60">Managing {{ totals.members }} member{{ totals.members|pluralize }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow border-l-4 border-info">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "archive" size=16 %} Archived clubs</div>
|
||||||
|
<div class="text-4xl font-bold tabular-nums font-mono">{{ totals.archived_clubs }}</div>
|
||||||
|
<div class="text-xs opacity-60">Not accessible but data maintained</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow border-l-4 border-success {% if attention.clubs_without_season %}border-warning{% endif %}">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "calendar-x" size=16 %} No current season</div>
|
||||||
|
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.clubs_without_season }}</div>
|
||||||
<div class="text-xs opacity-60">Clubs that cannot take signups</div>
|
<div class="text-xs opacity-60">Clubs that cannot take signups</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card bg-base-100 shadow {% if attention.dormant_clubs %}border-l-4 border-warning{% endif %}">
|
|
||||||
|
<div class="card bg-base-100 shadow border-l-4 border-success {% if attention.dormant_clubs %}border-warning{% endif %}">
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "moon-star" size=16 %} Dormant</div>
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "moon-star" size=16 %} Dormant clubs</div>
|
||||||
<div class="text-3xl font-bold tabular-nums">{{ attention.dormant_clubs }}</div>
|
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.dormant_clubs }}</div>
|
||||||
<div class="text-xs opacity-60">Nothing scheduled in 30 days</div>
|
<div class="text-xs opacity-60">No events scheduled next 30 days</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card bg-base-100 shadow {% if attention.admins_pending_mfa %}border-l-4 border-error{% endif %}">
|
|
||||||
|
<div class="card bg-base-100 shadow border-l-4 border-success {% if attention.admins_pending_mfa %}border-warning{% endif %}">
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "shield-alert" size=16 %} MFA pending</div>
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "shield-alert" size=16 %} MFA pending</div>
|
||||||
<div class="text-3xl font-bold tabular-nums">{{ attention.admins_pending_mfa }}</div>
|
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.admins_pending_mfa }}</div>
|
||||||
<div class="text-xs opacity-60">Admins locked out until they enrol</div>
|
<div class="text-xs opacity-60">Admins without MFA configured</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card bg-base-100 shadow {% if attention.dues_owed %}border-l-4 border-error{% endif %}">
|
|
||||||
|
<div class="card bg-base-100 shadow border-l-4 border-success {% if attention.dues_owed %}border-warning{% endif %}">
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
{% comment %}
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "receipt-euro" size=16 %} Payment pending</div>
|
||||||
What the CLUBS owe US. Not to be confused with the club-shop money below,
|
<div class="text-4xl font-bold tabular-nums font-mono">€{{ attention.dues_owed|floatformat:2 }}</div>
|
||||||
which members owe their clubs and is never ours.
|
|
||||||
{% endcomment %}
|
|
||||||
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "receipt-euro" size=16 %} Dues owed</div>
|
|
||||||
<div class="text-3xl font-bold tabular-nums">€{{ attention.dues_owed|floatformat:2 }}</div>
|
|
||||||
<div class="text-xs opacity-60">
|
<div class="text-xs opacity-60">
|
||||||
{{ attention.dues_in_grace }} in grace ·
|
{{ attention.dues_in_grace }} in grace ·
|
||||||
<span class="{% if attention.dues_overdue %}font-semibold text-error{% endif %}">{{ attention.dues_overdue }} overdue</span>
|
<span class="{% if attention.dues_overdue %}font-semibold text-error{% endif %}">{{ attention.dues_overdue }} overdue</span>
|
||||||
|
{% comment %}
|
||||||
|
Renewals pending should sit at ~0: the cron job renews clubs 30 days out and
|
||||||
|
then they fall past the horizon. A number that lingers here means the job has
|
||||||
|
stopped and a club is about to use the platform for free — which no other
|
||||||
|
figure on this page reveals, because nothing has been billed yet.
|
||||||
|
{% endcomment %}
|
||||||
|
{% if attention.renewals_pending %}
|
||||||
|
· <span class="font-semibold text-warning">{{ attention.renewals_pending }} awaiting renewal</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,22 +81,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card bg-base-100 shadow">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title text-base">{% lucide "receipt-euro" size=18 %} Platform dues per month</h2>
|
|
||||||
<p class="text-sm opacity-70">What clubs paid us. Club-shop money is theirs, not ours.</p>
|
|
||||||
<div class="h-56">
|
|
||||||
<canvas id="revenue-chart"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-6 grid gap-4 lg:grid-cols-2">
|
|
||||||
<div class="card bg-base-100 shadow">
|
<div class="card bg-base-100 shadow">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-base">{% lucide "milestone" size=18 %} Onboarding</h2>
|
<h2 class="card-title text-base">{% lucide "milestone" size=18 %} Onboarding</h2>
|
||||||
<p class="text-sm opacity-70">Where clubs stall. One with no team or no events is a shell.</p>
|
<p class="text-sm opacity-70">Tracking club onboarding to ensure a smooth start</p>
|
||||||
<div class="mt-2 space-y-3">
|
<div class="mt-2 space-y-3">
|
||||||
{% for step in funnel %}
|
{% for step in funnel %}
|
||||||
<div>
|
<div>
|
||||||
@@ -86,76 +93,18 @@
|
|||||||
<span class="flex items-center gap-2">{% lucide step.icon size=14 %} {{ step.label }}</span>
|
<span class="flex items-center gap-2">{% lucide step.icon size=14 %} {{ step.label }}</span>
|
||||||
<span class="font-semibold tabular-nums">{{ step.count }}</span>
|
<span class="font-semibold tabular-nums">{{ step.count }}</span>
|
||||||
</div>
|
</div>
|
||||||
<progress class="progress progress-primary w-full" value="{{ step.count }}" max="{{ funnel.0.count }}"></progress>
|
<progress class="progress {% if step.count == funnel.0.count %}progress-success{% elif step.count == 0 %}progress-error{% else %}progress-warning{% endif %} w-full" value="{{ step.count }}"
|
||||||
|
max="{{ funnel.0.count }}"></progress>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h2 class="card-title text-base">{% lucide "toggle-right" size=18 %} Feature adoption</h2>
|
|
||||||
<a class="btn btn-ghost btn-xs" href="{% url 'controlpanel:features' %}">Manage</a>
|
|
||||||
</div>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="table table-sm">
|
|
||||||
<tbody>
|
|
||||||
{% for flag in flags %}
|
|
||||||
<tr>
|
|
||||||
<td class="font-mono font-medium">{{ flag.name }}</td>
|
|
||||||
<td class="text-right">
|
|
||||||
{% if flag.overridden %}
|
|
||||||
{# `everyone` overrides club targeting, so the club count says nothing here. #}
|
|
||||||
<span class="badge badge-sm {% if flag.everyone %}badge-success{% else %}badge-error{% endif %}">
|
|
||||||
{% if flag.everyone %}On for all{% else %}Off everywhere{% endif %}
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="tabular-nums">{{ flag.clubs }} / {{ totals.clubs }} clubs</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% empty %}
|
|
||||||
<tr>
|
|
||||||
<td class="text-center opacity-60">No features yet.</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stats mb-6 w-full bg-base-100 shadow">
|
|
||||||
<div class="stat">
|
|
||||||
<div class="stat-title">Active clubs</div>
|
|
||||||
<div class="stat-value">{{ totals.clubs }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat">
|
|
||||||
<div class="stat-title">Archived</div>
|
|
||||||
<div class="stat-value">{{ totals.archived_clubs }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat">
|
|
||||||
<div class="stat-title">Members</div>
|
|
||||||
<div class="stat-value">{{ totals.members }}</div>
|
|
||||||
<div class="stat-desc">{{ attention.members_without_login }} without a login</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat">
|
|
||||||
<div class="stat-title">Club admins</div>
|
|
||||||
<div class="stat-value">{{ totals.admins }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat">
|
|
||||||
<div class="stat-title">Not billed</div>
|
|
||||||
<div class="stat-value {% if attention.clubs_unbilled %}text-warning{% endif %}">{{ attention.clubs_unbilled }}</div>
|
|
||||||
<div class="stat-desc">Clubs on no tier</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow">
|
<div class="card bg-base-100 shadow">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title">Clubs</h2>
|
<h2 class="card-title">{% lucide "building-2" size=18 %} Clubs</h2>
|
||||||
{% include "controlpanel/_club_health_table.html" %}
|
{% include "controlpanel/_club_health_table.html" %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,22 +168,22 @@
|
|||||||
data: {
|
data: {
|
||||||
labels: data.signups.map((point) => point.month),
|
labels: data.signups.map((point) => point.month),
|
||||||
datasets: [
|
datasets: [
|
||||||
{ label: "New", data: data.signups.map((point) => point.new), backgroundColor: css("--color-primary", "#4f46e5") },
|
{label: "New", data: data.signups.map((point) => point.new), backgroundColor: css("--color-primary", "#4f46e5")},
|
||||||
{ label: "Returning", data: data.signups.map((point) => point.returning), backgroundColor: css("--color-accent", "#0ea5e9") },
|
{label: "Returning", data: data.signups.map((point) => point.returning), backgroundColor: css("--color-accent", "#0ea5e9")},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
plugins: { legend: { position: "bottom", labels: { color: ink } } },
|
plugins: {legend: {position: "bottom", labels: {color: ink}}},
|
||||||
scales: {
|
scales: {
|
||||||
x: { stacked: true, ticks: { color: ink }, grid: { color: grid } },
|
x: {stacked: true, ticks: {color: ink}, grid: {color: grid}},
|
||||||
y: { stacked: true, beginAtZero: true, ticks: { color: ink, precision: 0 }, grid: { color: grid } },
|
y: {stacked: true, beginAtZero: true, ticks: {color: ink, precision: 0}, grid: {color: grid}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return [signups, build("revenue-chart", "Dues", data.dues, css("--color-accent", "#0ea5e9"), "bar", true)];
|
return [signups]; // build("revenue-chart", "Dues", data.dues, css("--color-accent", "#0ea5e9"), "bar", true)];
|
||||||
};
|
};
|
||||||
|
|
||||||
let charts = render();
|
let charts = render();
|
||||||
|
|||||||
@@ -751,8 +751,8 @@ class DashboardMetricsTests(ControlPanelTestBase):
|
|||||||
|
|
||||||
self.assertContains(response, "No current season")
|
self.assertContains(response, "No current season")
|
||||||
self.assertContains(response, "MFA pending")
|
self.assertContains(response, "MFA pending")
|
||||||
|
self.assertContains(response, "Payment pending")
|
||||||
self.assertContains(response, 'id="signups-chart"')
|
self.assertContains(response, 'id="signups-chart"')
|
||||||
self.assertContains(response, 'id="revenue-chart"')
|
|
||||||
self.assertContains(response, "js/chart.js")
|
self.assertContains(response, "js/chart.js")
|
||||||
self.assertIn("signups", response.context["charts"])
|
self.assertIn("signups", response.context["charts"])
|
||||||
|
|
||||||
@@ -1014,18 +1014,17 @@ class ClubHealthTableTests(TestCase):
|
|||||||
|
|
||||||
response = self.client.get(reverse("controlpanel:dashboard"))
|
response = self.client.get(reverse("controlpanel:dashboard"))
|
||||||
|
|
||||||
self.assertContains(response, "Owed")
|
# Health, not vanity: Plan and Dues each name something to act on, next to the counts.
|
||||||
self.assertContains(response, "Upcoming")
|
for column in ("Members", "Admins", "Teams", "Events", "Plan", "Dues"):
|
||||||
self.assertContains(response, "Unpaid")
|
self.assertContains(response, f">{column}</th>")
|
||||||
|
|
||||||
|
|
||||||
class ClubListHealthTests(ControlPanelTestBase):
|
class ClubListHealthTests(ControlPanelTestBase):
|
||||||
def test_the_list_shows_the_same_health_columns_as_the_dashboard(self):
|
def test_the_list_shows_the_same_health_columns_as_the_dashboard(self):
|
||||||
response = self.client.get(reverse("controlpanel:club_list"))
|
response = self.client.get(reverse("controlpanel:club_list"))
|
||||||
|
|
||||||
self.assertContains(response, "Owed")
|
for column in ("Members", "Admins", "Teams", "Events", "Plan", "Dues"):
|
||||||
self.assertContains(response, "Upcoming")
|
self.assertContains(response, f">{column}</th>")
|
||||||
self.assertContains(response, "Unpaid")
|
|
||||||
self.assertTemplateUsed(response, "controlpanel/_club_health_table.html")
|
self.assertTemplateUsed(response, "controlpanel/_club_health_table.html")
|
||||||
|
|
||||||
def test_an_archived_club_is_badged_archived_rather_than_dormant(self):
|
def test_an_archived_club_is_badged_archived_rather_than_dormant(self):
|
||||||
@@ -1097,6 +1096,24 @@ class PlatformDuesMetricTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(platform_attention()["clubs_unbilled"], 0)
|
self.assertEqual(platform_attention()["clubs_unbilled"], 0)
|
||||||
|
|
||||||
|
def test_renewals_pending_counts_clubs_about_to_lapse(self):
|
||||||
|
# ~0 in normal running; a number here means the renewal cron has stopped.
|
||||||
|
self.assertEqual(platform_attention()["renewals_pending"], 0)
|
||||||
|
|
||||||
|
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=350)) # ends in 15 days
|
||||||
|
|
||||||
|
self.assertEqual(platform_attention()["renewals_pending"], 1)
|
||||||
|
|
||||||
|
def test_the_dashboard_surfaces_pending_renewals(self):
|
||||||
|
# The whole point of the KPI: a club about to go free is visible, though nothing is
|
||||||
|
# owed yet, so no other figure on the page would show it.
|
||||||
|
subscribe(self.club, self.tier, start=self.today - datetime.timedelta(days=350))
|
||||||
|
staff = User.objects.create_user(email="staff@example.com", password="pw-secret-123", is_staff=True)
|
||||||
|
enrol_mfa(staff)
|
||||||
|
self.client.force_login(staff)
|
||||||
|
|
||||||
|
self.assertContains(self.client.get(reverse("controlpanel:dashboard")), "awaiting renewal")
|
||||||
|
|
||||||
def test_platform_dues_and_club_shop_money_are_different_charts(self):
|
def test_platform_dues_and_club_shop_money_are_different_charts(self):
|
||||||
subscribe(self.club, self.tier)
|
subscribe(self.club, self.tier)
|
||||||
record_payment(self.club.dues.first(), Decimal("500.00"))
|
record_payment(self.club.dues.first(), Decimal("500.00"))
|
||||||
@@ -1114,6 +1131,21 @@ class PlatformDuesMetricTests(TestCase):
|
|||||||
self.assertEqual(club.tier_name, "Standard")
|
self.assertEqual(club.tier_name, "Standard")
|
||||||
self.assertEqual(club.dues_owed, Decimal("500.00"))
|
self.assertEqual(club.dues_owed, Decimal("500.00"))
|
||||||
|
|
||||||
|
def test_a_fully_paid_club_shows_when_its_cover_ends(self):
|
||||||
|
# The end of the current paid period is the day grace would start if nothing renews.
|
||||||
|
subscribe(self.club, self.tier)
|
||||||
|
due = self.club.dues.first()
|
||||||
|
record_payment(due, due.amount)
|
||||||
|
|
||||||
|
club = clubs_with_health().get(pk=self.club.pk)
|
||||||
|
|
||||||
|
self.assertEqual(club.paid_until, due.period_end)
|
||||||
|
|
||||||
|
def test_a_club_that_owes_has_no_paid_until(self):
|
||||||
|
subscribe(self.club, self.tier) # unpaid
|
||||||
|
|
||||||
|
self.assertIsNone(clubs_with_health().get(pk=self.club.pk).paid_until)
|
||||||
|
|
||||||
def test_the_health_table_still_costs_one_query_with_billing_on_it(self):
|
def test_the_health_table_still_costs_one_query_with_billing_on_it(self):
|
||||||
subscribe(self.club, self.tier)
|
subscribe(self.club, self.tier)
|
||||||
subscribe(Club.objects.create(name="Feyenoord"), self.tier)
|
subscribe(Club.objects.create(name="Feyenoord"), self.tier)
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ class SubscribeClubView(PlatformStaffRequiredMixin, FormView):
|
|||||||
subscription.save()
|
subscription.save()
|
||||||
messages.success(self.request, f"{club} is now on {subscription.tier}. The current period keeps the amount it was billed at.")
|
messages.success(self.request, f"{club} is now on {subscription.tier}. The current period keeps the amount it was billed at.")
|
||||||
else:
|
else:
|
||||||
subscribe(club, form.cleaned_data["tier"], start=form.cleaned_data.get("start"), auto_archive=form.cleaned_data["auto_archive"])
|
subscribe(club, form.cleaned_data["tier"], start=form.cleaned_data.get("start"), auto_archive=form.cleaned_data["auto_archive"], auto_renew=form.cleaned_data["auto_renew"])
|
||||||
messages.success(self.request, f"{club} is on {form.cleaned_data['tier']}. Its first period is open.")
|
messages.success(self.request, f"{club} is on {form.cleaned_data['tier']}. Its first period is open.")
|
||||||
except BillingError as error:
|
except BillingError as error:
|
||||||
messages.error(self.request, str(error))
|
messages.error(self.request, str(error))
|
||||||
|
|||||||
5026
static/css/app.css
5026
static/css/app.css
File diff suppressed because one or more lines are too long
@@ -33,8 +33,14 @@
|
|||||||
{% block extra %}{% endblock extra %}
|
{% block extra %}{% endblock extra %}
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="min-h-screen bg-base-200">
|
{% comment %}
|
||||||
<div class="navbar mb-4 border-b border-base-300 bg-base-100 px-6 shadow-sm">
|
An app shell: the viewport is the frame, and exactly one region scrolls. The body is a
|
||||||
|
flex column pinned to the screen height with overflow hidden, so the navbar and the
|
||||||
|
sidebar cannot be scrolled away — only <main> moves. Leave the body scrollable instead
|
||||||
|
and a "sticky" sidebar still drifts on a long page, which is the thing this is for.
|
||||||
|
{% endcomment %}
|
||||||
|
<body class="flex h-screen flex-col overflow-hidden bg-base-200">
|
||||||
|
<div class="navbar shrink-0 border-b border-base-300 bg-base-100 px-6 shadow-sm">
|
||||||
<div class="my-4 flex-1">
|
<div class="my-4 flex-1">
|
||||||
{% block brand %}{% endblock brand %}
|
{% block brand %}{% endblock brand %}
|
||||||
</div>
|
</div>
|
||||||
@@ -65,25 +71,36 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if messages %}
|
<div class="flex flex-1 overflow-hidden">
|
||||||
<div class="mx-auto mt-4 w-full space-y-2 px-4">
|
{% comment %}
|
||||||
{% for message in messages %}
|
The sidebar is a sibling of <main>, not inside it, so it sits outside the one
|
||||||
{% with alert=message|as_alert %}
|
scrolling region and stays put by construction. Pages with no menu (the auth
|
||||||
<div class="alert alert-soft {{ alert.css }}" role="alert">
|
screens) leave the block empty and <main> simply takes the full width.
|
||||||
{% lucide alert.icon size=20 %}
|
{% endcomment %}
|
||||||
<div>
|
{% block menu %}{% endblock menu %}
|
||||||
<div class="font-bold">{{ alert.title }}</div>
|
|
||||||
<div class="text-sm">{{ alert.body }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endwith %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<main class="mx-auto w-full py-4 px-8">
|
<main class="flex-1 overflow-y-auto">
|
||||||
{% block main %}{% endblock main %}
|
<div class="mx-auto w-full px-8 py-6">
|
||||||
</main>
|
{% if messages %}
|
||||||
|
<div class="mb-6 w-full space-y-2">
|
||||||
|
{% for message in messages %}
|
||||||
|
{% with alert=message|as_alert %}
|
||||||
|
<div class="alert alert-soft {{ alert.css }}" role="alert">
|
||||||
|
{% lucide alert.icon size=20 %}
|
||||||
|
<div>
|
||||||
|
<div class="font-bold">{{ alert.title }}</div>
|
||||||
|
<div class="text-sm">{{ alert.body }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% block main %}{% endblock main %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// The button cycles light -> dark -> auto. "auto" removes the attribute and the
|
// The button cycles light -> dark -> auto. "auto" removes the attribute and the
|
||||||
|
|||||||
Reference in New Issue
Block a user