Compare commits
19 Commits
c42963c447
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 40255805c3 | |||
| 127d0e338e | |||
| 83caa233d7 | |||
| fc7a349f8f | |||
| f403128f57 | |||
| 91270b0cf8 | |||
| 19108407c6 | |||
| 10b113f244 | |||
| 5b51f2c945 | |||
| 4d74f3fbde | |||
| a1266378fc | |||
| a2bcb2f0c8 | |||
| 9bc5377cc5 | |||
| 7c874aa87b | |||
| 5283262e6b | |||
| 65a2f741f6 | |||
| 975426a17f | |||
| 9a616c20e4 | |||
| 2d43b0b903 |
@@ -298,6 +298,35 @@ A *.test.rosterchief.app -> <server ip>
|
||||
The compose project is named `rosterchief-test`, so its containers and volumes never collide
|
||||
with a production stack on the same host.
|
||||
|
||||
### Deploying with one command
|
||||
|
||||
Once the server has the repo cloned at `/home/bernard/RosterChief` and its two env files in
|
||||
place, `deploy/deploy-dev.sh` does a full deploy over SSH:
|
||||
|
||||
```bash
|
||||
deploy/deploy-dev.sh # deploy the current branch
|
||||
BRANCH=main deploy/deploy-dev.sh
|
||||
deploy/deploy-dev.sh --push # push the branch first, then deploy
|
||||
```
|
||||
|
||||
It runs from your machine and does the work on the server in one SSH session: fetch the pushed
|
||||
branch (a hard reset to `origin/<branch>`, since a deploy target only receives deploys), build
|
||||
the image, run migrations *explicitly*, restart only `web`, and wait for `/healthz`.
|
||||
|
||||
It refuses to deploy a branch whose local commits are not pushed — the server pulls from git,
|
||||
so unpushed work would ship stale code silently. Override the host, user, directory or branch
|
||||
with the `SSH_HOST` / `SSH_USER` / `REMOTE_DIR` / `BRANCH` environment variables.
|
||||
|
||||
First-time setup on the server, once:
|
||||
|
||||
```bash
|
||||
git clone git@git.siebens.org:bernard/RosterChief.git /home/bernard/RosterChief
|
||||
cd /home/bernard/RosterChief
|
||||
cp .env.compose.example .env # fill in POSTGRES_PASSWORD etc.
|
||||
cp .env.production.example .env.production
|
||||
# then add the reverse_proxy site block to the host's Caddy (see above)
|
||||
```
|
||||
|
||||
## Automated backups
|
||||
|
||||
`deploy/backup.sh` dumps the database, tars the uploads while they are still on local disk,
|
||||
|
||||
@@ -57,7 +57,11 @@ RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PATH="/app/.venv/bin:$PATH"
|
||||
PATH="/app/.venv/bin:$PATH" \
|
||||
# gunicorn 26's control server puts a socket in $HOME. The app user has no home dir, so
|
||||
# without this it logs "Permission denied: /home/rosterchief" on every boot. /app is
|
||||
# already the workdir and owned by the app user, so point HOME there.
|
||||
HOME="/app"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ class TierPriceAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(Subscription)
|
||||
class SubscriptionAdmin(admin.ModelAdmin):
|
||||
list_display = ["club", "tier", "auto_archive"]
|
||||
list_filter = ["tier", "auto_archive"]
|
||||
list_display = ["club", "tier", "auto_renew", "auto_archive"]
|
||||
list_filter = ["tier", "auto_renew", "auto_archive"]
|
||||
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.
|
||||
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:
|
||||
"""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"))
|
||||
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."))
|
||||
notes = models.TextField(_("notes"), blank=True)
|
||||
|
||||
|
||||
@@ -6,17 +6,17 @@ from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
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 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.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."""
|
||||
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)
|
||||
|
||||
return subscription
|
||||
@@ -114,7 +114,7 @@ def waive(due: Due, *, note: str = "") -> Due:
|
||||
return due
|
||||
|
||||
|
||||
def owing_dues(today: date | None = None):
|
||||
def owing_dues():
|
||||
return Due.objects.filter(status__in=Due.OWING)
|
||||
|
||||
|
||||
@@ -151,3 +151,31 @@ def reactivate(club, *, start: date | None = None) -> Due:
|
||||
club.restore()
|
||||
|
||||
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 django.core.management import call_command
|
||||
from django.core.management.base import CommandError
|
||||
from django.test import TestCase
|
||||
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 .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
|
||||
|
||||
|
||||
@@ -335,3 +336,155 @@ class ModelStringTests(BillingTestBase):
|
||||
self.assertIn("10.00", str(payment))
|
||||
self.assertIn("INV-", str(due.invoice))
|
||||
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:
|
||||
model = Subscription
|
||||
fields = ["tier", "auto_archive", "notes"]
|
||||
fields = ["tier", "auto_renew", "auto_archive", "notes"]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
35
controlpanel/messages.py
Normal file
35
controlpanel/messages.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""A compact way to queue a Django message that carries its own title.
|
||||
|
||||
Django's messages framework has no title field — a call site that wants one passes it
|
||||
as ``extra_tags`` (``messages.success(request, body, extra_tags="Club created")``), which
|
||||
reads fine written out but is easy to forget, so in practice every message ends up on
|
||||
the generic per-level heading (`as_alert`'s "Done" / "Careful" / "Something went wrong").
|
||||
|
||||
``notify`` folds level, title and body into one string instead: ``"<level>|<title>|<body>"``.
|
||||
One call, title included, nothing to forget. `as_alert` (controlpanel/templatetags/ui.py)
|
||||
reads the title back off ``extra_tags`` at render time — unchanged from before.
|
||||
"""
|
||||
|
||||
from django.contrib import messages
|
||||
|
||||
#: One letter per Django message level. `notify` picks the level from the spec string;
|
||||
#: `as_alert` picks the icon/colour/fallback-title from the level the message actually
|
||||
#: carries (via ``level_tag``), so the two stay in step by construction.
|
||||
LEVELS = {
|
||||
"s": messages.SUCCESS,
|
||||
"i": messages.INFO,
|
||||
"w": messages.WARNING,
|
||||
"e": messages.ERROR,
|
||||
"d": messages.DEBUG,
|
||||
}
|
||||
|
||||
|
||||
def notify(request, spec: str, **kwargs) -> None:
|
||||
"""Queue a message from a ``"<level>|<title>|<body>"`` spec.
|
||||
|
||||
``level`` is one of ``s`` (success), ``i`` (info), ``w`` (warning), ``e`` (error),
|
||||
``d`` (debug). An empty title (``"s||Body text"``) falls back to the generic
|
||||
per-level heading, same as never passing ``extra_tags`` at all.
|
||||
"""
|
||||
level_code, title, body = spec.split("|", 2)
|
||||
messages.add_message(request, LEVELS[level_code], body, extra_tags=title, **kwargs)
|
||||
@@ -1,5 +1,8 @@
|
||||
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||
from django.http import Http404
|
||||
from django.shortcuts import redirect
|
||||
|
||||
from .messages import notify
|
||||
|
||||
|
||||
class PlatformStaffRequiredMixin(UserPassesTestMixin):
|
||||
@@ -38,3 +41,21 @@ class PlatformSuperuserRequiredMixin(PlatformStaffRequiredMixin):
|
||||
|
||||
def test_func(self):
|
||||
return self.request.user.is_superuser
|
||||
|
||||
|
||||
class RedirectOnInvalidMixin:
|
||||
"""A form submitted from a modal has nowhere sensible to re-render on error: the page
|
||||
that opened it has already moved on, and the view has no standalone template of its
|
||||
own. Redirect back to ``invalid_redirect_url_name`` instead, with the errors flattened
|
||||
into messages, rather than Django's default of re-rendering ``template_name``.
|
||||
"""
|
||||
|
||||
invalid_redirect_url_name = None
|
||||
|
||||
def get_invalid_redirect_kwargs(self):
|
||||
return {}
|
||||
|
||||
def form_invalid(self, form):
|
||||
for error in form.errors.values():
|
||||
notify(self.request, f"e|Couldn't save|{' '.join(error)}")
|
||||
return redirect(self.invalid_redirect_url_name, **self.get_invalid_redirect_kwargs())
|
||||
|
||||
@@ -11,14 +11,14 @@ from decimal import Decimal
|
||||
|
||||
from allauth.mfa.models import Authenticator
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models import Count, DecimalField, Exists, F, IntegerField, OuterRef, Q, Subquery, Sum, Value
|
||||
from django.db.models import Count, DateField, DecimalField, Exists, F, IntegerField, OuterRef, Q, Subquery, Sum, Value
|
||||
from django.db.models.functions import Coalesce, TruncMonth
|
||||
from django.utils import timezone
|
||||
from waffle import get_waffle_flag_model
|
||||
|
||||
from authentication.middleware import ELEVATED_ROLES
|
||||
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 events.models import Attendance, Event
|
||||
from members.models import Member
|
||||
@@ -64,6 +64,8 @@ def clubs_with_health(queryset=None, today=None, now=None):
|
||||
clubs = Club.objects.active() if queryset is None else queryset
|
||||
|
||||
in_season = Q(season__start_date__lte=today, season__end_date__gte=today)
|
||||
# A period the club is covered for, most recent first — paid or waived, both settled.
|
||||
_covered = Due.objects.filter(club=OuterRef("pk"), status__in=(Due.Status.PAID, Due.Status.WAIVED)).order_by("-period_end")
|
||||
managed_this_season = Q(
|
||||
staff_assignments__season__start_date__lte=today,
|
||||
staff_assignments__season__end_date__gte=today,
|
||||
@@ -84,6 +86,13 @@ 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_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]),
|
||||
# How far the club is covered: the furthest-out period that is settled. PAID and
|
||||
# WAIVED both mean nothing is owed for that period, and its end is the day grace
|
||||
# would start if nothing renews — so both count. `covered_status` is read from the
|
||||
# same top row, so the table can badge "paid" vs "waived". Null when the club owes
|
||||
# or was never billed.
|
||||
covered_until=Subquery(_covered.values("period_end")[:1], output_field=DateField()),
|
||||
covered_status=Subquery(_covered.values("status")[:1]),
|
||||
)
|
||||
.annotate(teams_without_coach=F("team_count") - F("teams_managed"))
|
||||
.order_by("name")
|
||||
@@ -141,11 +150,25 @@ def onboarding_funnel():
|
||||
return [
|
||||
{"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 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"},
|
||||
]
|
||||
|
||||
|
||||
def flags_for_club(club):
|
||||
"""Every flag, annotated with whether it is on for this club and why."""
|
||||
enabled_ids = set(club.flags.values_list("pk", flat=True))
|
||||
return [
|
||||
{
|
||||
"flag": flag,
|
||||
"enabled": flag.pk in enabled_ids,
|
||||
# `everyone` overrides club targeting, so the per-club toggle is moot.
|
||||
"overridden": flag.everyone is not None,
|
||||
}
|
||||
for flag in get_waffle_flag_model().objects.order_by("name")
|
||||
]
|
||||
|
||||
|
||||
def flag_adoption():
|
||||
"""Clubs per feature flag. `everyone` overrides club targeting, so a flag set that
|
||||
way is on (or off) everywhere and its club count says nothing — hence `overridden`."""
|
||||
@@ -172,6 +195,10 @@ def platform_attention():
|
||||
"dues_in_grace": dues_in_grace().count(),
|
||||
"dues_overdue": dues_overdue().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()),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
{% load lucide ui %}
|
||||
|
||||
{% comment %}
|
||||
The shell every billing form uses: card, fields, cancel + submit. Included with
|
||||
`heading`, `blurb`, `submit_label`, `submit_icon` and `cancel_url`.
|
||||
{% endcomment %}
|
||||
<div class="card max-w-xl bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
{% if blurb %}<p class="text-sm opacity-70">{{ blurb }}</p>{% endif %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{% for error in form.non_field_errors %}
|
||||
<div class="alert alert-error my-2">
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% for field in form %}
|
||||
<div class="form-control my-3 w-full">
|
||||
{% if field.field.widget.input_type == "checkbox" %}
|
||||
<label class="label cursor-pointer justify-start gap-3" for="{{ field.id_for_label }}">
|
||||
{{ field|daisy }}
|
||||
<span class="label-text">{{ field.label }}</span>
|
||||
</label>
|
||||
{% else %}
|
||||
<label class="label" for="{{ field.id_for_label }}">
|
||||
<span class="label-text">{{ field.label }}</span>
|
||||
</label>
|
||||
{{ field|daisy }}
|
||||
{% endif %}
|
||||
{% if field.help_text %}<span class="label-text-alt mt-1 block text-base-content/70">{{ field.help_text }}</span>{% endif %}
|
||||
{% for error in field.errors %}<span class="label-text-alt mt-1 text-error">{{ error }}</span>{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="card-actions justify-end pt-2">
|
||||
<a class="btn btn-outline gap-2" href="{{ cancel_url }}">{% lucide "arrow-left" size=16 %} Cancel</a>
|
||||
<button class="btn btn-primary gap-2" type="submit">{% lucide submit_icon|default:"check" size=16 %} {{ submit_label|default:"Save" }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
49
controlpanel/templates/controlpanel/_club_admins_card.html
Normal file
49
controlpanel/templates/controlpanel/_club_admins_card.html
Normal file
@@ -0,0 +1,49 @@
|
||||
{% load lucide ui %}
|
||||
|
||||
{% comment %}
|
||||
Club-scoped admins, and the modals to add one / confirm removing one. Included with
|
||||
`club`, `admins`, `admin_form` already in context.
|
||||
{% endcomment %}
|
||||
<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 "shield-user" size=18 %} Club admins</h2>
|
||||
<button class="btn btn-primary btn-sm gap-2" type="button" onclick="document.getElementById('club_admin_add_modal').showModal()">{% lucide "user-plus" size=16 %} Add admin</button>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for role in admins %}
|
||||
<tr>
|
||||
<td>{{ role.member }}</td>
|
||||
<td>{{ role.member.user.email|default:"—" }}</td>
|
||||
<td class="text-right">
|
||||
<button class="btn btn-error btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ role.pk|dom_id:"admin_remove_modal" }}').showModal()">{% lucide "trash-2" size=14 %} Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-center opacity-60">No admins yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% url 'controlpanel:club_admin_add' club.pk as club_admin_add_url %}
|
||||
{% include "controlpanel/_modal_form.html" with modal_id="club_admin_add_modal" title="Add admin" form=admin_form action_url=club_admin_add_url submit_label="Grant admin" submit_icon="user-plus" blurb="A club admin can manage everything in this club. They will be required to set up two-factor authentication before they can sign in." %}
|
||||
|
||||
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
|
||||
{% for role in admins %}
|
||||
{% url 'controlpanel:club_admin_remove' club.pk role.pk as admin_remove_url %}
|
||||
{% include "controlpanel/_confirm_modal.html" with modal_id=role.pk|dom_id:"admin_remove_modal" title="Remove admin" body="Remove "|add:role.member.get_full_name|add:" as an admin of this club? They keep their membership — only admin rights are revoked." action_url=admin_remove_url submit_label="Remove" %}
|
||||
{% endfor %}
|
||||
124
controlpanel/templates/controlpanel/_club_billing_card.html
Normal file
124
controlpanel/templates/controlpanel/_club_billing_card.html
Normal file
@@ -0,0 +1,124 @@
|
||||
{% load lucide ui %}
|
||||
|
||||
{% comment %}
|
||||
What the platform bills this club: plan, periods, and the modals for changing plan,
|
||||
opening a period, and recording a payment. Included with `club`, `subscription`,
|
||||
`dues`, `today`, `subscription_form`, `open_period_form`, `open_period_blurb` already
|
||||
in context.
|
||||
{% endcomment %}
|
||||
<div class="card mb-6 bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 class="card-title text-base">{% lucide "receipt-euro" size=18 %} Billing</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('subscription_modal').showModal()">
|
||||
{% lucide "layers" size=14 %} {% if subscription %}Change plan{% else %}Start billing{% endif %}
|
||||
</button>
|
||||
{% if subscription %}
|
||||
<button class="btn btn-primary btn-sm gap-2" type="button" onclick="document.getElementById('open_period_modal').showModal()">
|
||||
{% lucide "calendar-plus" size=14 %} {% if club.is_archived %}Reactivate{% else %}Open period{% endif %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not subscription %}
|
||||
<p class="text-sm opacity-70">This club is not billed for anything. Put it on a tier to start.</p>
|
||||
{% else %}
|
||||
<p class="text-sm opacity-70">
|
||||
On plan <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 %}
|
||||
Archived automatically when a period goes unpaid past its grace period.
|
||||
{% else %}
|
||||
<span class="badge badge-warning badge-sm">Auto-archive off</span> — it will never be archived for non-payment.
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Period</th>
|
||||
<th class="text-right">Billed</th>
|
||||
<th class="text-right">Paid</th>
|
||||
<th class="text-right">Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for due in dues %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ due.period_start|date:"j M Y" }} — {{ due.period_end|date:"j M Y" }}
|
||||
<div class="text-xs opacity-60">{{ due.tier.name }} · {{ due.invoice.number }} · grace to {{ due.grace_until|date:"j M Y" }}</div>
|
||||
</td>
|
||||
<td class="text-right tabular-nums">€{{ due.amount|floatformat:2 }}</td>
|
||||
<td class="text-right tabular-nums">€{{ due.amount_paid|floatformat:2 }}</td>
|
||||
<td class="text-right">
|
||||
{% if due.status == "paid" %}
|
||||
<span class="badge badge-success gap-1">{% lucide "check" size=12 %} Paid</span>
|
||||
{% elif due.status == "waived" %}
|
||||
<span class="badge badge-outline gap-1">{% lucide "check" size=12 %} Waived</span>
|
||||
{% elif due.grace_until < today %}
|
||||
<span class="badge badge-error gap-1">{% lucide "triangle-alert" size=12 %} Overdue</span>
|
||||
{% elif due.period_end < today %}
|
||||
<span class="badge badge-warning gap-1">{% lucide "hourglass" size=12 %} In grace</span>
|
||||
{% else %}
|
||||
<span class="badge badge-outline">{{ due.get_status_display }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right flex flex-row gap-2 justify-end">
|
||||
{% if due.is_owing %}
|
||||
<button class="btn btn-primary btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ due.pk|dom_id:"due_pay_modal" }}').showModal()">{% lucide "banknote" size=14 %} Add payment</button>
|
||||
{% if not due.payments.all %}
|
||||
<form class="inline" method="post" action="{% url 'controlpanel:due_waive' due.pk %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-outline btn-sm gap-1" type="submit">{% lucide "ban" size=14 %} Waive payment</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<a class="btn btn-accent btn-outline btn-sm gap-1" href="{% url 'controlpanel:due_invoice' due.pk %}">{% lucide "file-down" size=14 %} Download invoice</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% for payment in due.payments.all %}
|
||||
<tr class="text-xs opacity-70">
|
||||
<td colspan="2" class="pl-8">
|
||||
{% lucide "corner-down-right" size=12 %}
|
||||
{{ payment.paid_at|date:"j M Y" }} · {{ payment.get_method_display }}{% if payment.reference %} · {{ payment.reference }}{% endif %}
|
||||
</td>
|
||||
<td class="text-right tabular-nums">€{{ payment.amount|floatformat:2 }}</td>
|
||||
<td colspan="2"></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center opacity-60">No periods billed yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
|
||||
{% for due in dues %}
|
||||
{% if due.is_owing %}
|
||||
{% url 'controlpanel:due_pay' due.pk as due_pay_url %}
|
||||
{% include "controlpanel/_modal_form.html" with modal_id=due.pk|dom_id:"due_pay_modal" title="Record payment" form=due.payment_form action_url=due_pay_url submit_label="Record payment" submit_icon="banknote" %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% url 'controlpanel:club_subscribe' club.pk as subscribe_url %}
|
||||
{% include "controlpanel/_modal_form.html" with modal_id="subscription_modal" title=subscription|yesno:"Change plan,Start billing" form=subscription_form action_url=subscribe_url submit_label="Save plan" submit_icon="layers" blurb="Changing tier does not re-bill: the current period keeps the amount it was issued at, and the new rate applies from the next one." %}
|
||||
|
||||
{% if subscription %}
|
||||
{% url 'controlpanel:club_open_period' club.pk as open_period_url %}
|
||||
{% include "controlpanel/_modal_form.html" with modal_id="open_period_modal" title=club.is_archived|yesno:"Reactivate,Open period" form=open_period_form action_url=open_period_url submit_label="Open period" submit_icon="calendar-plus" blurb=open_period_blurb %}
|
||||
{% endif %}
|
||||
45
controlpanel/templates/controlpanel/_club_features_card.html
Normal file
45
controlpanel/templates/controlpanel/_club_features_card.html
Normal file
@@ -0,0 +1,45 @@
|
||||
{% load lucide %}
|
||||
|
||||
{% comment %}
|
||||
Which feature flags apply to this club. Included with `club`, `flags` (from
|
||||
`flags_for_club`) already in context.
|
||||
{% endcomment %}
|
||||
<div class="card mb-6 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 %} Features</h2>
|
||||
<a class="btn btn-outline btn-sm gap-2" href="{% url 'controlpanel:features' %}">{% lucide "wrench" size=14 %} Manage features</a>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
{% for entry in flags %}
|
||||
<tr>
|
||||
<td class="font-mono font-medium">{{ entry.flag.name }}</td>
|
||||
<td class="opacity-70">{{ entry.flag.note|default:"—" }}</td>
|
||||
<td class="text-right">
|
||||
{% if entry.overridden %}
|
||||
{# `everyone` overrides club targeting, so a per-club toggle would be a lie. #}
|
||||
<span class="badge {% if entry.flag.everyone %}badge-success{% else %}badge-error{% endif %}">
|
||||
{% if entry.flag.everyone %}On for all clubs{% else %}Off everywhere{% endif %}
|
||||
</span>
|
||||
{% else %}
|
||||
<form method="post" action="{% url 'controlpanel:club_feature_toggle' club.pk entry.flag.pk %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-sm gap-1 {% if entry.enabled %}btn-success{% else %}btn-ghost{% endif %}" type="submit">
|
||||
{% if entry.enabled %}{% lucide "toggle-right" size=16 %} On{% else %}{% lucide "toggle-left" size=16 %} Off{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td class="text-center opacity-60">No features defined yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -13,65 +13,117 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Club</th>
|
||||
<th></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">Teams</th>
|
||||
<th class="text-right">Events</th>
|
||||
<th class="text-right">Plan</th>
|
||||
<th class="text-right">Dues</th>
|
||||
<th class="text-right">Plan end</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for club in clubs %}
|
||||
<tr>
|
||||
<td>
|
||||
<a class="link link-hover font-medium" href="{% url 'controlpanel:club_detail' club.pk %}">{{ club.name }}</a>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-1">
|
||||
<span class="text-xs opacity-60">{{ club.slug }}</span>
|
||||
<div class="flex flex-row items-center gap-4">
|
||||
<div>
|
||||
{% 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 %}
|
||||
{% comment %}
|
||||
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>
|
||||
<span class="badge badge-warning">{% lucide "archive" size=14 %} archived</span>
|
||||
{% 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.upcoming_events %}<span class="badge badge-ghost badge-xs gap-1">{% lucide "moon-star" size=10 %} Dormant</span>{% endif %}
|
||||
{% if not club.has_season %}
|
||||
<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 %}
|
||||
</div>
|
||||
</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">
|
||||
{% if not club.dues_owed %}
|
||||
{% if club.tier_name %}<span class="badge badge-success badge-xs">Paid</span>{% endif %}
|
||||
{% else %}
|
||||
<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>
|
||||
<div class="flex flex-row gap-2 items-center justify-end">
|
||||
{% if not club.admin_count %}
|
||||
<span class="text-error">{% lucide "triangle-alert" size=16 %}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<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 %}
|
||||
<span class="{% if not club.admin_count %}font-bold text-error{% endif %}">{{ club.admin_count }}</span>
|
||||
</div>
|
||||
</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 {% 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 %}
|
||||
-
|
||||
{% 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 %}
|
||||
Not owing and on a plan. covered_until is the settled period's end — the day
|
||||
grace would start if nothing renews — shown on its own row under the badge,
|
||||
for a paid period AND a waived one (both cover the club, they just differ in
|
||||
how). No covered period at all (only cancelled dues, say) shows a dash.
|
||||
{% endcomment %}
|
||||
<div class="flex flex-col items-end gap-1">
|
||||
{% if club.covered_status == "waived" %}
|
||||
<span class="badge badge-ghost badge-outline">waived</span>
|
||||
{% elif club.covered_until %}
|
||||
<span class="badge badge-success">paid</span>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
-
|
||||
{% 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 class="text-right">
|
||||
<span class="whitespace-nowrap">{{ club.covered_until|date:"j M Y"|default:"-" }}</span>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a class="btn btn-sm btn-outline gap-2" href="{% url "controlpanel:club_detail" club.pk %}">{% lucide "pencil" size=14 %} Edit</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
|
||||
28
controlpanel/templates/controlpanel/_confirm_modal.html
Normal file
28
controlpanel/templates/controlpanel/_confirm_modal.html
Normal file
@@ -0,0 +1,28 @@
|
||||
{% load lucide %}
|
||||
|
||||
{% comment %}
|
||||
A daisyUI native <dialog> confirmation modal for a destructive POST action with no
|
||||
fields of its own. Included with `modal_id`, `title`, `body`, `action_url`, and
|
||||
optional `submit_label` (default "Confirm"), `submit_icon` (default "trash-2"). The
|
||||
submit button sits outside the form tag (linked via the `form` attribute), same as
|
||||
`_modal_form.html`, so it can share the `modal-action` row with the dialog-closing
|
||||
Cancel button without nesting one <form> inside another.
|
||||
{% endcomment %}
|
||||
<dialog id="{{ modal_id }}" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">{{ title }}</h3>
|
||||
<p class="py-2 text-sm opacity-70">{{ body }}</p>
|
||||
<form method="post" action="{{ action_url }}" id="{{ modal_id }}-form">
|
||||
{% csrf_token %}
|
||||
</form>
|
||||
<div class="modal-action">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-outline gap-2">{% lucide "x" size=16 %} Cancel</button>
|
||||
</form>
|
||||
<button class="btn btn-error gap-2" type="submit" form="{{ modal_id }}-form">{% lucide submit_icon|default:"trash-2" size=16 %} {{ submit_label|default:"Confirm" }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
29
controlpanel/templates/controlpanel/_form_fields.html
Normal file
29
controlpanel/templates/controlpanel/_form_fields.html
Normal file
@@ -0,0 +1,29 @@
|
||||
{% load ui %}
|
||||
|
||||
{% comment %}
|
||||
The field loop every card-form and modal-form wrapper shares: label, daisyUI-styled
|
||||
widget, help text, errors — with checkboxes laid out label-beside-input instead of
|
||||
label-above. Included with `form`.
|
||||
{% endcomment %}
|
||||
{% for error in form.non_field_errors %}
|
||||
<div class="alert alert-error my-2">
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% for field in form %}
|
||||
<div class="form-control my-3 w-full">
|
||||
{% if field.field.widget.input_type == "checkbox" %}
|
||||
<label class="label cursor-pointer justify-start gap-3" for="{{ field.id_for_label }}">
|
||||
{{ field|daisy }}
|
||||
<span class="label-text">{{ field.label }}</span>
|
||||
</label>
|
||||
{% else %}
|
||||
<label class="label" for="{{ field.id_for_label }}">
|
||||
<span class="label-text">{{ field.label }}</span>
|
||||
</label>
|
||||
{{ field|daisy }}
|
||||
{% endif %}
|
||||
{% if field.help_text %}<span class="label-text-alt mt-1 text-xs block text-base-content/70">{{ field.help_text }}</span>{% endif %}
|
||||
{% for error in field.errors %}<span class="label-text-alt text-xs mt-1 text-error">{{ error }}</span>{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
28
controlpanel/templates/controlpanel/_modal_form.html
Normal file
28
controlpanel/templates/controlpanel/_modal_form.html
Normal file
@@ -0,0 +1,28 @@
|
||||
{% load lucide %}
|
||||
|
||||
{% comment %}
|
||||
A daisyUI native <dialog> modal wrapping a Django form that posts straight to
|
||||
`action_url`. Included with `modal_id`, `title`, `form`, `action_url`, `submit_label`
|
||||
and `submit_icon`, plus an optional `blurb`. The submit button sits outside the form
|
||||
tag (linked via the `form` attribute) so it can share the `modal-action` row with the
|
||||
dialog-closing Cancel button without nesting one <form> inside another.
|
||||
{% endcomment %}
|
||||
<dialog id="{{ modal_id }}" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">{{ title }}</h3>
|
||||
{% if blurb %}<p class="py-2 text-sm opacity-70">{{ blurb }}</p>{% endif %}
|
||||
<form method="post" action="{{ action_url }}" id="{{ modal_id }}-form">
|
||||
{% csrf_token %}
|
||||
{% include "controlpanel/_form_fields.html" %}
|
||||
</form>
|
||||
<div class="modal-action">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-outline gap-2">{% lucide "x" size=16 %} Cancel</button>
|
||||
</form>
|
||||
<button class="btn btn-primary gap-2" type="submit" form="{{ modal_id }}-form">{% lucide submit_icon|default:"check" size=16 %} {{ submit_label|default:"Save" }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
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 %} Platform admins
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
@@ -1,39 +0,0 @@
|
||||
{% extends "controlpanel/base.html" %}
|
||||
{% load lucide ui %}
|
||||
|
||||
{% block heading %}Grant platform access{% endblock heading %}
|
||||
|
||||
{% block panel %}
|
||||
<div class="card max-w-xl bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info">
|
||||
<span>Platform admins can manage every club. They must set up two-factor authentication before they can sign in.</span>
|
||||
</div>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{% for error in form.non_field_errors %}
|
||||
<div class="alert alert-error my-2"><span>{{ error }}</span></div>
|
||||
{% endfor %}
|
||||
{% for field in form %}
|
||||
<div class="form-control my-3 w-full">
|
||||
{% if field.field.widget.input_type == "checkbox" %}
|
||||
<label class="label cursor-pointer justify-start gap-3" for="{{ field.id_for_label }}">
|
||||
{{ field|daisy }}
|
||||
<span class="label-text">{{ field.label }}</span>
|
||||
</label>
|
||||
{% else %}
|
||||
<label class="label" for="{{ field.id_for_label }}"><span class="label-text">{{ field.label }}</span></label>
|
||||
{{ field|daisy }}
|
||||
{% endif %}
|
||||
{% if field.help_text %}<span class="label-text-alt mt-1 text-base-content/70">{{ field.help_text }}</span>{% endif %}
|
||||
{% for error in field.errors %}<span class="label-text-alt mt-1 text-error">{{ error }}</span>{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="card-actions justify-end pt-2">
|
||||
<a class="btn btn-outline gap-2" href="{% url 'controlpanel:admins' %}">{% lucide "arrow-left" size=16 %} Cancel</a>
|
||||
<button class="btn btn-primary gap-2" type="submit">{% lucide "user-plus" size=16 %} Grant access</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock panel %}
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "controlpanel/base.html" %}
|
||||
{% load lucide %}
|
||||
{% load lucide ui %}
|
||||
|
||||
{% block heading %}Platform admins{% endblock heading %}
|
||||
|
||||
@@ -8,10 +8,13 @@
|
||||
{% endblock subheading %}
|
||||
|
||||
{% block actions %}
|
||||
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:admin_add' %}">{% lucide "user-plus" size=16 %} Grant access</a>
|
||||
<button class="btn btn-primary gap-2" type="button" onclick="document.getElementById('admin_add_modal').showModal()">{% lucide "user-plus" size=16 %} Grant access</button>
|
||||
{% endblock actions %}
|
||||
|
||||
{% block panel %}
|
||||
{% url 'controlpanel:admin_add' as admin_add_url %}
|
||||
{% include "controlpanel/_modal_form.html" with modal_id="admin_add_modal" title="Grant platform access" form=admin_form action_url=admin_add_url submit_label="Grant access" submit_icon="user-plus" blurb="Platform admins can manage every club. They must set up two-factor authentication before they can sign in." %}
|
||||
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<div class="overflow-x-auto">
|
||||
@@ -30,15 +33,16 @@
|
||||
<tr>
|
||||
<td>
|
||||
<div class="font-medium">{{ admin.email }}</div>
|
||||
{% if admin.pk == user.pk %}<div class="text-xs opacity-60">That's you</div>{% endif %}
|
||||
{% if admin.pk == user.pk %}
|
||||
<div class="text-xs opacity-60">That's you</div>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="{% url 'controlpanel:admin_update' admin.pk %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="is_staff" value="{% if admin.is_staff %}0{% else %}1{% endif %}">
|
||||
<input type="hidden" name="is_superuser" value="{% if admin.is_superuser %}1{% else %}0{% endif %}">
|
||||
<button class="btn btn-xs gap-1 {% if admin.is_staff %}btn-success{% else %}btn-ghost{% endif %}" type="submit">
|
||||
{% if admin.is_staff %}{% lucide "check" size=14 %} Yes{% else %}No{% endif %}
|
||||
<button class="btn btn-sm gap-1 {% if admin.is_staff %}btn-success{% else %}btn-outline{% endif %}" type="submit">
|
||||
{% if admin.is_staff %}{% lucide "user" size=14 %} Yes{% else %}{% lucide "x" size=14 %} No{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
@@ -47,17 +51,14 @@
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="is_staff" value="{% if admin.is_staff %}1{% else %}0{% endif %}">
|
||||
<input type="hidden" name="is_superuser" value="{% if admin.is_superuser %}0{% else %}1{% endif %}">
|
||||
<button class="btn btn-xs gap-1 {% if admin.is_superuser %}btn-warning{% else %}btn-ghost{% endif %}" type="submit">
|
||||
{% if admin.is_superuser %}{% lucide "shield" size=14 %} Yes{% else %}No{% endif %}
|
||||
<button class="btn btn-sm gap-1 {% if admin.is_superuser %}btn-warning{% else %}btn-outline{% endif %}" type="submit">
|
||||
{% if admin.is_superuser %}{% lucide "shield" size=14 %} Yes{% else %}{% lucide "x" size=14 %} No{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
<td class="opacity-70">{{ admin.last_login|date:"j M Y"|default:"Never" }}</td>
|
||||
<td class="text-right">
|
||||
<form method="post" action="{% url 'controlpanel:admin_revoke' admin.pk %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-ghost btn-xs gap-1 text-error" type="submit">{% lucide "user-minus" size=14 %} Revoke</button>
|
||||
</form>
|
||||
<button class="btn btn-error btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ admin.pk|dom_id:"admin_revoke_modal" }}').showModal()">{% lucide "user-minus" size=14 %} Revoke</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
@@ -68,6 +69,12 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
|
||||
{% for admin in admins %}
|
||||
{% url 'controlpanel:admin_revoke' admin.pk as admin_revoke_url %}
|
||||
{% include "controlpanel/_confirm_modal.html" with modal_id=admin.pk|dom_id:"admin_revoke_modal" title="Revoke platform access" body="Revoke platform access for "|add:admin.email|add:"? They will no longer be able to reach the control panel." action_url=admin_revoke_url submit_label="Revoke" submit_icon="user-minus" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock panel %}
|
||||
|
||||
@@ -5,19 +5,33 @@
|
||||
{% block panel_title %}Control panel{% endblock panel_title %} · RosterChief
|
||||
{% 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 %}
|
||||
{% if maintenance_on %}
|
||||
<div class="alert alert-error mb-6">
|
||||
{% lucide "wrench" size=20 %}
|
||||
<span>
|
||||
<strong>The platform is closed for maintenance.</strong>
|
||||
Clubs see a maintenance page and the scheduled jobs are standing down.
|
||||
</span>
|
||||
<a class="btn btn-sm" href="{% url 'controlpanel:features' %}">Reopen</a>
|
||||
<strong>The platform is currently closed for maintenance.</strong>
|
||||
Clubs see a maintenance page and the scheduled jobs are standing down.
|
||||
</span>
|
||||
<a class="btn btn-sm gap-2 btn-error btn-soft" href="{% url 'controlpanel:features' %}">{% lucide "unlock" size=16 %} Reopen platform</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="mb-6 flex flex-wrap flex-row items-center justify-between gap-3">
|
||||
{% block logo %}{% endblock logo %}
|
||||
|
||||
<div class="flex flex-col gap-2 grow">
|
||||
<h1 class="text-3xl font-bold">
|
||||
{% block heading %}Control panel{% endblock heading %}
|
||||
</h1>
|
||||
@@ -28,15 +42,11 @@
|
||||
{% block actions %}{% endblock actions %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div role="tablist" class="tabs-boxed tabs mb-6 w-fit">
|
||||
<a role="tab" href="{% url 'controlpanel:dashboard' %}" class="tab gap-2 {% if nav == 'dashboard' %}tab-active{% endif %}">{% lucide "layout-dashboard" size=16 %} Dashboard</a>
|
||||
<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>
|
||||
<a role="tab" href="{% url 'controlpanel:billing' %}" class="tab gap-2 {% if nav == 'billing' %}tab-active{% endif %}">{% lucide "receipt-euro" size=16 %} Billing</a>
|
||||
<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>
|
||||
|
||||
{# Below `lg` the sidebar is hidden, so the same links appear here rather than nowhere. #}
|
||||
<ul class="menu menu-horizontal mb-6 w-full gap-1 overflow-x-auto rounded-box bg-base-100 lg:hidden">
|
||||
{% include "controlpanel/_nav_items.html" %}
|
||||
</ul>
|
||||
|
||||
{% block panel %}{% endblock panel %}
|
||||
{% endblock main %}
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
{% extends "controlpanel/base.html" %}
|
||||
{% load lucide %}
|
||||
{% load lucide ui %}
|
||||
|
||||
{% block heading %}Billing{% endblock heading %}
|
||||
{% block subheading %}<p class="text-sm opacity-70">What the platform charges its clubs.</p>{% endblock subheading %}
|
||||
|
||||
{% block actions %}
|
||||
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:tier_create' %}">{% lucide "plus" size=16 %} New tier</a>
|
||||
<button class="btn btn-primary gap-2" type="button" onclick="document.getElementById('tier_create_modal').showModal()">{% lucide "plus" size=16 %} New plan</button>
|
||||
{% endblock actions %}
|
||||
|
||||
{% block panel %}
|
||||
{% url 'controlpanel:tier_create' as tier_create_url %}
|
||||
{% include "controlpanel/_modal_form.html" with modal_id="tier_create_modal" title="New plan" form=tier_form action_url=tier_create_url submit_label="Create plan" submit_icon="plus" %}
|
||||
|
||||
<div class="card mb-6 bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">{% lucide "layers" size=18 %} Tiers</h2>
|
||||
<h2 class="card-title text-base">{% lucide "layers" size=18 %} Plans</h2>
|
||||
{% comment %}
|
||||
Prices are dated, not edited. A rate change is a new row with a future
|
||||
active_from; every period already opened keeps the amount it was billed at,
|
||||
so raising the price cannot rewrite an invoice you have already sent.
|
||||
{% endcomment %}
|
||||
<p class="text-sm opacity-70">A rate change is a new dated price. Periods already billed keep the amount they were issued at.</p>
|
||||
<p class="text-sm opacity-70">A rate change only takes effect as of a certain date. Periods already billed keep the amount they were issued at.</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tier</th>
|
||||
<th>Plan</th>
|
||||
<th class="text-right">Clubs</th>
|
||||
<th>Prices</th>
|
||||
<th></th>
|
||||
@@ -34,7 +36,8 @@
|
||||
<td>
|
||||
<div class="font-medium">{{ tier.name }}</div>
|
||||
{% if not tier.is_active %}<span class="badge badge-ghost badge-xs">Retired</span>{% endif %}
|
||||
{% if tier.description %}<div class="text-xs opacity-60">{{ tier.description }}</div>{% endif %}
|
||||
{% if tier.description %}
|
||||
<div class="text-xs opacity-60">{{ tier.description }}</div>{% endif %}
|
||||
</td>
|
||||
<td class="text-right tabular-nums">{{ tier.club_count }}</td>
|
||||
<td>
|
||||
@@ -48,14 +51,14 @@
|
||||
<span class="badge badge-error badge-sm">No price — cannot be billed</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<a class="btn btn-ghost btn-xs gap-1" href="{% url 'controlpanel:tier_price_create' tier.pk %}">{% lucide "euro" size=14 %} New price</a>
|
||||
<a class="btn btn-ghost btn-xs gap-1" href="{% url 'controlpanel:tier_update' tier.pk %}">{% lucide "pencil" size=14 %} Edit</a>
|
||||
<td class="flex flex-row gap-2 justify-end">
|
||||
<button class="btn btn-primary btn-sm btn-outline gap-1" type="button" onclick="document.getElementById('{{ tier.pk|dom_id:"tier_price_modal" }}').showModal()">{% lucide "euro" size=14 %} New price</button>
|
||||
<button class="btn btn-sm btn-outline gap-1" type="button" onclick="document.getElementById('{{ tier.pk|dom_id:"tier_edit_modal" }}').showModal()">{% lucide "pencil" size=14 %} Edit</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center opacity-60">No tiers yet.</td>
|
||||
<td colspan="4" class="text-center opacity-60">No plans yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -64,6 +67,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
|
||||
{% for tier in tiers %}
|
||||
{% url 'controlpanel:tier_price_create' tier.pk as tier_price_url %}
|
||||
{% include "controlpanel/_modal_form.html" with modal_id=tier.pk|dom_id:"tier_price_modal" title="New price — "|add:tier.name form=tier.price_form action_url=tier_price_url submit_label="Add price" submit_icon="euro" %}
|
||||
|
||||
{% url 'controlpanel:tier_update' tier.pk as tier_update_url %}
|
||||
{% include "controlpanel/_modal_form.html" with modal_id=tier.pk|dom_id:"tier_edit_modal" title="Edit "|add:tier.name form=tier.edit_form action_url=tier_update_url submit_label="Save" submit_icon="check" %}
|
||||
{% endfor %}
|
||||
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">{% lucide "receipt-euro" size=18 %} Owed</h2>
|
||||
@@ -74,7 +86,7 @@
|
||||
<th>Club</th>
|
||||
<th>Period</th>
|
||||
<th class="text-right">Owed</th>
|
||||
<th>Status</th>
|
||||
<th class="text-right">Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -90,18 +102,18 @@
|
||||
<div class="text-xs opacity-60">Grace to {{ due.grace_until|date:"j M Y" }}</div>
|
||||
</td>
|
||||
<td class="text-right font-semibold tabular-nums">€{{ due.balance|floatformat:2 }}</td>
|
||||
<td>
|
||||
<td class="text-right">
|
||||
{% if due.grace_until < today %}
|
||||
<span class="badge badge-error gap-1">{% lucide "triangle-alert" size=12 %} Overdue</span>
|
||||
{% elif due.period_end < today %}
|
||||
<span class="badge badge-warning gap-1">{% lucide "hourglass" size=12 %} In grace</span>
|
||||
{% else %}
|
||||
<span class="badge badge-ghost">{{ due.get_status_display }}</span>
|
||||
<span class="badge badge-outline">{{ due.get_status_display }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<a class="btn btn-primary btn-xs gap-1" href="{% url 'controlpanel:due_pay' due.pk %}">{% lucide "banknote" size=14 %} Record payment</a>
|
||||
<a class="btn btn-ghost btn-xs gap-1" href="{% url 'controlpanel:due_invoice' due.pk %}">{% lucide "file-text" size=14 %} Invoice</a>
|
||||
<td class="text-right flex flex-row gap-2 justify-end">
|
||||
<button class="btn btn-primary btn-sm btn-outline gap-1" type="button" onclick="document.getElementById('{{ due.pk|dom_id:"due_pay_modal" }}').showModal()">{% lucide "banknote" size=14 %} Record payment</button>
|
||||
<a class="btn btn-accent btn-outline btn-sm gap-1" href="{% url 'controlpanel:due_invoice' due.pk %}">{% lucide "file-down" size=14 %} Download invoice</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
@@ -114,4 +126,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
|
||||
{% for due in owing %}
|
||||
{% url 'controlpanel:due_pay' due.pk as due_pay_url %}
|
||||
{% include "controlpanel/_modal_form.html" with modal_id=due.pk|dom_id:"due_pay_modal" title="Record payment — "|add:due.club.name form=due.payment_form action_url=due_pay_url submit_label="Record payment" submit_icon="banknote" %}
|
||||
{% endfor %}
|
||||
{% endblock panel %}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
{% extends "controlpanel/base.html" %}
|
||||
{% load lucide ui %}
|
||||
|
||||
{% block heading %}Add an admin to {{ club }}{% endblock heading %}
|
||||
|
||||
{% block panel %}
|
||||
<div class="card max-w-xl bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info">
|
||||
<span>A club admin can manage everything in this club. They will be required to set up two-factor authentication before they can sign in.</span>
|
||||
</div>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{% for error in form.non_field_errors %}
|
||||
<div class="alert alert-error my-2">
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% for field in form %}
|
||||
<div class="form-control my-3 w-full">
|
||||
<label class="label" for="{{ field.id_for_label }}">
|
||||
<span class="label-text">{{ field.label }}</span>
|
||||
</label>
|
||||
{{ field|daisy }}
|
||||
{% if field.help_text %}<span class="label-text-alt mt-1 text-base-content/70">{{ field.help_text }}</span>{% endif %}
|
||||
{% for error in field.errors %}<span class="label-text-alt mt-1 text-error">{{ error }}</span>{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="card-actions justify-end pt-2">
|
||||
<a class="btn btn-outline gap-2" href="{% url 'controlpanel:club_detail' club.pk %}">{% lucide "arrow-left" size=16 %} Cancel</a>
|
||||
<button class="btn btn-primary" type="submit">Grant admin</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock panel %}
|
||||
@@ -1,19 +1,30 @@
|
||||
{% extends "controlpanel/base.html" %}
|
||||
{% load static lucide %}
|
||||
|
||||
{% block logo %}
|
||||
{% if club.logo %}
|
||||
<img class="h-16 w-16 object-contain" src="{{ club.logo.url }}" alt="{{ club.name }}">
|
||||
{% else %}
|
||||
<div class="avatar avatar-placeholder">
|
||||
<div class="w-16 text-xl rounded-full bg-neutral text-neutral-content">
|
||||
<span>{{ club.initials }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock logo %}
|
||||
|
||||
{% block heading %}{{ club.name }}{% endblock heading %}
|
||||
|
||||
{% block subheading %}
|
||||
<p class="text-sm opacity-70">
|
||||
{{ club.slug }}
|
||||
{% if club.is_archived %}
|
||||
<span class="badge badge-warning badge-sm ml-2">Archived</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
{{ club.slug }}.rosterchief.app
|
||||
{% if club.is_archived %}
|
||||
<span class="badge badge-warning badge-sm ml-2">Archived</span>
|
||||
{% endif %}
|
||||
{% endblock subheading %}
|
||||
|
||||
{% block actions %}
|
||||
<a class="btn btn-ghost gap-2" href="{% url 'controlpanel:club_update' club.pk %}">{% lucide "pencil" size=16 %} Edit</a>
|
||||
<a class="btn btn-outline gap-2" href="{% url 'controlpanel:club_update' club.pk %}">{% lucide "pencil" size=16 %} Edit</a>
|
||||
<a class="btn btn-primary gap-2" href="https://{{ club.slug }}.rosterchief.app">{% lucide "external-link" size=16 %} Open</a>
|
||||
{% if club.is_archived %}
|
||||
<form method="post" action="{% url 'controlpanel:club_restore' club.pk %}">
|
||||
{% csrf_token %}
|
||||
@@ -30,6 +41,7 @@
|
||||
{% block panel %}
|
||||
{% if club.is_archived %}
|
||||
<div class="alert alert-warning mb-6">
|
||||
{% lucide "alert-triangle" size=20 %}
|
||||
<span>This club is archived: its subdomain no longer resolves. Nothing has been deleted — restore it to bring it back.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -47,96 +59,91 @@
|
||||
the club's setup, not a statistic: with nobody in a management position the access
|
||||
service grants no authority over that team, so nobody can pick the squad.
|
||||
{% 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.outstanding %}border-l-4 border-error{% endif %}">
|
||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-6">
|
||||
{% comment %}<div class="card bg-base-100 shadow {% if attention.outstanding %}border-l-4 border-error{% endif %}">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "banknote" size=16 %} Outstanding</div>
|
||||
<div class="text-3xl font-bold tabular-nums">€{{ attention.outstanding|floatformat:2 }}</div>
|
||||
<div class="text-xs opacity-60">{{ attention.unpaid_members }} member{{ attention.unpaid_members|pluralize }} unpaid this season</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow {% if attention.teams_without_manager %}border-l-4 border-error{% endif %}">
|
||||
</div>{% endcomment %}
|
||||
<div class="card bg-base-100 shadow border-l-4 {% if attention.teams_without_manager %}border-error{% else %}border-success{% endif %}">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "user-x" size=16 %} No coach</div>
|
||||
<div class="text-3xl font-bold tabular-nums">{{ attention.teams_without_manager }}</div>
|
||||
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-x" size=16 %} Teams without coach</div>
|
||||
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.teams_without_manager }}</div>
|
||||
<div class="text-xs opacity-60">Teams nobody can pick a squad for</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow {% if attention.unrostered %}border-l-4 border-warning{% endif %}">
|
||||
|
||||
<div class="card bg-base-100 shadow border-l-4 {% if attention.unrostered %}border-warning{% else %}border-success{% endif %}">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "user-minus" size=16 %} Unrostered</div>
|
||||
<div class="text-3xl font-bold tabular-nums">{{ attention.unrostered }}</div>
|
||||
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-minus" size=16 %} Unrostered members</div>
|
||||
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.unrostered }}</div>
|
||||
<div class="text-xs opacity-60">Active members on no team</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow {% if attention.pending_approvals %}border-l-4 border-warning{% endif %}">
|
||||
|
||||
<div class="card bg-base-100 shadow border-l-4 {% if attention.pending_approvals %}border-warning{% else %}border-success{% endif %}">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "clock" size=16 %} Pending</div>
|
||||
<div class="text-3xl font-bold tabular-nums">{{ attention.pending_approvals }}</div>
|
||||
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "clock" size=16 %} Pending</div>
|
||||
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.pending_approvals }}</div>
|
||||
<div class="text-xs opacity-60">Memberships awaiting approval</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 grid gap-4 lg:grid-cols-4">
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">{% lucide "sparkles" size=18 %} New members</h2>
|
||||
<div class="text-4xl font-bold tabular-nums">{{ attention.new_members }}</div>
|
||||
<div class="card bg-base-100 shadow border-l-4 border-info">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "sparkles" size=16 %} New members</div>
|
||||
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.new_members }}</div>
|
||||
{# First season at this club — someone returning after a year away is a renewal. #}
|
||||
<p class="text-sm opacity-70">first season at this club</p>
|
||||
<div class="text-xs opacity-60">First season at this club</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">{% lucide "repeat" size=18 %} Renewal</h2>
|
||||
{% if attention.renewal_rate is None %}
|
||||
{# No prior season to compare against: a first-season club has not failed to renew anyone. #}
|
||||
<p class="text-sm opacity-60">No previous season to compare against yet.</p>
|
||||
{% else %}
|
||||
<div class="text-4xl font-bold tabular-nums">{{ attention.renewal_rate }}%</div>
|
||||
<p class="text-sm opacity-70">of last season's active members signed up again</p>
|
||||
<progress class="progress progress-primary w-full" value="{{ attention.renewal_rate }}" max="100"></progress>
|
||||
{% endif %}
|
||||
<div class="card bg-base-100 shadow border-l-4 {% if attention.renewal_rate is None %}border-info{% elif attention.renewal_rate < 30 %}border-error{% elif attention.renewal_rate < 65 %}border-warning{% else %}border-success{% endif %}">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "repeat" size=16 %} Renewal rate</div>
|
||||
<div class="text-4xl font-bold tabular-nums font-mono">
|
||||
{% if attention.renewal_rate is None %}
|
||||
N/A
|
||||
{% else %}
|
||||
{{ attention.renewal_rate }}%
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-xs opacity-60">
|
||||
{% if attention.renewal_rate is None %}
|
||||
No previous season
|
||||
{% else %}
|
||||
<progress class="progress w-full {% if attention.renewal_rate < 30 %}progress-error{% elif attention.renewal_rate < 65 %}progress-warning{% else %}progress-success{% endif %}" value="{{ attention.renewal_rate }}"
|
||||
max="100"></progress>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">{% lucide "user-check" size=18 %} Attendance</h2>
|
||||
{% if attention.attendance.turnout is None %}
|
||||
<p class="text-sm opacity-60">No past events with responses this season.</p>
|
||||
{% else %}
|
||||
<div class="text-4xl font-bold tabular-nums">{{ attention.attendance.turnout }}%</div>
|
||||
<p class="text-sm opacity-70">turnout of those who answered</p>
|
||||
<p class="mt-2 text-sm">
|
||||
{# The leading indicator: it measures whether members use the app at all. #}
|
||||
<span class="font-semibold {% if attention.attendance.no_response > 30 %}text-warning{% endif %}">{{ attention.attendance.no_response }}%</span>
|
||||
<span class="opacity-70">never responded</span>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">{% lucide "hourglass" size=18 %} Unpaid, by age</h2>
|
||||
<table class="table table-sm">
|
||||
<tbody>
|
||||
{% for bucket in attention.aging %}
|
||||
<tr>
|
||||
<td class="{% if bucket.overdue and bucket.total %}font-semibold text-error{% endif %}">{{ bucket.label }}</td>
|
||||
<td class="text-right tabular-nums">€{{ bucket.total|floatformat:2 }}</td>
|
||||
<td class="text-right opacity-60">{{ bucket.count }} order{{ bucket.count|pluralize }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="card bg-base-100 shadow border-l-4 {% if attention.attendance.turnout is None %}border-info{% elif attention.attendance.turnout < 30 %}border-error{% elif attention.attendance.turnout < 65 %}border-warning{% else %}border-success{% endif %}">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-check" size=16 %} Attendance rate</div>
|
||||
<div class="text-4xl font-bold tabular-nums font-mono">
|
||||
{% if attention.attendance.turnout is None %}
|
||||
N/A
|
||||
{% else %}
|
||||
{{ attention.attendance.turnout }}%
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-xs opacity-60">
|
||||
{% if attention.attendance.turnout is None %}
|
||||
No events this season
|
||||
{% else %}
|
||||
<progress class="progress w-full {% if attention.attendance.turnout < 30 %}progress-error{% elif attention.attendance.turnout < 65 %}progress-warning{% else %}progress-success{% endif %}"
|
||||
value="{{ attention.attendance.turnout }}" max="100"></progress>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mb-6 grid gap-4 lg:grid-cols-2">
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
@@ -149,7 +156,7 @@
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">{% lucide "wallet" size=18 %} Fee status this season</h2>
|
||||
<h2 class="card-title text-base">{% lucide "wallet" size=18 %} Club fee status this season</h2>
|
||||
<div class="h-56">
|
||||
<canvas id="fees-chart"></canvas>
|
||||
</div>
|
||||
@@ -157,7 +164,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 grid gap-4 md:grid-cols-2">
|
||||
<div class="mb-6 grid gap-4 md:grid-cols-4">
|
||||
{% for group in groups %}
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
@@ -166,7 +173,7 @@
|
||||
{% for label, value in group.stats %}
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<dt class="text-sm opacity-70">{{ label }}</dt>
|
||||
<dd class="font-semibold tabular-nums">{{ value }}</dd>
|
||||
<dd class="font-semibold tabular-nums font-mono">{% if group.title == "Shop" and label == "Outstanding" or label == "Revenue" %}€{% endif %}{{ value }}</dd>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
@@ -174,178 +181,9 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="card mb-6 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 %} Features</h2>
|
||||
<a class="btn btn-ghost btn-xs" href="{% url 'controlpanel:features' %}">Manage features</a>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
{% for entry in flags %}
|
||||
<tr>
|
||||
<td class="font-mono font-medium">{{ entry.flag.name }}</td>
|
||||
<td class="opacity-70">{{ entry.flag.note|default:"—" }}</td>
|
||||
<td class="text-right">
|
||||
{% if entry.overridden %}
|
||||
{# `everyone` overrides club targeting, so a per-club toggle would be a lie. #}
|
||||
<span class="badge {% if entry.flag.everyone %}badge-success{% else %}badge-error{% endif %}">
|
||||
{% if entry.flag.everyone %}On for all clubs{% else %}Off everywhere{% endif %}
|
||||
</span>
|
||||
{% else %}
|
||||
<form method="post" action="{% url 'controlpanel:club_feature_toggle' club.pk entry.flag.pk %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-sm gap-1 {% if entry.enabled %}btn-success{% else %}btn-ghost{% endif %}" type="submit">
|
||||
{% if entry.enabled %}{% lucide "toggle-right" size=16 %} On{% else %}{% lucide "toggle-left" size=16 %} Off{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td class="text-center opacity-60">No features defined yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-6 bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 class="card-title text-base">{% lucide "receipt-euro" size=18 %} Billing</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a class="btn btn-outline btn-sm gap-2" href="{% url 'controlpanel:club_subscribe' club.pk %}">
|
||||
{% lucide "layers" size=14 %} {% if subscription %}Change plan{% else %}Start billing{% endif %}
|
||||
</a>
|
||||
{% if subscription %}
|
||||
<a class="btn btn-primary btn-sm gap-2" href="{% url 'controlpanel:club_open_period' club.pk %}">
|
||||
{% lucide "calendar-plus" size=14 %} {% if club.is_archived %}Reactivate{% else %}Open period{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not subscription %}
|
||||
<p class="text-sm opacity-70">This club is not billed for anything. Put it on a tier to start.</p>
|
||||
{% else %}
|
||||
<p class="text-sm opacity-70">
|
||||
On <strong>{{ subscription.tier.name }}</strong>.
|
||||
{% if subscription.auto_archive %}
|
||||
Archived automatically when a period goes unpaid past its grace period.
|
||||
{% else %}
|
||||
<span class="badge badge-warning badge-sm">Auto-archive off</span> — it will never be archived for non-payment.
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Period</th>
|
||||
<th class="text-right">Billed</th>
|
||||
<th class="text-right">Paid</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for due in dues %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ due.period_start|date:"j M Y" }} — {{ due.period_end|date:"j M Y" }}
|
||||
<div class="text-xs opacity-60">{{ due.tier.name }} · {{ due.invoice.number }} · grace to {{ due.grace_until|date:"j M Y" }}</div>
|
||||
</td>
|
||||
<td class="text-right tabular-nums">€{{ due.amount|floatformat:2 }}</td>
|
||||
<td class="text-right tabular-nums">€{{ due.amount_paid|floatformat:2 }}</td>
|
||||
<td>
|
||||
{% if due.status == "paid" %}
|
||||
<span class="badge badge-success gap-1">{% lucide "check" size=12 %} Paid</span>
|
||||
{% elif due.status == "waived" %}
|
||||
<span class="badge badge-ghost">Waived</span>
|
||||
{% elif due.grace_until < today %}
|
||||
<span class="badge badge-error gap-1">{% lucide "triangle-alert" size=12 %} Overdue</span>
|
||||
{% elif due.period_end < today %}
|
||||
<span class="badge badge-warning gap-1">{% lucide "hourglass" size=12 %} In grace</span>
|
||||
{% else %}
|
||||
<span class="badge badge-ghost">{{ due.get_status_display }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{% if due.is_owing %}
|
||||
<a class="btn btn-primary btn-xs gap-1" href="{% url 'controlpanel:due_pay' due.pk %}">{% lucide "banknote" size=14 %} Pay</a>
|
||||
{% if not due.payments.all %}
|
||||
<form class="inline" method="post" action="{% url 'controlpanel:due_waive' due.pk %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-ghost btn-xs gap-1" type="submit">{% lucide "ban" size=14 %} Waive</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<a class="btn btn-ghost btn-xs gap-1" href="{% url 'controlpanel:due_invoice' due.pk %}">{% lucide "file-text" size=14 %} Invoice</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% for payment in due.payments.all %}
|
||||
<tr class="text-xs opacity-70">
|
||||
<td colspan="2" class="pl-8">
|
||||
{% lucide "corner-down-right" size=12 %}
|
||||
{{ payment.paid_at|date:"j M Y" }} · {{ payment.get_method_display }}{% if payment.reference %} · {{ payment.reference }}{% endif %}
|
||||
</td>
|
||||
<td class="text-right tabular-nums">€{{ payment.amount|floatformat:2 }}</td>
|
||||
<td colspan="2"></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center opacity-60">No periods billed yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</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">Club admins</h2>
|
||||
<a class="btn btn-primary btn-sm gap-2" href="{% url 'controlpanel:club_admin_add' club.pk %}">{% lucide "user-plus" size=16 %} Add admin</a>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for role in admins %}
|
||||
<tr>
|
||||
<td>{{ role.member }}</td>
|
||||
<td>{{ role.member.user.email|default:"—" }}</td>
|
||||
<td class="text-right">
|
||||
<form method="post" action="{% url 'controlpanel:club_admin_remove' club.pk role.pk %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-ghost btn-xs gap-1 text-error" type="submit">{% lucide "trash-2" size=14 %} Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-center opacity-60">No admins yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "controlpanel/_club_features_card.html" %}
|
||||
{% include "controlpanel/_club_billing_card.html" %}
|
||||
{% include "controlpanel/_club_admins_card.html" %}
|
||||
{% endblock panel %}
|
||||
|
||||
{% block extra_body %}
|
||||
@@ -367,17 +205,17 @@
|
||||
data: {
|
||||
labels: data.signups.map((point) => point.month),
|
||||
datasets: [
|
||||
{ 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: "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")},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: "bottom", labels: { color: ink } } },
|
||||
plugins: {legend: {position: "bottom", labels: {color: ink}}},
|
||||
scales: {
|
||||
x: { stacked: true, ticks: { color: ink }, grid: { color: grid } },
|
||||
y: { stacked: true, beginAtZero: true, ticks: { color: ink, precision: 0 }, grid: { color: grid } },
|
||||
x: {stacked: true, ticks: {color: ink}, grid: {color: grid}},
|
||||
y: {stacked: true, beginAtZero: true, ticks: {color: ink, precision: 0}, grid: {color: grid}},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -385,7 +223,7 @@
|
||||
// Colour carries the meaning here — unpaid must read as a problem, waived must
|
||||
// not — so the slices are pinned to the semantic theme colours, in order.
|
||||
const fees = new Chart(document.getElementById("fees-chart"), {
|
||||
type: "doughnut",
|
||||
type: "pie",
|
||||
data: {
|
||||
labels: data.fees.map((slice) => slice.label),
|
||||
datasets: [
|
||||
@@ -398,7 +236,7 @@
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: "right", labels: { color: ink } } },
|
||||
plugins: {legend: {position: "right", labels: {color: ink}}},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -411,7 +249,7 @@
|
||||
new MutationObserver(() => {
|
||||
charts.forEach((chart) => chart.destroy());
|
||||
charts = render();
|
||||
}).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
|
||||
}).observe(document.documentElement, {attributes: true, attributeFilter: ["data-theme"]});
|
||||
})();
|
||||
</script>
|
||||
{% endblock extra_body %}
|
||||
|
||||
@@ -4,28 +4,33 @@
|
||||
{% block heading %}{% if object %}Edit {{ object }}{% else %}New club{% endif %}{% endblock heading %}
|
||||
|
||||
{% block panel %}
|
||||
<div class="card max-w-xl bg-base-100 shadow">
|
||||
<div class="card w-full bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
{% for error in form.non_field_errors %}
|
||||
<div class="alert alert-error my-2">
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% for field in form %}
|
||||
<div class="form-control my-3 w-full">
|
||||
<label class="label" for="{{ field.id_for_label }}">
|
||||
<span class="label-text">{{ field.label }}</span>
|
||||
</label>
|
||||
{{ field|daisy }}
|
||||
{% if field.help_text %}<span class="label-text-alt mt-1 text-base-content/70">{{ field.help_text }}</span>{% endif %}
|
||||
{% for error in field.errors %}<span class="label-text-alt mt-1 text-error">{{ error }}</span>{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="card-actions justify-end pt-2">
|
||||
<a class="btn btn-outline gap-2" href="{% url 'controlpanel:club_list' %}">{% lucide "arrow-left" size=16 %} Cancel</a>
|
||||
<button class="btn btn-primary" type="submit">Save</button>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{% for field in form %}
|
||||
<div class="my-3 w-full">
|
||||
<label class="label" for="{{ field.id_for_label }}">
|
||||
<span class="label-text">{{ field.label }}</span>
|
||||
</label>
|
||||
{{ field|daisy }}
|
||||
{% if field.help_text and not field.errors %}<span class="label-text-alt mt-1 text-base-content/70">{{ field.help_text }}</span>{% endif %}
|
||||
{% for error in field.errors %}<span class="label-text-alt mt-1 text-error">{{ error }}</span>{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-start pt-2 mt-2">
|
||||
<a class="btn btn-outline gap-2" href="{% if update_view %}{% url "controlpanel:club_detail" object.pk %}{% else %}{% url "controlpanel:club_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} Cancel</a>
|
||||
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
|
||||
{% block actions %}
|
||||
{% if show_archived %}
|
||||
<a class="btn btn-ghost" href="{% url 'controlpanel:club_list' %}">Active clubs</a>
|
||||
<a class="btn btn-outline" href="{% url 'controlpanel:club_list' %}">{% lucide "archive-x" size=16 %} Hide archived clubs</a>
|
||||
{% else %}
|
||||
<a class="btn btn-ghost" href="{% url 'controlpanel:club_list' %}?archived=1">Archived</a>
|
||||
<a class="btn btn-outline" href="{% url 'controlpanel:club_list' %}?archived=1">{% lucide "archive" size=16 %} Show archived clubs</a>
|
||||
{% endif %}
|
||||
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:club_create' %}">{% lucide "plus" size=16 %} New club</a>
|
||||
{% endblock actions %}
|
||||
@@ -15,12 +15,19 @@
|
||||
{% block panel %}
|
||||
<form method="get" class="mb-4 flex gap-2">
|
||||
{% if show_archived %}<input type="hidden" name="archived" value="1">{% endif %}
|
||||
<input type="search"
|
||||
name="q"
|
||||
value="{{ search }}"
|
||||
placeholder="Search clubs…"
|
||||
class="input input-bordered w-full max-w-xs">
|
||||
<button class="btn gap-2" type="submit">{% lucide "search" size=16 %} Search</button>
|
||||
<label class="input">
|
||||
<span class="opacity-50">{% lucide "search" size=16 %}</span>
|
||||
<input type="search"
|
||||
name="q"
|
||||
value="{{ search }}"
|
||||
placeholder="Search clubs…"
|
||||
class="input input-bordered w-full max-w-xs">
|
||||
</label>
|
||||
|
||||
<button class="btn btn-outline gap-2" type="submit">{% lucide "search" size=16 %} Search</button>
|
||||
{% if search %}
|
||||
<a class="btn btn-primary gap-2" href="{% url "controlpanel:club_list" %}">{% lucide "x" size=16 %} Clear filter</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
|
||||
@@ -2,52 +2,70 @@
|
||||
{% load static lucide %}
|
||||
|
||||
{% 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 %}
|
||||
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:club_create' %}">{% lucide "plus" size=16 %} Create new club</a>
|
||||
{% endblock actions %}
|
||||
|
||||
{% block panel %}
|
||||
{% comment %}
|
||||
Needs attention first: these are the numbers that are supposed to be zero. A club with
|
||||
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="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-6">
|
||||
<div class="card bg-base-100 shadow border-l-4 border-info">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "calendar-x" size=16 %} No current season</div>
|
||||
<div class="text-3xl font-bold tabular-nums">{{ attention.clubs_without_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-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>
|
||||
</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="flex items-center gap-2 text-sm opacity-70">{% lucide "moon-star" size=16 %} Dormant</div>
|
||||
<div class="text-3xl font-bold tabular-nums">{{ attention.dormant_clubs }}</div>
|
||||
<div class="text-xs opacity-60">Nothing scheduled in 30 days</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-4xl font-bold tabular-nums font-mono">{{ attention.dormant_clubs }}</div>
|
||||
<div class="text-xs opacity-60">No events scheduled next 30 days</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="flex items-center gap-2 text-sm opacity-70">{% lucide "shield-alert" size=16 %} MFA pending</div>
|
||||
<div class="text-3xl font-bold tabular-nums">{{ attention.admins_pending_mfa }}</div>
|
||||
<div class="text-xs opacity-60">Admins locked out until they enrol</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-4xl font-bold tabular-nums font-mono">{{ attention.admins_pending_mfa }}</div>
|
||||
<div class="text-xs opacity-60">Admins without MFA configured</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">
|
||||
{% comment %}
|
||||
What the CLUBS owe US. Not to be confused with the club-shop money below,
|
||||
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="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "receipt-euro" size=16 %} Payment pending</div>
|
||||
<div class="text-4xl font-bold tabular-nums font-mono">€{{ attention.dues_owed|floatformat:2 }}</div>
|
||||
<div class="text-xs opacity-60">
|
||||
{{ attention.dues_in_grace }} in grace ·
|
||||
<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>
|
||||
@@ -63,99 +81,30 @@
|
||||
</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-body">
|
||||
<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">
|
||||
{% for step in funnel %}
|
||||
<div>
|
||||
<div class="mb-1 flex items-center justify-between text-sm">
|
||||
<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 font-mono">{{ step.count }}</span>
|
||||
</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>
|
||||
{% endfor %}
|
||||
</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 class="card bg-base-100 shadow">
|
||||
<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" %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -219,22 +168,22 @@
|
||||
data: {
|
||||
labels: data.signups.map((point) => point.month),
|
||||
datasets: [
|
||||
{ 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: "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")},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: "bottom", labels: { color: ink } } },
|
||||
plugins: {legend: {position: "bottom", labels: {color: ink}}},
|
||||
scales: {
|
||||
x: { stacked: true, ticks: { color: ink }, grid: { color: grid } },
|
||||
y: { stacked: true, beginAtZero: true, ticks: { color: ink, precision: 0 }, grid: { color: grid } },
|
||||
x: {stacked: true, ticks: {color: ink}, 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();
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
{% block heading %}Features{% endblock heading %}
|
||||
|
||||
{% block actions %}
|
||||
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:flag_create' %}">{% lucide "plus" size=16 %} New feature</a>
|
||||
<button class="btn btn-primary gap-2" type="button" onclick="document.getElementById('flag_create_modal').showModal()">{% lucide "plus" size=16 %} New feature</button>
|
||||
{% endblock actions %}
|
||||
|
||||
{% block panel %}
|
||||
{% url 'controlpanel:flag_create' as flag_create_url %}
|
||||
{% include "controlpanel/_modal_form.html" with modal_id="flag_create_modal" title="New feature" form=flag_form action_url=flag_create_url submit_label="Create" submit_icon="plus" %}
|
||||
|
||||
{% comment %}
|
||||
The lock-down. Clubs get a maintenance page, the scheduled jobs stand down, and the
|
||||
control panel and the auth screens stay open — otherwise you could not sign in to
|
||||
@@ -16,14 +19,21 @@
|
||||
<div class="card mb-6 bg-base-100 shadow {% if maintenance.is_active %}border-l-4 border-error{% endif %}">
|
||||
<div class="card-body">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="card-title text-base">{% lucide "wrench" size=18 %} Maintenance mode</h2>
|
||||
<div class="w-full">
|
||||
<h2 class="card-title text-base mb-2">{% lucide "wrench" size=18 %} Maintenance mode</h2>
|
||||
{% if maintenance.is_active %}
|
||||
<p class="text-sm">
|
||||
<div class="flex flex-row gap-2 text-sm">
|
||||
<span class="badge badge-error gap-1">{% lucide "lock" size=12 %} Platform closed</span>
|
||||
since {{ maintenance.started_at|date:"j M Y, H:i" }}{% if maintenance.started_by %} by {{ maintenance.started_by.email }}{% endif %}.
|
||||
</p>
|
||||
{% if maintenance.message %}<p class="mt-1 text-sm opacity-70">“{{ maintenance.message }}”</p>{% endif %}
|
||||
<div>·</div>
|
||||
<div>since {{ maintenance.started_at|date:"j M Y, H:i" }}{% if maintenance.started_by %} by {{ maintenance.started_by.email }}{% endif %}</div>
|
||||
</div>
|
||||
|
||||
{% if maintenance.message %}
|
||||
<div class="my-4">
|
||||
<div class="font-semibold mb-1">Message</div>
|
||||
<div class="p-4 bg-base-300 border-l-4 border-info w-full font-mono">{{ maintenance.message }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-sm opacity-70">
|
||||
Closes every club subdomain and stands the scheduled jobs down. The control panel and the sign-in screens stay open.
|
||||
@@ -35,12 +45,12 @@
|
||||
<form class="mt-2" method="post" action="{% url 'controlpanel:maintenance' %}">
|
||||
{% csrf_token %}
|
||||
{% if not maintenance.is_active %}
|
||||
<div class="form-control my-2 w-full max-w-xl">
|
||||
<div class="form-control my-2 w-full pb-2">
|
||||
<label class="label" for="{{ maintenance_form.message.id_for_label }}">
|
||||
<span class="label-text">{{ maintenance_form.message.label }}</span>
|
||||
</label>
|
||||
{{ maintenance_form.message|daisy }}
|
||||
<span class="label-text-alt mt-1 block text-base-content/70">{{ maintenance_form.message.help_text }}</span>
|
||||
<span class="label-text-alt mt-1 block text-xs text-base-content/70">{{ maintenance_form.message.help_text }}</span>
|
||||
</div>
|
||||
<button class="btn btn-error gap-2" type="submit">{% lucide "lock" size=16 %} Close the platform</button>
|
||||
{% else %}
|
||||
@@ -77,13 +87,13 @@
|
||||
{% elif flag.everyone is False %}
|
||||
<span class="badge badge-error">Off everywhere</span>
|
||||
{% else %}
|
||||
<span class="badge badge-ghost">Per club</span>
|
||||
<span class="badge badge-info">Per club</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ flag.clubs.count }}</td>
|
||||
<td class="max-w-xs truncate opacity-70">{{ flag.note|default:"—" }}</td>
|
||||
<td class="max-w-xs truncate opacity-70">{{ flag.note|default:"-" }}</td>
|
||||
<td class="text-right">
|
||||
<a class="btn btn-ghost btn-xs gap-1" href="{% url 'controlpanel:flag_update' flag.pk %}">{% lucide "pencil" size=14 %} Edit</a>
|
||||
<button class="btn btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ flag.pk|dom_id:"flag_edit_modal" }}').showModal()">{% lucide "pencil" size=14 %} Edit</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
@@ -94,6 +104,12 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
|
||||
{% for flag in flags %}
|
||||
{% url 'controlpanel:flag_update' flag.pk as flag_update_url %}
|
||||
{% include "controlpanel/_modal_form.html" with modal_id=flag.pk|dom_id:"flag_edit_modal" title="Edit "|add:flag.name form=flag.edit_form action_url=flag_update_url submit_label="Save" submit_icon="check" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow">
|
||||
@@ -106,7 +122,7 @@
|
||||
{% for switch in switches %}
|
||||
<tr>
|
||||
<td class="font-mono font-medium">{{ switch.name }}</td>
|
||||
<td class="opacity-70">{{ switch.note|default:"—" }}</td>
|
||||
<td class="opacity-70">{{ switch.note|default:"-" }}</td>
|
||||
<td class="text-right">
|
||||
<form method="post" action="{% url 'controlpanel:switch_toggle' switch.pk %}">
|
||||
{% csrf_token %}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
{% extends "controlpanel/base.html" %}
|
||||
{% load lucide ui %}
|
||||
|
||||
{% block heading %}{% if object %}Edit {{ object.name }}{% else %}New feature{% endif %}{% endblock heading %}
|
||||
|
||||
{% block panel %}
|
||||
<div class="card max-w-xl bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{% for error in form.non_field_errors %}
|
||||
<div class="alert alert-error my-2">
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% for field in form %}
|
||||
<div class="form-control my-3 w-full">
|
||||
<label class="label" for="{{ field.id_for_label }}">
|
||||
<span class="label-text">{{ field.label }}</span>
|
||||
</label>
|
||||
{{ field|daisy }}
|
||||
{% if field.help_text %}<span class="label-text-alt mt-1 text-base-content/70">{{ field.help_text }}</span>{% endif %}
|
||||
{% for error in field.errors %}<span class="label-text-alt mt-1 text-error">{{ error }}</span>{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="card-actions justify-end pt-2">
|
||||
<a class="btn btn-outline gap-2" href="{% url 'controlpanel:features' %}">{% lucide "arrow-left" size=16 %} Cancel</a>
|
||||
<button class="btn btn-primary" type="submit">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock panel %}
|
||||
@@ -1,14 +0,0 @@
|
||||
{% extends "controlpanel/base.html" %}
|
||||
|
||||
{% block heading %}Record payment — {{ due.club }}{% endblock heading %}
|
||||
|
||||
{% block subheading %}
|
||||
<p class="text-sm opacity-70">
|
||||
{{ due.period_start|date:"j M Y" }} to {{ due.period_end|date:"j M Y" }} · €{{ due.amount|floatformat:2 }} billed · €{{ due.balance|floatformat:2 }} outstanding
|
||||
</p>
|
||||
{% endblock subheading %}
|
||||
|
||||
{% block panel %}
|
||||
{% url 'controlpanel:club_detail' due.club.pk as club_url %}
|
||||
{% include "controlpanel/_billing_form.html" with cancel_url=club_url submit_label="Record payment" submit_icon="banknote" blurb="Part payments are fine: they accumulate against the period until it is settled." %}
|
||||
{% endblock panel %}
|
||||
@@ -1,12 +0,0 @@
|
||||
{% extends "controlpanel/base.html" %}
|
||||
|
||||
{% block heading %}{% if club.is_archived %}Reactivate{% else %}Open period{% endif %} — {{ club }}{% endblock heading %}
|
||||
|
||||
{% block subheading %}
|
||||
<p class="text-sm opacity-70">Next period starts {{ next_start|date:"j M Y" }} unless you say otherwise.</p>
|
||||
{% endblock subheading %}
|
||||
|
||||
{% block panel %}
|
||||
{% url 'controlpanel:club_detail' club.pk as club_url %}
|
||||
{% include "controlpanel/_billing_form.html" with cancel_url=club_url submit_label="Open period" submit_icon="calendar-plus" blurb="By default the period continues from the end of the last one, so a lapsed year is still owed. Pick a start date to forgive the gap." %}
|
||||
{% endblock panel %}
|
||||
@@ -1,8 +0,0 @@
|
||||
{% extends "controlpanel/base.html" %}
|
||||
|
||||
{% block heading %}{% if subscription %}Change plan{% else %}Start billing{% endif %} — {{ club }}{% endblock heading %}
|
||||
|
||||
{% block panel %}
|
||||
{% url 'controlpanel:club_detail' club.pk as club_url %}
|
||||
{% include "controlpanel/_billing_form.html" with cancel_url=club_url submit_label="Save plan" submit_icon="layers" blurb="Changing tier does not re-bill: the current period keeps the amount it was issued at, and the new rate applies from the next one." %}
|
||||
{% endblock panel %}
|
||||
@@ -1,8 +0,0 @@
|
||||
{% extends "controlpanel/base.html" %}
|
||||
|
||||
{% block heading %}{% if object %}Edit {{ object }}{% else %}New tier{% endif %}{% endblock heading %}
|
||||
|
||||
{% block panel %}
|
||||
{% url 'controlpanel:billing' as billing_url %}
|
||||
{% include "controlpanel/_billing_form.html" with cancel_url=billing_url submit_label="Save tier" submit_icon="layers" blurb="A tier has no price until you add one. A tier with no price cannot be billed." %}
|
||||
{% endblock panel %}
|
||||
@@ -1,8 +0,0 @@
|
||||
{% extends "controlpanel/base.html" %}
|
||||
|
||||
{% block heading %}New price for {{ tier }}{% endblock heading %}
|
||||
|
||||
{% block panel %}
|
||||
{% url 'controlpanel:billing' as billing_url %}
|
||||
{% include "controlpanel/_billing_form.html" with cancel_url=billing_url submit_label="Add price" submit_icon="euro" blurb="Prices are dated, never edited: periods already opened keep the amount they were billed at, so this cannot rewrite an invoice you have already sent." %}
|
||||
{% endblock panel %}
|
||||
@@ -18,11 +18,11 @@ DEFAULT_WIDGET_CLASS = "input input-bordered w-full"
|
||||
|
||||
#: Icon, default heading and daisyUI colour per message level.
|
||||
MESSAGE_ALERTS = {
|
||||
"debug": ("bug", "Debug", "alert-info"),
|
||||
"info": ("info", "Heads up", "alert-info"),
|
||||
"success": ("circle-check", "Done", "alert-success"),
|
||||
"warning": ("triangle-alert", "Careful", "alert-warning"),
|
||||
"error": ("circle-x", "Something went wrong", "alert-error"),
|
||||
"debug": ("bug", "Debug", "alert-info border-info"),
|
||||
"info": ("info", "Heads up", "alert-info border-info"),
|
||||
"success": ("circle-check", "Done", "alert-success border-success"),
|
||||
"warning": ("triangle-alert", "Careful", "alert-warning border-warning"),
|
||||
"error": ("circle-x", "Something went wrong", "alert-error border-error"),
|
||||
}
|
||||
DEFAULT_MESSAGE_ALERT = MESSAGE_ALERTS["info"]
|
||||
|
||||
@@ -32,10 +32,11 @@ def as_alert(message):
|
||||
"""Presentation for one Django message: icon, bold title, body, colour.
|
||||
|
||||
Django messages carry a level and a string — there is no title field — so the
|
||||
title comes from the level, and a call site that wants a specific one passes it
|
||||
as ``extra_tags``::
|
||||
title comes from the level, unless the message carries one as ``extra_tags``.
|
||||
Call sites queue messages with ``notify`` (controlpanel/messages.py), which sets
|
||||
exactly that from a compact ``"<level>|<title>|<body>"`` spec::
|
||||
|
||||
messages.success(request, f"{club} is live.", extra_tags="Club created")
|
||||
notify(request, f"s|Club created|{club} is live.")
|
||||
|
||||
Keyed on ``level_tag``, never ``tags``: ``tags`` is extra_tags and level_tag
|
||||
joined, so a message carrying a custom title would stop matching its own level
|
||||
@@ -99,6 +100,17 @@ def excluded(field, names):
|
||||
return field.name in (names or "").split(",")
|
||||
|
||||
|
||||
@register.filter
|
||||
def dom_id(pk, prefix):
|
||||
"""A stable per-row DOM id, e.g. ``{{ due.pk|dom_id:"due_pay_modal" }}``.
|
||||
|
||||
Django templates cannot concatenate a string literal with a non-string filter
|
||||
argument directly (``"prefix_"|add:some_uuid`` raises), which is what a per-row modal
|
||||
id needs. This is the one place that builds one, so every call site reads the same way.
|
||||
"""
|
||||
return f"{prefix}_{pk}"
|
||||
|
||||
|
||||
@register.filter
|
||||
def daisy(field, css=None):
|
||||
"""Render a bound form field with the right daisyUI classes.
|
||||
|
||||
@@ -9,15 +9,16 @@ from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.messages.storage.base import Message
|
||||
from django.contrib.messages.storage.fallback import FallbackStorage
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase, override_settings
|
||||
from django.test import RequestFactory, TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from waffle import get_waffle_flag_model, get_waffle_switch_model
|
||||
|
||||
from billing.models import GRACE_DAYS, Due, Tier, TierPrice
|
||||
from billing.services import BillingError
|
||||
from billing.services.dues import record_payment, subscribe
|
||||
from billing.services.dues import record_payment, subscribe, waive
|
||||
from club.models import Club, ClubMembership, ClubRole, Season
|
||||
from events.models import Attendance, Event
|
||||
from features.models import Maintenance
|
||||
@@ -25,6 +26,7 @@ from members.models import Member
|
||||
from shop.models import Order
|
||||
from teams.models import Position, StaffAssignment, Team, TeamMembership
|
||||
|
||||
from .messages import LEVELS, notify
|
||||
from .services.admins import grant_club_admin
|
||||
from .services.platform_admins import PlatformAdminError, is_last_superuser, set_platform_access
|
||||
from .services.statistics import (
|
||||
@@ -175,11 +177,18 @@ class ClubAdminManagementTests(ControlPanelTestBase):
|
||||
self.assertEqual(ClubRole.objects.get(club=self.club, member__user=user).role, ClubRole.Roles.ADMIN)
|
||||
|
||||
def test_a_new_email_must_come_with_a_name(self):
|
||||
response = self.add_admin(email="nameless@example.com", first_name="", last_name="")
|
||||
# Reachable only via the "Add admin" modal on the club detail page, so a rejected
|
||||
# submission bounces back there with the error as a message.
|
||||
response = self.client.post(reverse("controlpanel:club_admin_add", args=[self.club.pk]), {"email": "nameless@example.com", "first_name": "", "last_name": ""}, follow=True)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertRedirects(response, reverse("controlpanel:club_detail", args=[self.club.pk]))
|
||||
self.assertFalse(ClubRole.objects.exists())
|
||||
self.assertFormError(response.context["form"], "first_name", "Required: this email has no account yet.")
|
||||
self.assertContains(response, "Required: this email has no account yet.")
|
||||
|
||||
def test_add_admin_is_post_only(self):
|
||||
response = self.client.get(reverse("controlpanel:club_admin_add", args=[self.club.pk]))
|
||||
|
||||
self.assertEqual(response.status_code, 405)
|
||||
|
||||
def test_an_existing_member_is_promoted_rather_than_duplicated(self):
|
||||
user = User.objects.create_user(email="existing@example.com", password="pw-secret-123")
|
||||
@@ -315,8 +324,10 @@ class PlatformAdminTests(TestCase):
|
||||
def test_superuser_sees_the_admins_section(self):
|
||||
self.assertEqual(self.client.get(reverse("controlpanel:admins")).status_code, 200)
|
||||
|
||||
def test_the_grant_form_renders(self):
|
||||
self.assertEqual(self.client.get(reverse("controlpanel:admin_add")).status_code, 200)
|
||||
def test_the_grant_form_is_post_only(self):
|
||||
# Reachable only via the "Grant access" modal on the admins page: there is no
|
||||
# standalone template to render on a GET.
|
||||
self.assertEqual(self.client.get(reverse("controlpanel:admin_add")).status_code, 405)
|
||||
|
||||
def test_grant_access_to_a_new_email_creates_a_staff_account(self):
|
||||
self.client.post(reverse("controlpanel:admin_add"), {"email": "New.Admin@Example.com"})
|
||||
@@ -407,9 +418,17 @@ class FeatureViewTests(ControlPanelTestBase):
|
||||
self.assertContains(response, "shop")
|
||||
self.assertContains(response, "maintenance")
|
||||
|
||||
def test_the_flag_forms_render(self):
|
||||
self.assertEqual(self.client.get(reverse("controlpanel:flag_create")).status_code, 200)
|
||||
self.assertEqual(self.client.get(reverse("controlpanel:flag_update", args=[self.flag.pk])).status_code, 200)
|
||||
def test_the_flag_forms_are_post_only(self):
|
||||
# Reachable only through a modal on the features page: there is no standalone
|
||||
# template to render on a GET.
|
||||
self.assertEqual(self.client.get(reverse("controlpanel:flag_create")).status_code, 405)
|
||||
self.assertEqual(self.client.get(reverse("controlpanel:flag_update", args=[self.flag.pk])).status_code, 405)
|
||||
|
||||
def test_an_invalid_flag_submission_redirects_with_a_message(self):
|
||||
response = self.client.post(reverse("controlpanel:flag_create"), {"name": "", "note": "", "percent": "", "everyone": ""}, follow=True)
|
||||
|
||||
self.assertRedirects(response, reverse("controlpanel:features"))
|
||||
self.assertContains(response, "This field is required")
|
||||
|
||||
def test_create_a_flag(self):
|
||||
self.client.post(reverse("controlpanel:flag_create"), {"name": "news", "note": "News module", "percent": "", "everyone": ""})
|
||||
@@ -456,33 +475,75 @@ class FeatureViewTests(ControlPanelTestBase):
|
||||
self.assertNotContains(response, reverse("controlpanel:club_feature_toggle", args=[self.club.pk, self.flag.pk]))
|
||||
|
||||
|
||||
class NotifyTests(TestCase):
|
||||
def request(self):
|
||||
request = RequestFactory().get("/")
|
||||
request.session = {}
|
||||
storage = FallbackStorage(request)
|
||||
request._messages = storage
|
||||
return request, storage
|
||||
|
||||
def test_splits_level_title_and_body(self):
|
||||
request, storage = self.request()
|
||||
|
||||
notify(request, "s|Club created|Ajax United is live.")
|
||||
|
||||
[message] = list(storage)
|
||||
self.assertEqual(message.level, messages.SUCCESS)
|
||||
self.assertEqual(message.extra_tags, "Club created")
|
||||
self.assertEqual(message.message, "Ajax United is live.")
|
||||
|
||||
def test_maps_every_level_code_to_its_django_level(self):
|
||||
self.assertEqual(LEVELS, {"s": messages.SUCCESS, "i": messages.INFO, "w": messages.WARNING, "e": messages.ERROR, "d": messages.DEBUG})
|
||||
|
||||
def test_a_pipe_inside_the_body_is_preserved_intact(self):
|
||||
# maxsplit=2 stops after the level and the title, so a "|" a club/tier/flag name
|
||||
# might contain stays part of the body rather than truncating it.
|
||||
request, storage = self.request()
|
||||
|
||||
notify(request, "s|Title|Before | after.")
|
||||
|
||||
[message] = list(storage)
|
||||
self.assertEqual(message.message, "Before | after.")
|
||||
|
||||
def test_an_empty_title_falls_back_to_the_generic_one_at_render_time(self):
|
||||
request, storage = self.request()
|
||||
|
||||
notify(request, "s||No custom title.")
|
||||
|
||||
[message] = list(storage)
|
||||
self.assertEqual(as_alert(message)["title"], "Done")
|
||||
|
||||
|
||||
class MessageAlertTests(TestCase):
|
||||
def alert(self, level, text, extra_tags=None):
|
||||
return as_alert(Message(level, text, extra_tags=extra_tags))
|
||||
|
||||
def test_each_level_gets_its_own_icon_title_and_colour(self):
|
||||
self.assertEqual(self.alert(messages.SUCCESS, "Saved.")["icon"], "circle-check")
|
||||
self.assertEqual(self.alert(messages.WARNING, "Careful.")["css"], "alert-warning")
|
||||
self.assertEqual(self.alert(messages.WARNING, "Careful.")["css"], "alert-warning border-warning")
|
||||
self.assertEqual(self.alert(messages.ERROR, "Boom.")["title"], "Something went wrong")
|
||||
self.assertEqual(self.alert(messages.INFO, "FYI.")["css"], "alert-info")
|
||||
self.assertEqual(self.alert(messages.INFO, "FYI.")["css"], "alert-info border-info")
|
||||
|
||||
def test_extra_tags_override_the_title(self):
|
||||
alert = self.alert(messages.SUCCESS, "Ajax United is live.", extra_tags="Club created")
|
||||
|
||||
self.assertEqual(alert["title"], "Club created")
|
||||
self.assertEqual(alert["body"], "Ajax United is live.")
|
||||
self.assertEqual(alert["css"], "alert-success") # a custom title must not change the level
|
||||
self.assertEqual(alert["css"], "alert-success border-success") # a custom title must not change the level
|
||||
|
||||
def test_an_unknown_level_falls_back_to_info(self):
|
||||
self.assertEqual(self.alert(999, "Odd.")["css"], "alert-info")
|
||||
self.assertEqual(self.alert(999, "Odd.")["css"], "alert-info border-info")
|
||||
|
||||
|
||||
class MessageRenderingTests(ControlPanelTestBase):
|
||||
def test_a_message_renders_as_a_soft_alert_with_icon_and_title(self):
|
||||
# club_archive queues its message through `notify`, which sets a custom title —
|
||||
# so the generic per-level one ("Careful") must not show.
|
||||
response = self.client.post(reverse("controlpanel:club_archive", args=[self.club.pk]), follow=True)
|
||||
|
||||
self.assertContains(response, "alert alert-soft alert-warning")
|
||||
self.assertContains(response, '<div class="font-bold">Careful</div>', html=False)
|
||||
self.assertContains(response, "alert alert-soft border-2 alert-warning border-warning")
|
||||
self.assertContains(response, '<div class="font-bold">Club archived</div>', html=False)
|
||||
self.assertContains(response, "<svg") # the lucide icon
|
||||
|
||||
|
||||
@@ -751,8 +812,8 @@ class DashboardMetricsTests(ControlPanelTestBase):
|
||||
|
||||
self.assertContains(response, "No current season")
|
||||
self.assertContains(response, "MFA pending")
|
||||
self.assertContains(response, "Payment pending")
|
||||
self.assertContains(response, 'id="signups-chart"')
|
||||
self.assertContains(response, 'id="revenue-chart"')
|
||||
self.assertContains(response, "js/chart.js")
|
||||
self.assertIn("signups", response.context["charts"])
|
||||
|
||||
@@ -1014,18 +1075,17 @@ class ClubHealthTableTests(TestCase):
|
||||
|
||||
response = self.client.get(reverse("controlpanel:dashboard"))
|
||||
|
||||
self.assertContains(response, "Owed")
|
||||
self.assertContains(response, "Upcoming")
|
||||
self.assertContains(response, "Unpaid")
|
||||
# Health, not vanity: Plan and Dues each name something to act on, next to the counts.
|
||||
for column in ("Members", "Admins", "Teams", "Events", "Plan", "Dues"):
|
||||
self.assertContains(response, f">{column}</th>")
|
||||
|
||||
|
||||
class ClubListHealthTests(ControlPanelTestBase):
|
||||
def test_the_list_shows_the_same_health_columns_as_the_dashboard(self):
|
||||
response = self.client.get(reverse("controlpanel:club_list"))
|
||||
|
||||
self.assertContains(response, "Owed")
|
||||
self.assertContains(response, "Upcoming")
|
||||
self.assertContains(response, "Unpaid")
|
||||
for column in ("Members", "Admins", "Teams", "Events", "Plan", "Dues"):
|
||||
self.assertContains(response, f">{column}</th>")
|
||||
self.assertTemplateUsed(response, "controlpanel/_club_health_table.html")
|
||||
|
||||
def test_an_archived_club_is_badged_archived_rather_than_dormant(self):
|
||||
@@ -1097,6 +1157,24 @@ class PlatformDuesMetricTests(TestCase):
|
||||
|
||||
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):
|
||||
subscribe(self.club, self.tier)
|
||||
record_payment(self.club.dues.first(), Decimal("500.00"))
|
||||
@@ -1114,6 +1192,36 @@ class PlatformDuesMetricTests(TestCase):
|
||||
self.assertEqual(club.tier_name, "Standard")
|
||||
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.covered_until, due.period_end)
|
||||
self.assertEqual(club.covered_status, Due.Status.PAID)
|
||||
|
||||
def test_a_waived_period_also_shows_its_cover_end(self):
|
||||
# Waived is settled too — the club is covered for that time, so its end date shows,
|
||||
# badged "waived" rather than "paid".
|
||||
subscribe(self.club, self.tier)
|
||||
due = self.club.dues.first()
|
||||
waive(due)
|
||||
|
||||
club = clubs_with_health().get(pk=self.club.pk)
|
||||
|
||||
self.assertEqual(club.covered_until, due.period_end)
|
||||
self.assertEqual(club.covered_status, Due.Status.WAIVED)
|
||||
|
||||
def test_a_club_that_owes_has_no_cover(self):
|
||||
subscribe(self.club, self.tier) # unpaid
|
||||
|
||||
club = clubs_with_health().get(pk=self.club.pk)
|
||||
self.assertIsNone(club.covered_until)
|
||||
self.assertIsNone(club.covered_status)
|
||||
|
||||
def test_the_health_table_still_costs_one_query_with_billing_on_it(self):
|
||||
subscribe(self.club, self.tier)
|
||||
subscribe(Club.objects.create(name="Feyenoord"), self.tier)
|
||||
@@ -1250,7 +1358,9 @@ class BillingFormRenderTests(ControlPanelTestBase):
|
||||
self.tier = Tier.objects.create(name="Standard")
|
||||
TierPrice.objects.create(tier=self.tier, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00"))
|
||||
|
||||
def test_the_billing_forms_render(self):
|
||||
def test_the_billing_forms_are_post_only(self):
|
||||
# Every one of these is reachable only through a modal on the billing or club
|
||||
# detail page: there is no standalone template to render on a GET.
|
||||
subscribe(self.club, self.tier)
|
||||
due = self.club.dues.first()
|
||||
|
||||
@@ -1262,17 +1372,33 @@ class BillingFormRenderTests(ControlPanelTestBase):
|
||||
reverse("controlpanel:club_open_period", args=[self.club.pk]),
|
||||
reverse("controlpanel:due_pay", args=[due.pk]),
|
||||
):
|
||||
self.assertEqual(self.client.get(url).status_code, 200, url)
|
||||
self.assertEqual(self.client.get(url).status_code, 405, url)
|
||||
|
||||
def test_the_payment_form_defaults_to_the_outstanding_balance(self):
|
||||
def test_the_billing_forms_redirect_with_a_message_on_invalid_input(self):
|
||||
# Rejected input has nowhere to re-render — the modal that submitted it is on a
|
||||
# page this view no longer serves — so it must bounce back with an error message
|
||||
# rather than 500 or silently drop the submission.
|
||||
subscribe(self.club, self.tier)
|
||||
due = self.club.dues.first()
|
||||
|
||||
response = self.client.post(reverse("controlpanel:tier_create"), {"name": "", "description": "", "is_active": "on"}, follow=True)
|
||||
self.assertRedirects(response, reverse("controlpanel:billing"))
|
||||
self.assertContains(response, "This field is required")
|
||||
|
||||
response = self.client.post(reverse("controlpanel:due_pay", args=[due.pk]), {"amount": "not-a-number", "method": "bank_transfer", "reference": "", "paid_at": "", "note": ""}, follow=True)
|
||||
self.assertRedirects(response, reverse("controlpanel:club_detail", args=[self.club.pk]))
|
||||
self.assertContains(response, "Enter a number")
|
||||
|
||||
def test_the_payment_modal_defaults_to_the_outstanding_balance(self):
|
||||
subscribe(self.club, self.tier)
|
||||
due = self.club.dues.first()
|
||||
record_payment(due, Decimal("200.00"))
|
||||
due.refresh_from_db()
|
||||
|
||||
response = self.client.get(reverse("controlpanel:due_pay", args=[due.pk]))
|
||||
response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk]))
|
||||
|
||||
self.assertEqual(response.context["form"].initial["amount"], Decimal("300.00"))
|
||||
rendered_due = next(rendered for rendered in response.context["dues"] if rendered.pk == due.pk)
|
||||
self.assertEqual(rendered_due.payment_form.initial["amount"], Decimal("300.00"))
|
||||
|
||||
def test_a_tier_can_be_renamed(self):
|
||||
self.client.post(reverse("controlpanel:tier_update", args=[self.tier.pk]), {"name": "Standard plus", "description": "", "is_active": "on"})
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from django.contrib import messages
|
||||
from contextlib import contextmanager
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models import Count
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.formats import date_format
|
||||
from django.views.generic import CreateView, DetailView, FormView, ListView, TemplateView, UpdateView, View
|
||||
from waffle import get_waffle_flag_model, get_waffle_switch_model
|
||||
|
||||
@@ -16,7 +18,8 @@ from club.models import Club, ClubRole
|
||||
from features.models import Maintenance
|
||||
|
||||
from .forms import ClubAdminForm, ClubForm, DuePaymentForm, FlagForm, MaintenanceForm, OpenPeriodForm, PlatformAdminForm, SubscriptionForm, TierForm, TierPriceForm
|
||||
from .mixins import PlatformStaffRequiredMixin, PlatformSuperuserRequiredMixin
|
||||
from .messages import notify
|
||||
from .mixins import PlatformStaffRequiredMixin, PlatformSuperuserRequiredMixin, RedirectOnInvalidMixin
|
||||
from .services.admins import grant_club_admin, revoke_club_admin
|
||||
from .services.platform_admins import (
|
||||
PlatformAdminError,
|
||||
@@ -25,12 +28,26 @@ from .services.platform_admins import (
|
||||
revoke_platform_access,
|
||||
set_platform_access,
|
||||
)
|
||||
from .services.statistics import club_attention, club_charts, club_statistics, clubs_with_health, flag_adoption, onboarding_funnel, platform_attention, platform_charts, platform_totals
|
||||
from .services.statistics import club_attention, club_charts, club_statistics, clubs_with_health, flag_adoption, flags_for_club, onboarding_funnel, platform_attention, platform_charts, platform_totals
|
||||
|
||||
Flag = get_waffle_flag_model()
|
||||
Switch = get_waffle_switch_model()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def suppress_billing_errors(request, title="Billing error"):
|
||||
"""Turn a BillingError into an error message rather than letting it propagate.
|
||||
|
||||
Fits call sites that fall through to the same redirect on the happy and unhappy path
|
||||
alike — the success message is set inside the block, the failure message by this
|
||||
context manager, and whichever fired, the caller's next line runs unchanged.
|
||||
"""
|
||||
try:
|
||||
yield
|
||||
except BillingError as error:
|
||||
notify(request, f"e|{title}|{error}")
|
||||
|
||||
|
||||
class DashboardView(PlatformStaffRequiredMixin, TemplateView):
|
||||
template_name = "controlpanel/dashboard.html"
|
||||
|
||||
@@ -74,12 +91,15 @@ class ClubCreateView(PlatformStaffRequiredMixin, CreateView):
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
messages.success(self.request, f"Club “{self.object}” created.")
|
||||
notify(self.request, f"s|Club created|Club “{self.object}” created.")
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("controlpanel:club_detail", args=[self.object.pk])
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(nav="clubs", **kwargs)
|
||||
|
||||
|
||||
class ClubUpdateView(PlatformStaffRequiredMixin, UpdateView):
|
||||
model = Club
|
||||
@@ -88,12 +108,15 @@ class ClubUpdateView(PlatformStaffRequiredMixin, UpdateView):
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
messages.success(self.request, f"Club “{self.object}” updated.")
|
||||
notify(self.request, f"s|Club updated|Club “{self.object}” updated.")
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("controlpanel:club_detail", args=[self.object.pk])
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(nav="clubs", update_view=True, **kwargs)
|
||||
|
||||
|
||||
class ClubDetailView(PlatformStaffRequiredMixin, DetailView):
|
||||
model = Club
|
||||
@@ -101,16 +124,30 @@ class ClubDetailView(PlatformStaffRequiredMixin, DetailView):
|
||||
context_object_name = "club"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
subscription = getattr(self.object, "subscription", None)
|
||||
next_start = next_period_start(self.object)
|
||||
|
||||
# Bound per-row so each due's "Add payment" modal can render its own form without
|
||||
# the template calling DuePaymentForm(initial=...) itself.
|
||||
dues = list(self.object.dues.select_related("tier", "invoice").prefetch_related("payments"))
|
||||
for due in dues:
|
||||
if due.is_owing:
|
||||
due.payment_form = DuePaymentForm(initial={"amount": due.balance})
|
||||
|
||||
return super().get_context_data(
|
||||
nav="clubs",
|
||||
groups=club_statistics(self.object),
|
||||
attention=club_attention(self.object),
|
||||
charts=club_charts(self.object),
|
||||
subscription=getattr(self.object, "subscription", None),
|
||||
dues=self.object.dues.select_related("tier", "invoice").prefetch_related("payments"),
|
||||
subscription=subscription,
|
||||
dues=dues,
|
||||
today=timezone.localdate(),
|
||||
admins=ClubRole.objects.filter(club=self.object, role=ClubRole.Roles.ADMIN).select_related("member", "member__user"),
|
||||
admin_form=ClubAdminForm(),
|
||||
flags=flags_for_club(self.object),
|
||||
open_period_form=OpenPeriodForm(),
|
||||
open_period_blurb=f"Next period starts {date_format(next_start, 'j M Y')} unless you say otherwise. By default it continues from the end of the last one, so a lapsed year is still owed — pick a start date to forgive the gap.",
|
||||
subscription_form=SubscriptionForm(instance=subscription) if subscription else SubscriptionForm(),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -121,7 +158,7 @@ class ClubArchiveView(PlatformStaffRequiredMixin, View):
|
||||
def post(self, request, pk):
|
||||
club = get_object_or_404(Club, pk=pk)
|
||||
club.archive()
|
||||
messages.warning(request, f"Club “{club}” archived. Its subdomain no longer resolves.")
|
||||
notify(request, f"w|Club archived|Club “{club}” archived. Its subdomain no longer resolves.")
|
||||
return redirect("controlpanel:club_detail", pk=club.pk)
|
||||
|
||||
|
||||
@@ -129,24 +166,28 @@ class ClubRestoreView(PlatformStaffRequiredMixin, View):
|
||||
def post(self, request, pk):
|
||||
club = get_object_or_404(Club, pk=pk)
|
||||
club.restore()
|
||||
messages.success(request, f"Club “{club}” restored.")
|
||||
notify(request, f"s|Club restored|Club “{club}” restored.")
|
||||
return redirect("controlpanel:club_detail", pk=club.pk)
|
||||
|
||||
|
||||
class ClubAdminAddView(PlatformStaffRequiredMixin, FormView):
|
||||
class ClubAdminAddView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, FormView):
|
||||
"""Reachable only via the "Add admin" modal on the club detail page — POST-only, and
|
||||
there is no standalone template to render on GET or on a rejected submission."""
|
||||
|
||||
form_class = ClubAdminForm
|
||||
template_name = "controlpanel/club_admin_form.html"
|
||||
http_method_names = ["post"]
|
||||
invalid_redirect_url_name = "controlpanel:club_detail"
|
||||
|
||||
@property
|
||||
def club(self):
|
||||
return get_object_or_404(Club, pk=self.kwargs["pk"])
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(nav="clubs", club=self.club, **kwargs)
|
||||
def get_invalid_redirect_kwargs(self):
|
||||
return {"pk": self.kwargs["pk"]}
|
||||
|
||||
def form_valid(self, form):
|
||||
role = grant_club_admin(self.club, **form.cleaned_data)
|
||||
messages.success(self.request, f"{role.member} is now an admin of {role.club}. They must set up two-factor authentication before they can sign in.")
|
||||
notify(self.request, f"s|Admin added|{role.member} is now an admin of {role.club}. They must set up two-factor authentication before they can sign in.")
|
||||
return redirect("controlpanel:club_detail", pk=self.kwargs["pk"])
|
||||
|
||||
|
||||
@@ -155,24 +196,10 @@ class ClubAdminRemoveView(PlatformStaffRequiredMixin, View):
|
||||
role = get_object_or_404(ClubRole, pk=role_pk, club_id=pk, role=ClubRole.Roles.ADMIN)
|
||||
member = role.member
|
||||
revoke_club_admin(role)
|
||||
messages.warning(request, f"{member} is no longer an admin of this club.")
|
||||
notify(request, f"w|Admin removed|{member} is no longer an admin of this club.")
|
||||
return redirect("controlpanel:club_detail", pk=pk)
|
||||
|
||||
|
||||
def flags_for_club(club):
|
||||
"""Every flag, annotated with whether it is on for this club and why."""
|
||||
enabled_ids = set(club.flags.values_list("pk", flat=True))
|
||||
return [
|
||||
{
|
||||
"flag": flag,
|
||||
"enabled": flag.pk in enabled_ids,
|
||||
# `everyone` overrides club targeting, so the per-club toggle is moot.
|
||||
"overridden": flag.everyone is not None,
|
||||
}
|
||||
for flag in Flag.objects.order_by("name")
|
||||
]
|
||||
|
||||
|
||||
class ClubFeatureToggleView(PlatformStaffRequiredMixin, View):
|
||||
"""Turn a feature on or off for one club."""
|
||||
|
||||
@@ -182,10 +209,10 @@ class ClubFeatureToggleView(PlatformStaffRequiredMixin, View):
|
||||
|
||||
if flag.clubs.filter(pk=club.pk).exists():
|
||||
flag.clubs.remove(club)
|
||||
messages.warning(request, f"“{flag.name}” turned off for {club}.")
|
||||
notify(request, f"w|Feature disabled|“{flag.name}” turned off for {club}.")
|
||||
else:
|
||||
flag.clubs.add(club)
|
||||
messages.success(request, f"“{flag.name}” turned on for {club}.")
|
||||
notify(request, f"s|Feature enabled|“{flag.name}” turned on for {club}.")
|
||||
|
||||
return redirect("controlpanel:club_detail", pk=club.pk)
|
||||
|
||||
@@ -194,9 +221,16 @@ class FeatureListView(PlatformStaffRequiredMixin, TemplateView):
|
||||
template_name = "controlpanel/features.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
# Bound per-row so each flag's "Edit" modal can render its own form: the template
|
||||
# can't call FlagForm(instance=flag) itself, so the form rides along on the flag.
|
||||
flags = list(Flag.objects.prefetch_related("clubs").order_by("name"))
|
||||
for flag in flags:
|
||||
flag.edit_form = FlagForm(instance=flag)
|
||||
|
||||
return super().get_context_data(
|
||||
nav="features",
|
||||
flags=Flag.objects.prefetch_related("clubs").order_by("name"),
|
||||
flags=flags,
|
||||
flag_form=FlagForm(),
|
||||
switches=Switch.objects.order_by("name"),
|
||||
maintenance=Maintenance.current(),
|
||||
maintenance_form=MaintenanceForm(),
|
||||
@@ -210,45 +244,46 @@ class MaintenanceView(PlatformStaffRequiredMixin, View):
|
||||
def post(self, request):
|
||||
if Maintenance.is_on():
|
||||
Maintenance.stop()
|
||||
messages.success(request, "Maintenance ended. The clubs are back.")
|
||||
notify(request, "s|Maintenance ended|The clubs are back.")
|
||||
else:
|
||||
form = MaintenanceForm(request.POST)
|
||||
message = form.cleaned_data["message"] if form.is_valid() else ""
|
||||
Maintenance.start(message=message, user=request.user)
|
||||
messages.warning(request, "Platform closed. Every club subdomain now serves a maintenance page, and the scheduled jobs stand down.")
|
||||
notify(request, "w|Platform closed|Every club subdomain now serves a maintenance page, and the scheduled jobs stand down.")
|
||||
|
||||
return redirect("controlpanel:features")
|
||||
|
||||
|
||||
class FlagCreateView(PlatformStaffRequiredMixin, CreateView):
|
||||
class FlagCreateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, CreateView):
|
||||
"""Reachable only via the "New feature" modal on the features page — POST-only, and
|
||||
there is no standalone template to render on GET or on a rejected submission."""
|
||||
|
||||
model = Flag
|
||||
form_class = FlagForm
|
||||
template_name = "controlpanel/flag_form.html"
|
||||
success_url = None
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(nav="features", **kwargs)
|
||||
http_method_names = ["post"]
|
||||
invalid_redirect_url_name = "controlpanel:features"
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
messages.success(self.request, f"Feature “{self.object.name}” created.")
|
||||
notify(self.request, f"s|Feature created|Feature “{self.object.name}” created.")
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("controlpanel:features")
|
||||
|
||||
|
||||
class FlagUpdateView(PlatformStaffRequiredMixin, UpdateView):
|
||||
class FlagUpdateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, UpdateView):
|
||||
"""Reachable only via a flag's "Edit" modal on the features page — POST-only, and
|
||||
there is no standalone template to render on GET or on a rejected submission."""
|
||||
|
||||
model = Flag
|
||||
form_class = FlagForm
|
||||
template_name = "controlpanel/flag_form.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(nav="features", **kwargs)
|
||||
http_method_names = ["post"]
|
||||
invalid_redirect_url_name = "controlpanel:features"
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
messages.success(self.request, f"Feature “{self.object.name}” updated.")
|
||||
notify(self.request, f"s|Feature updated|Feature “{self.object.name}” updated.")
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
@@ -262,7 +297,8 @@ class SwitchToggleView(PlatformStaffRequiredMixin, View):
|
||||
switch = get_object_or_404(Switch, pk=pk)
|
||||
switch.active = not switch.active
|
||||
switch.save()
|
||||
messages.success(request, f"Switch “{switch.name}” is now {'on' if switch.active else 'off'}.")
|
||||
title = "Switch on" if switch.active else "Switch off"
|
||||
notify(request, f"s|{title}|Switch “{switch.name}” is now {'on' if switch.active else 'off'}.")
|
||||
return redirect("controlpanel:features")
|
||||
|
||||
|
||||
@@ -270,19 +306,20 @@ class PlatformAdminListView(PlatformSuperuserRequiredMixin, TemplateView):
|
||||
template_name = "controlpanel/admins.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(nav="admins", admins=platform_admins(), **kwargs)
|
||||
return super().get_context_data(nav="admins", admins=platform_admins(), admin_form=PlatformAdminForm(), **kwargs)
|
||||
|
||||
|
||||
class PlatformAdminAddView(PlatformSuperuserRequiredMixin, FormView):
|
||||
class PlatformAdminAddView(PlatformSuperuserRequiredMixin, RedirectOnInvalidMixin, FormView):
|
||||
"""Reachable only via the "Grant access" modal on the admins page — POST-only, and
|
||||
there is no standalone template to render on GET or on a rejected submission."""
|
||||
|
||||
form_class = PlatformAdminForm
|
||||
template_name = "controlpanel/admin_form.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(nav="admins", **kwargs)
|
||||
http_method_names = ["post"]
|
||||
invalid_redirect_url_name = "controlpanel:admins"
|
||||
|
||||
def form_valid(self, form):
|
||||
user = grant_platform_access(form.cleaned_data["email"], is_superuser=form.cleaned_data["is_superuser"])
|
||||
messages.success(self.request, f"{user.email} now has platform access. They must set up two-factor authentication before they can sign in.")
|
||||
notify(self.request, f"s|Platform access granted|{user.email} now has platform access. They must set up two-factor authentication before they can sign in.")
|
||||
return redirect("controlpanel:admins")
|
||||
|
||||
|
||||
@@ -297,9 +334,9 @@ class PlatformAdminUpdateView(PlatformSuperuserRequiredMixin, View):
|
||||
is_superuser=request.POST.get("is_superuser") == "1",
|
||||
)
|
||||
except PlatformAdminError as error:
|
||||
messages.error(request, str(error))
|
||||
notify(request, f"e|Couldn't update access|{error}")
|
||||
else:
|
||||
messages.success(request, f"Updated platform access for {user.email}.")
|
||||
notify(request, f"s|Access updated|Updated platform access for {user.email}.")
|
||||
return redirect("controlpanel:admins")
|
||||
|
||||
|
||||
@@ -309,9 +346,9 @@ class PlatformAdminRevokeView(PlatformSuperuserRequiredMixin, View):
|
||||
try:
|
||||
revoke_platform_access(request.user, user)
|
||||
except PlatformAdminError as error:
|
||||
messages.error(request, str(error))
|
||||
notify(request, f"e|Couldn't revoke access|{error}")
|
||||
else:
|
||||
messages.warning(request, f"{user.email} no longer has platform access.")
|
||||
notify(request, f"w|Access revoked|{user.email} no longer has platform access.")
|
||||
return redirect("controlpanel:admins")
|
||||
|
||||
|
||||
@@ -322,76 +359,102 @@ class BillingView(PlatformStaffRequiredMixin, TemplateView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
today = timezone.localdate()
|
||||
|
||||
# Bound per-row so each "Edit" / "New price" modal can render its own form: the
|
||||
# template can't call TierForm(instance=tier) itself, so the form rides along on
|
||||
# the object it belongs to.
|
||||
tiers = list(Tier.objects.prefetch_related("prices").annotate(club_count=Count("subscriptions")))
|
||||
for tier in tiers:
|
||||
tier.edit_form = TierForm(instance=tier)
|
||||
tier.price_form = TierPriceForm()
|
||||
|
||||
owing = list(Due.objects.filter(status__in=Due.OWING).select_related("club", "tier").order_by("grace_until"))
|
||||
for due in owing:
|
||||
due.payment_form = DuePaymentForm(initial={"amount": due.balance})
|
||||
|
||||
return super().get_context_data(
|
||||
nav="billing",
|
||||
tiers=Tier.objects.prefetch_related("prices").annotate(club_count=Count("subscriptions")),
|
||||
owing=Due.objects.filter(status__in=Due.OWING).select_related("club", "tier").order_by("grace_until"),
|
||||
tiers=tiers,
|
||||
tier_form=TierForm(),
|
||||
owing=owing,
|
||||
today=today,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class TierCreateView(PlatformStaffRequiredMixin, CreateView):
|
||||
class TierCreateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, CreateView):
|
||||
"""Reachable only via the "New plan" modal on the billing page — POST-only, and there
|
||||
is no standalone template to render on GET or on a rejected submission."""
|
||||
|
||||
model = Tier
|
||||
form_class = TierForm
|
||||
template_name = "controlpanel/tier_form.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(nav="billing", **kwargs)
|
||||
http_method_names = ["post"]
|
||||
invalid_redirect_url_name = "controlpanel:billing"
|
||||
|
||||
def get_success_url(self):
|
||||
messages.success(self.request, f"Tier “{self.object}” created. Give it a price before billing anyone.")
|
||||
notify(self.request, f"s|Plan created|Tier “{self.object}” created. Give it a price before billing anyone.")
|
||||
return reverse("controlpanel:billing")
|
||||
|
||||
|
||||
class TierUpdateView(PlatformStaffRequiredMixin, UpdateView):
|
||||
class TierUpdateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, UpdateView):
|
||||
"""Reachable only via a tier's "Edit" modal on the billing page — POST-only, and there
|
||||
is no standalone template to render on GET or on a rejected submission."""
|
||||
|
||||
model = Tier
|
||||
form_class = TierForm
|
||||
template_name = "controlpanel/tier_form.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(nav="billing", **kwargs)
|
||||
http_method_names = ["post"]
|
||||
invalid_redirect_url_name = "controlpanel:billing"
|
||||
|
||||
def get_success_url(self):
|
||||
messages.success(self.request, f"Tier “{self.object}” updated.")
|
||||
notify(self.request, f"s|Plan updated|Tier “{self.object}” updated.")
|
||||
return reverse("controlpanel:billing")
|
||||
|
||||
|
||||
class TierPriceCreateView(PlatformStaffRequiredMixin, CreateView):
|
||||
class TierPriceCreateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, CreateView):
|
||||
"""A rate change is a new dated price, never an edit of the old one — periods already
|
||||
billed keep the amount they were billed at."""
|
||||
billed keep the amount they were billed at.
|
||||
|
||||
Reachable only via a tier's "New price" modal on the billing page — POST-only, and
|
||||
there is no standalone template to render on GET or on a rejected submission.
|
||||
"""
|
||||
|
||||
model = TierPrice
|
||||
form_class = TierPriceForm
|
||||
template_name = "controlpanel/tier_price_form.html"
|
||||
http_method_names = ["post"]
|
||||
invalid_redirect_url_name = "controlpanel:billing"
|
||||
|
||||
@property
|
||||
def tier(self):
|
||||
return get_object_or_404(Tier, pk=self.kwargs["pk"])
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(nav="billing", tier=self.tier, **kwargs)
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.tier = self.tier
|
||||
response = super().form_valid(form)
|
||||
messages.success(self.request, f"{self.tier} is €{self.object.amount} for periods opening from {self.object.active_from}.")
|
||||
notify(self.request, f"s|Price added|{self.tier} is €{self.object.amount} for periods opening from {self.object.active_from}.")
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("controlpanel:billing")
|
||||
|
||||
|
||||
class SubscribeClubView(PlatformStaffRequiredMixin, FormView):
|
||||
"""Put a club on a tier, which opens its first period."""
|
||||
class SubscribeClubView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, FormView):
|
||||
"""Put a club on a tier, which opens its first period.
|
||||
|
||||
Reachable only via the "Change plan" modal on the club detail page — POST-only, and
|
||||
there is no standalone template to render on GET or on a rejected submission.
|
||||
"""
|
||||
|
||||
form_class = SubscriptionForm
|
||||
template_name = "controlpanel/subscription_form.html"
|
||||
http_method_names = ["post"]
|
||||
invalid_redirect_url_name = "controlpanel:club_detail"
|
||||
|
||||
@property
|
||||
def club(self):
|
||||
return get_object_or_404(Club, pk=self.kwargs["pk"])
|
||||
|
||||
def get_invalid_redirect_kwargs(self):
|
||||
return {"pk": self.club.pk}
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
subscription = getattr(self.club, "subscription", None)
|
||||
@@ -399,46 +462,43 @@ class SubscribeClubView(PlatformStaffRequiredMixin, FormView):
|
||||
kwargs["instance"] = subscription
|
||||
return kwargs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(nav="clubs", club=self.club, subscription=getattr(self.club, "subscription", None), **kwargs)
|
||||
|
||||
def form_valid(self, form):
|
||||
club = self.club
|
||||
existing = getattr(club, "subscription", None)
|
||||
try:
|
||||
with suppress_billing_errors(self.request, title="Couldn't change plan"):
|
||||
if existing:
|
||||
# Changing tier does not re-bill: the current period keeps the amount it was
|
||||
# issued at, and the new rate applies from the next one.
|
||||
subscription = form.save(commit=False)
|
||||
subscription.club = club
|
||||
subscription.save()
|
||||
messages.success(self.request, f"{club} is now on {subscription.tier}. The current period keeps the amount it was billed at.")
|
||||
notify(self.request, f"s|Plan changed|{club} is now on {subscription.tier}. The current period keeps the amount it was billed at.")
|
||||
else:
|
||||
subscribe(club, form.cleaned_data["tier"], start=form.cleaned_data.get("start"), auto_archive=form.cleaned_data["auto_archive"])
|
||||
messages.success(self.request, f"{club} is on {form.cleaned_data['tier']}. Its first period is open.")
|
||||
except BillingError as error:
|
||||
messages.error(self.request, str(error))
|
||||
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"])
|
||||
notify(self.request, f"s|Billing started|{club} is on {form.cleaned_data['tier']}. Its first period is open.")
|
||||
|
||||
return redirect("controlpanel:club_detail", pk=club.pk)
|
||||
|
||||
|
||||
class RecordPaymentView(PlatformStaffRequiredMixin, FormView):
|
||||
class RecordPaymentView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, FormView):
|
||||
"""Reachable only via a due's "Record payment" modal on the club or billing page —
|
||||
POST-only, and there is no standalone template to render on GET or on a rejected
|
||||
submission."""
|
||||
|
||||
form_class = DuePaymentForm
|
||||
template_name = "controlpanel/payment_form.html"
|
||||
http_method_names = ["post"]
|
||||
invalid_redirect_url_name = "controlpanel:club_detail"
|
||||
|
||||
@property
|
||||
def due(self):
|
||||
return get_object_or_404(Due.objects.select_related("club", "tier"), pk=self.kwargs["pk"])
|
||||
|
||||
def get_initial(self):
|
||||
return {"amount": self.due.balance}
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(nav="clubs", due=self.due, **kwargs)
|
||||
def get_invalid_redirect_kwargs(self):
|
||||
return {"pk": self.due.club_id}
|
||||
|
||||
def form_valid(self, form):
|
||||
due = self.due
|
||||
try:
|
||||
with suppress_billing_errors(self.request, title="Couldn't record payment"):
|
||||
record_payment(
|
||||
due,
|
||||
form.cleaned_data["amount"],
|
||||
@@ -449,9 +509,7 @@ class RecordPaymentView(PlatformStaffRequiredMixin, FormView):
|
||||
user=self.request.user,
|
||||
)
|
||||
due.refresh_from_db()
|
||||
messages.success(self.request, f"€{form.cleaned_data['amount']} recorded. {due.get_status_display().capitalize()} — €{due.balance} outstanding.")
|
||||
except BillingError as error:
|
||||
messages.error(self.request, str(error))
|
||||
notify(self.request, f"s|Payment recorded|€{form.cleaned_data['amount']} recorded. {due.get_status_display().capitalize()} — €{due.balance} outstanding.")
|
||||
|
||||
return redirect("controlpanel:club_detail", pk=due.club_id)
|
||||
|
||||
@@ -459,37 +517,37 @@ class RecordPaymentView(PlatformStaffRequiredMixin, FormView):
|
||||
class WaiveDueView(PlatformStaffRequiredMixin, View):
|
||||
def post(self, request, pk):
|
||||
due = get_object_or_404(Due, pk=pk)
|
||||
try:
|
||||
with suppress_billing_errors(request, title="Couldn't waive period"):
|
||||
waive(due)
|
||||
messages.warning(request, f"Period {due.period_start} to {due.period_end} waived. Nothing is owed and the club will not be archived for it.")
|
||||
except BillingError as error:
|
||||
messages.error(request, str(error))
|
||||
notify(request, f"w|Period waived|Period {due.period_start} to {due.period_end} waived. Nothing is owed and the club will not be archived for it.")
|
||||
|
||||
return redirect("controlpanel:club_detail", pk=due.club_id)
|
||||
|
||||
|
||||
class OpenPeriodView(PlatformStaffRequiredMixin, FormView):
|
||||
"""Renew a club, or reactivate an archived one."""
|
||||
class OpenPeriodView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, FormView):
|
||||
"""Renew a club, or reactivate an archived one.
|
||||
|
||||
Reachable only via the "Open period" modal on the club detail page — POST-only, and
|
||||
there is no standalone template to render on GET or on a rejected submission.
|
||||
"""
|
||||
|
||||
form_class = OpenPeriodForm
|
||||
template_name = "controlpanel/period_form.html"
|
||||
http_method_names = ["post"]
|
||||
invalid_redirect_url_name = "controlpanel:club_detail"
|
||||
|
||||
@property
|
||||
def club(self):
|
||||
return get_object_or_404(Club, pk=self.kwargs["pk"])
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
club = self.club
|
||||
return super().get_context_data(nav="clubs", club=club, next_start=next_period_start(club), **kwargs)
|
||||
def get_invalid_redirect_kwargs(self):
|
||||
return {"pk": self.club.pk}
|
||||
|
||||
def form_valid(self, form):
|
||||
club = self.club
|
||||
start = form.cleaned_data.get("start")
|
||||
try:
|
||||
with suppress_billing_errors(self.request, title="Couldn't open period"):
|
||||
due = reactivate(club, start=start) if club.is_archived else open_period(club, start=start)
|
||||
messages.success(self.request, f"Period {due.period_start} to {due.period_end} opened for €{due.amount}. Invoice {due.invoice.number}.")
|
||||
except BillingError as error:
|
||||
messages.error(self.request, str(error))
|
||||
notify(self.request, f"s|Period opened|Period {due.period_start} to {due.period_end} opened for €{due.amount}. Invoice {due.invoice.number}.")
|
||||
|
||||
return redirect("controlpanel:club_detail", pk=club.pk)
|
||||
|
||||
@@ -502,7 +560,7 @@ class InvoicePdfView(PlatformStaffRequiredMixin, View):
|
||||
pdf = invoice_pdf(invoice)
|
||||
except BillingError as error:
|
||||
# The native PDF libraries are missing: say so rather than 500.
|
||||
messages.error(request, str(error))
|
||||
notify(request, f"e|PDF unavailable|{error}")
|
||||
return redirect("controlpanel:club_detail", pk=due.club_id)
|
||||
|
||||
response = HttpResponse(pdf, content_type="application/pdf")
|
||||
|
||||
111
deploy/deploy-dev.sh
Executable file
111
deploy/deploy-dev.sh
Executable file
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deploy the test instance to the dev server, behind its existing Caddy.
|
||||
#
|
||||
# deploy/deploy-dev.sh # deploy the current branch
|
||||
# BRANCH=main deploy/deploy-dev.sh
|
||||
# deploy/deploy-dev.sh --push # push the branch first, then deploy
|
||||
#
|
||||
# Runs FROM your machine, works ON the server over one SSH session: it fetches the pushed
|
||||
# branch, builds the image, runs migrations explicitly (never from the entrypoint — a
|
||||
# starting gunicorn worker is a bad place to discover a failed migration), restarts web, and
|
||||
# waits for /healthz. Any step failing aborts the whole thing with a non-zero exit.
|
||||
set -Eeuo pipefail
|
||||
|
||||
# --- config (override via env) ----------------------------------------------
|
||||
SSH_HOST="${SSH_HOST:-home.siebens.org}"
|
||||
SSH_USER="${SSH_USER:-bernard}"
|
||||
REMOTE_DIR="${REMOTE_DIR:-/home/bernard/RosterChief}"
|
||||
BRANCH="${BRANCH:-$(git rev-parse --abbrev-ref HEAD)}"
|
||||
COMPOSE_FILE="${COMPOSE_FILE:-compose.behind-proxy.yaml}"
|
||||
# The published port lives in the server's .env (WEB_PORT), so it is read there, not here —
|
||||
# see the remote block. This default only applies if that file omits it, matching compose's
|
||||
# own `${WEB_PORT:-8001}`.
|
||||
DEFAULT_WEB_PORT="${DEFAULT_WEB_PORT:-8001}"
|
||||
|
||||
SSH_TARGET="${SSH_USER}@${SSH_HOST}"
|
||||
|
||||
say() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
|
||||
die() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# --- preflight, locally -----------------------------------------------------
|
||||
# The server deploys what is on the git remote, so unpushed commits would silently ship stale
|
||||
# code. Catch that here rather than after a confusing "why isn't my change live" round trip.
|
||||
git rev-parse --verify --quiet "origin/${BRANCH}" >/dev/null \
|
||||
|| die "origin/${BRANCH} does not exist. Push the branch first, or pass --push."
|
||||
|
||||
if [ "${1:-}" = "--push" ]; then
|
||||
say "Pushing ${BRANCH} to origin"
|
||||
git push origin "${BRANCH}"
|
||||
elif [ -n "$(git rev-list "origin/${BRANCH}..HEAD" 2>/dev/null)" ]; then
|
||||
die "Local ${BRANCH} is ahead of origin — the server would deploy stale code. Push first, or run with --push."
|
||||
fi
|
||||
|
||||
say "Deploying ${BRANCH} to ${SSH_TARGET}:${REMOTE_DIR}"
|
||||
|
||||
# --- the work, on the server ------------------------------------------------
|
||||
# One SSH session runs the whole remote script; args are passed positionally so nothing has to
|
||||
# be re-quoted inside the heredoc.
|
||||
ssh -o ConnectTimeout=10 "${SSH_TARGET}" bash -s -- "${REMOTE_DIR}" "${BRANCH}" "${COMPOSE_FILE}" "${DEFAULT_WEB_PORT}" <<'REMOTE'
|
||||
set -Eeuo pipefail
|
||||
REMOTE_DIR="$1"; BRANCH="$2"; COMPOSE_FILE="$3"; DEFAULT_WEB_PORT="$4"
|
||||
|
||||
step() { printf '\033[1;34m ->\033[0m %s\n' "$*"; }
|
||||
|
||||
cd "$REMOTE_DIR" 2>/dev/null || { echo "ERROR: $REMOTE_DIR not found. Clone the repo there first."; exit 1; }
|
||||
[ -d .git ] || { echo "ERROR: $REMOTE_DIR is not a git checkout."; exit 1; }
|
||||
|
||||
# The env files carry secrets and are never committed, so they must already be on the server.
|
||||
# Fail loudly rather than boot a half-configured stack.
|
||||
[ -f .env.production ] || { echo "ERROR: .env.production missing (Django config). Copy from .env.production.example."; exit 1; }
|
||||
[ -f .env ] || { echo "ERROR: .env missing (compose vars: POSTGRES_PASSWORD, ...). Copy from .env.compose.example."; exit 1; }
|
||||
|
||||
dc() { docker compose -f "$COMPOSE_FILE" "$@"; }
|
||||
|
||||
# The health probe must hit the port the container actually publishes, which is WEB_PORT in
|
||||
# the same .env compose reads. Parse it the way compose does — last assignment wins, quotes
|
||||
# and inline whitespace stripped — and fall back to the compose default when it is unset.
|
||||
WEB_PORT="$(sed -n 's/^[[:space:]]*WEB_PORT[[:space:]]*=[[:space:]]*//p' .env | tail -1 | tr -d '"'"'"' \r')"
|
||||
WEB_PORT="${WEB_PORT:-$DEFAULT_WEB_PORT}"
|
||||
HEALTH_URL="http://127.0.0.1:${WEB_PORT}/healthz"
|
||||
|
||||
# reset --hard, not pull: a deploy target only receives deploys, so make it exactly match the
|
||||
# remote branch rather than risk a merge conflict from drift no one meant to leave there.
|
||||
step "Fetching ${BRANCH}"
|
||||
git fetch --quiet origin
|
||||
git checkout --quiet "$BRANCH"
|
||||
git reset --hard --quiet "origin/${BRANCH}"
|
||||
echo " at $(git rev-parse --short HEAD) — $(git log -1 --pretty=%s)"
|
||||
|
||||
step "Building image"
|
||||
dc build
|
||||
|
||||
# Bring the data services up first and wait for Postgres, so the migration below has something
|
||||
# to connect to on a cold start.
|
||||
step "Starting db + redis"
|
||||
dc up -d db redis
|
||||
|
||||
step "Running migrations"
|
||||
# -T and </dev/null are load-bearing: this whole script IS ssh's stdin (a heredoc), and
|
||||
# `compose run` without them attaches that stdin to the container — swallowing every command
|
||||
# below it, so web never restarts and the script exits 0 having done half the job.
|
||||
dc run --rm -T web python manage.py migrate --noinput </dev/null
|
||||
|
||||
# Recreate only web, with the freshly built image. db and redis keep running untouched.
|
||||
step "Restarting web"
|
||||
dc up -d --no-deps web
|
||||
|
||||
step "Waiting for /healthz on :${WEB_PORT}"
|
||||
for attempt in $(seq 1 20); do
|
||||
if curl -fsS "$HEALTH_URL" >/dev/null 2>&1; then
|
||||
echo " healthy after ${attempt} check(s)"
|
||||
exit 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
|
||||
echo "ERROR: health check never passed. Recent web logs:"
|
||||
dc logs --tail 40 web
|
||||
exit 1
|
||||
REMOTE
|
||||
|
||||
say "Done. https://<your-test-domain>/healthz should return ok."
|
||||
@@ -3,13 +3,13 @@
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from formbuilder.models import Answer, Field, Submission
|
||||
from formbuilder.models import Answer, Submission
|
||||
|
||||
from .options import allowed_values
|
||||
from .form_factory import build_form
|
||||
|
||||
|
||||
class FormSubmissionError(Exception):
|
||||
"""Raised when a submission is rejected. ``errors`` maps field key -> message."""
|
||||
"""Raised when a submission is rejected. ``errors`` maps field key -> messages."""
|
||||
|
||||
def __init__(self, message, *, errors=None):
|
||||
super().__init__(message)
|
||||
@@ -21,12 +21,17 @@ def _is_empty(value):
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def submit_form(form, member, data, *, when=None):
|
||||
"""Create a Submission (with Answers) for ``data`` or raise FormSubmissionError."""
|
||||
def submit_form(form, member, data, *, files=None, when=None):
|
||||
"""Create a Submission (with Answers) for ``data``/``files`` or raise FormSubmissionError.
|
||||
|
||||
Validation goes through ``build_form`` — the same dynamic Django Form the UI would
|
||||
render — so a NUMBER field is actually checked as a decimal, an EMAIL as an email, a
|
||||
CHOICE against its real options, and so on, rather than a hand-rolled subset of that.
|
||||
"""
|
||||
when = when or timezone.now()
|
||||
|
||||
_check_open(form, member, when)
|
||||
cleaned = _clean_answers(form, data)
|
||||
cleaned = _clean_answers(form, data, files)
|
||||
|
||||
submission = Submission.objects.create(form=form, member=member)
|
||||
Answer.objects.bulk_create([Answer(submission=submission, field=field, value=value) for field, value in cleaned])
|
||||
@@ -48,37 +53,13 @@ def _check_open(form, member, when):
|
||||
raise FormSubmissionError("You have reached the maximum number of submissions for this form.")
|
||||
|
||||
|
||||
def _clean_answers(form, data):
|
||||
errors = {}
|
||||
cleaned = []
|
||||
|
||||
for field in form.fields.filter(is_active=True):
|
||||
raw = data.get(field.key)
|
||||
if _is_empty(raw):
|
||||
if field.required:
|
||||
errors[field.key] = "This field is required."
|
||||
continue
|
||||
|
||||
message = _validate_choice(field, raw)
|
||||
if message is not None:
|
||||
errors[field.key] = message
|
||||
continue
|
||||
|
||||
cleaned.append((field, raw))
|
||||
|
||||
if errors:
|
||||
def _clean_answers(form, data, files):
|
||||
bound_form = build_form(form, data=data, files=files or {})
|
||||
if not bound_form.is_valid():
|
||||
errors = {key: list(messages) for key, messages in bound_form.errors.items()}
|
||||
raise FormSubmissionError("The submission has errors.", errors=errors)
|
||||
return cleaned
|
||||
|
||||
|
||||
def _validate_choice(field, raw):
|
||||
if field.field_type == Field.FieldType.CHOICE:
|
||||
allowed = allowed_values(field)
|
||||
if allowed and raw not in allowed:
|
||||
return "Select a valid choice."
|
||||
elif field.field_type == Field.FieldType.MULTICHOICE:
|
||||
allowed = allowed_values(field)
|
||||
values = raw if isinstance(raw, list) else [raw]
|
||||
if allowed and not set(values) <= allowed:
|
||||
return "Select valid choices."
|
||||
return None
|
||||
# Blank optional answers are validated (they may legitimately be empty) but not
|
||||
# stored — an Answer row exists only where the submitter actually said something.
|
||||
fields_by_key = {field.key: field for field in form.fields.filter(is_active=True)}
|
||||
return [(fields_by_key[key], value) for key, value in bound_form.cleaned_data.items() if not _is_empty(value)]
|
||||
|
||||
@@ -211,6 +211,24 @@ class SubmitFormTests(FormbuilderTestBase):
|
||||
|
||||
self.assertIn("size", ctx.exception.errors)
|
||||
|
||||
def test_number_field_rejects_non_numeric_input(self):
|
||||
# Validation goes through the same dynamic Django Form the UI renders, so a
|
||||
# NUMBER field is checked as a decimal — not merely "present".
|
||||
Field.objects.create(form=self.form, key="age", label="Age", field_type=Field.FieldType.NUMBER, required=True, order=3)
|
||||
|
||||
with self.assertRaises(FormSubmissionError) as ctx:
|
||||
submit_form(self.form, self.member, {"name": "Jane", "age": "not-a-number"})
|
||||
|
||||
self.assertIn("age", ctx.exception.errors)
|
||||
|
||||
def test_email_field_rejects_an_invalid_address(self):
|
||||
Field.objects.create(form=self.form, key="contact", label="Contact", field_type=Field.FieldType.EMAIL, required=True, order=3)
|
||||
|
||||
with self.assertRaises(FormSubmissionError) as ctx:
|
||||
submit_form(self.form, self.member, {"name": "Jane", "contact": "not-an-email"})
|
||||
|
||||
self.assertIn("contact", ctx.exception.errors)
|
||||
|
||||
def test_multichoice_validation(self):
|
||||
field = Field.objects.create(form=self.form, key="days", label="Days", field_type=Field.FieldType.MULTICHOICE, required=False, order=3, options=["mon", "tue", "wed"])
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ class MemberCsvImporter:
|
||||
},
|
||||
)
|
||||
|
||||
club, _ = Club.objects.get_or_create(name=club_name)
|
||||
club = self.get_club(club_name)
|
||||
season = self.get_current_season(club)
|
||||
|
||||
_, membership_created = ClubMembership.objects.update_or_create(
|
||||
@@ -141,6 +141,19 @@ class MemberCsvImporter:
|
||||
membership_created=membership_created,
|
||||
)
|
||||
|
||||
def get_club(self, club_name) -> Club:
|
||||
# Never get_or_create: Club.name isn't unique, so a typo'd or differently-cased
|
||||
# value would otherwise either spin up a duplicate club or raise
|
||||
# MultipleObjectsReturned against one that already exists. Matching
|
||||
# case-insensitively absorbs the harmless variety (a CSV export's casing rarely
|
||||
# matches the platform's own); an unknown club is a data problem the importer
|
||||
# must not paper over by inventing one.
|
||||
club = Club.objects.filter(name__iexact=club_name).first()
|
||||
if club is None:
|
||||
raise ValueError(f"Unknown club '{club_name}'.")
|
||||
|
||||
return club
|
||||
|
||||
def get_current_season(self, club) -> Season:
|
||||
# Season.get_current() is tenant-scoped, so bind the row's club as the
|
||||
# active tenant for the lookup.
|
||||
|
||||
@@ -554,8 +554,9 @@ class ImportMembersCsvCommandTests(TestCase):
|
||||
self.assertFalse(Member.objects.filter(email="nofirst@example.com").exists())
|
||||
|
||||
def test_import_skips_row_when_club_has_no_current_season(self):
|
||||
# "New Club" has no season, so the row is skipped and — because the row
|
||||
# is atomic — the member and club creation roll back too.
|
||||
# The club exists but has no season, so the row is skipped and — because
|
||||
# the row is atomic — the member creation rolls back too.
|
||||
Club.objects.create(name="New Club")
|
||||
csv_path = self.write_csv(
|
||||
"\n".join(
|
||||
[
|
||||
@@ -571,7 +572,45 @@ class ImportMembersCsvCommandTests(TestCase):
|
||||
self.assertIn("No current season for club 'New Club'.", stderr)
|
||||
self.assertIn("Rows skipped: 1.", stdout)
|
||||
self.assertFalse(Member.objects.filter(email="jane@example.com").exists())
|
||||
self.assertFalse(Club.objects.filter(name="New Club").exists())
|
||||
|
||||
def test_import_skips_row_for_an_unknown_club(self):
|
||||
# The importer must not silently spin up a club for a typo'd or unknown
|
||||
# name — that is a data problem, not something to paper over.
|
||||
csv_path = self.write_csv(
|
||||
"\n".join(
|
||||
[
|
||||
"first_name,last_name,email,date_of_birth,create_account,club_name,license_number",
|
||||
"Jane,Doe,jane@example.com,2010-04-12,false,Nonexistent Club,LIC-001",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
stdout, stderr = self.call_import_command(csv_path)
|
||||
|
||||
self.assertIn("Row 2 skipped:", stderr)
|
||||
self.assertIn("Unknown club 'Nonexistent Club'.", stderr)
|
||||
self.assertIn("Rows skipped: 1.", stdout)
|
||||
self.assertFalse(Member.objects.filter(email="jane@example.com").exists())
|
||||
self.assertFalse(Club.objects.filter(name="Nonexistent Club").exists())
|
||||
|
||||
def test_import_matches_a_club_name_case_insensitively(self):
|
||||
# A CSV export's casing rarely matches the platform's own; that is
|
||||
# harmless variation, not a different club.
|
||||
csv_path = self.write_csv(
|
||||
"\n".join(
|
||||
[
|
||||
"first_name,last_name,email,date_of_birth,create_account,club_name,license_number",
|
||||
"Jane,Doe,jane@example.com,2010-04-12,false,city swim club,LIC-001",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
stdout, stderr = self.call_import_command(csv_path)
|
||||
|
||||
self.assertEqual(stderr, "")
|
||||
self.assertIn("Rows skipped: 0.", stdout)
|
||||
membership = ClubMembership.objects.get(club=self.club, member__email="jane@example.com")
|
||||
self.assertEqual(membership.season, self.season)
|
||||
|
||||
|
||||
class MemberImportResultTests(TestCase):
|
||||
|
||||
@@ -30,6 +30,12 @@ SECRET_KEY = config("DJANGO_SECRET_KEY")
|
||||
DEBUG = config("DJANGO_DEBUG", default=False, cast=bool)
|
||||
|
||||
ALLOWED_HOSTS = config("DJANGO_ALLOWED_HOSTS", cast=Csv(), default="")
|
||||
# The loopback is always allowed, and it must be: the container's own healthcheck and the
|
||||
# deploy probe hit /healthz over 127.0.0.1/localhost, before any proxy has supplied a real
|
||||
# Host header. Without this they get a 400 and the container is marked unhealthy forever.
|
||||
# It widens nothing — gunicorn binds to the loopback only; the proxy owns the public domains.
|
||||
ALLOWED_HOSTS += [host for host in ("localhost", "127.0.0.1") if host not in ALLOWED_HOSTS]
|
||||
|
||||
INTERNAL_IPS = config("DJANGO_INTERNAL_IPS", cast=Csv(), default="127.0.0.1")
|
||||
|
||||
CSRF_TRUSTED_ORIGINS = config("DJANGO_CSRF_TRUSTED_ORIGINS", cast=Csv(), default="")
|
||||
|
||||
@@ -78,3 +78,11 @@ class HealthCheckTests(SimpleTestCase):
|
||||
response = self.client.get(reverse("healthz"))
|
||||
|
||||
self.assertIn("no-cache", response["Cache-Control"])
|
||||
|
||||
@override_settings(ALLOWED_HOSTS=["example.com", "localhost", "127.0.0.1"])
|
||||
def test_it_answers_over_the_loopback(self):
|
||||
# The container healthcheck and the deploy probe hit it as 127.0.0.1/localhost, before
|
||||
# a proxy supplies a real Host. If ALLOWED_HOSTS rejects those, /healthz 400s and the
|
||||
# container is unhealthy forever — which is exactly how the first deploy failed.
|
||||
for host in ("127.0.0.1", "localhost"):
|
||||
self.assertEqual(self.client.get(reverse("healthz"), HTTP_HOST=host).status_code, 200, host)
|
||||
|
||||
@@ -11,6 +11,14 @@ from rosterchief.base import ClubScopedModel, UUIDModel, validate_club_scope
|
||||
from teams.models import Position, Team
|
||||
|
||||
|
||||
class DiscountType(models.TextChoices):
|
||||
"""Shared by ``Product`` (early-bird), ``Discount`` and ``AppliedDiscount`` (its
|
||||
snapshot on an order) — one discount vocabulary, not three copies of it."""
|
||||
|
||||
PERCENTAGE = "percentage", _("Percentage")
|
||||
FIXED_AMOUNT = "fixed_amount", _("Fixed amount")
|
||||
|
||||
|
||||
def next_scoped_number(instance, code):
|
||||
"""Next per-club sequential number for the current year: ``<code>-<year>-<seq>``."""
|
||||
prefix = f"{code}-{timezone.now().year}-"
|
||||
@@ -46,10 +54,6 @@ class Product(ClubScopedModel):
|
||||
MERCHANDISE = "merchandise", _("Merchandise")
|
||||
DONATION = "donation", _("Donation")
|
||||
|
||||
class DiscountType(models.TextChoices):
|
||||
PERCENTAGE = "percentage", _("Percentage")
|
||||
FIXED_AMOUNT = "fixed_amount", _("Fixed amount")
|
||||
|
||||
name = models.CharField(_("name"), max_length=255)
|
||||
slug = models.SlugField(_("slug"), max_length=255, blank=True)
|
||||
|
||||
@@ -191,10 +195,6 @@ class OrderLine(UUIDModel):
|
||||
|
||||
|
||||
class Discount(ClubScopedModel):
|
||||
class DiscountType(models.TextChoices):
|
||||
PERCENTAGE = "percentage", _("Percentage")
|
||||
FIXED_AMOUNT = "fixed_amount", _("Fixed amount")
|
||||
|
||||
name = models.CharField(_("name"), max_length=255)
|
||||
slug = models.SlugField(_("slug"), max_length=255, blank=True)
|
||||
description = models.TextField(_("description"), blank=True)
|
||||
@@ -219,10 +219,6 @@ class Discount(ClubScopedModel):
|
||||
|
||||
|
||||
class AppliedDiscount(UUIDModel):
|
||||
class DiscountType(models.TextChoices):
|
||||
PERCENTAGE = "percentage", _("Percentage")
|
||||
FIXED_AMOUNT = "fixed_amount", _("Fixed amount")
|
||||
|
||||
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="applied_discounts", verbose_name=_("order"))
|
||||
discount = models.ForeignKey(Discount, on_delete=models.PROTECT, related_name="applied_discounts", verbose_name=_("discount"))
|
||||
|
||||
@@ -240,7 +236,7 @@ class AppliedDiscount(UUIDModel):
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
suffix = "%" if self.discount_type == self.DiscountType.PERCENTAGE else ""
|
||||
suffix = "%" if self.discount_type == DiscountType.PERCENTAGE else ""
|
||||
return f"{self.discount} - {self.discount_amount}{suffix}"
|
||||
|
||||
def clean(self):
|
||||
|
||||
@@ -21,6 +21,7 @@ from .models import (
|
||||
Cart,
|
||||
CartItem,
|
||||
Discount,
|
||||
DiscountType,
|
||||
Invoice,
|
||||
Order,
|
||||
OrderLine,
|
||||
@@ -251,12 +252,12 @@ class AppliedDiscountTests(ShopEntitiesTestBase):
|
||||
return AppliedDiscount.objects.create(**kwargs)
|
||||
|
||||
def test_str_percentage_shows_percent(self):
|
||||
applied = self.apply(discount_type=AppliedDiscount.DiscountType.PERCENTAGE)
|
||||
applied = self.apply(discount_type=DiscountType.PERCENTAGE)
|
||||
|
||||
self.assertEqual(str(applied), "Sibling - 10.00%")
|
||||
|
||||
def test_str_fixed_amount_has_no_percent(self):
|
||||
applied = self.apply(discount_type=AppliedDiscount.DiscountType.FIXED_AMOUNT)
|
||||
applied = self.apply(discount_type=DiscountType.FIXED_AMOUNT)
|
||||
|
||||
self.assertEqual(str(applied), "Sibling - 10.00")
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -33,8 +33,14 @@
|
||||
{% block extra %}{% endblock extra %}
|
||||
</head>
|
||||
|
||||
<body class="min-h-screen bg-base-200">
|
||||
<div class="navbar mb-4 border-b border-base-300 bg-base-100 px-6 shadow-sm">
|
||||
{% comment %}
|
||||
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">
|
||||
{% block brand %}{% endblock brand %}
|
||||
</div>
|
||||
@@ -65,25 +71,36 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if messages %}
|
||||
<div class="mx-auto mt-4 w-full space-y-2 px-4">
|
||||
{% 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 %}
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
{% comment %}
|
||||
The sidebar is a sibling of <main>, not inside it, so it sits outside the one
|
||||
scrolling region and stays put by construction. Pages with no menu (the auth
|
||||
screens) leave the block empty and <main> simply takes the full width.
|
||||
{% endcomment %}
|
||||
{% block menu %}{% endblock menu %}
|
||||
|
||||
<main class="mx-auto w-full py-4 px-8">
|
||||
{% block main %}{% endblock main %}
|
||||
</main>
|
||||
<main class="flex-1 overflow-y-auto">
|
||||
<div class="mx-auto w-full px-8 py-6">
|
||||
{% 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 border-2 {{ 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>
|
||||
// The button cycles light -> dark -> auto. "auto" removes the attribute and the
|
||||
|
||||
Reference in New Issue
Block a user