Compare commits

..

3 Commits

Author SHA1 Message Date
127d0e338e Modularize and streamline billing templates; replace _billing_form.html with reusable modals and shared partials, and update styles and interactions for consistency and clarity. billing, feature management, and forms
Refactored club detail templates to modularize common UI components. Standardized layout, interactions, and styles across admin, billing, and feature cards for consistency and reusability.
2026-07-16 16:44:48 +02:00
83caa233d7 Replace doughnut chart with pie chart in fees visualization and clean up unused CSS styles. 2026-07-16 08:38:17 +02:00
fc7a349f8f Add "Open" button to club detail for direct access to club subdomain 2026-07-16 00:12:29 +02:00
25 changed files with 597 additions and 1142 deletions

View File

@@ -114,7 +114,7 @@ def waive(due: Due, *, note: str = "") -> Due:
return due return due
def owing_dues(today: date | None = None): def owing_dues():
return Due.objects.filter(status__in=Due.OWING) return Due.objects.filter(status__in=Due.OWING)

View File

@@ -1,5 +1,7 @@
from django.contrib import messages
from django.contrib.auth.mixins import UserPassesTestMixin from django.contrib.auth.mixins import UserPassesTestMixin
from django.http import Http404 from django.http import Http404
from django.shortcuts import redirect
class PlatformStaffRequiredMixin(UserPassesTestMixin): class PlatformStaffRequiredMixin(UserPassesTestMixin):
@@ -38,3 +40,21 @@ class PlatformSuperuserRequiredMixin(PlatformStaffRequiredMixin):
def test_func(self): def test_func(self):
return self.request.user.is_superuser 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():
messages.error(self.request, " ".join(error))
return redirect(self.invalid_redirect_url_name, **self.get_invalid_redirect_kwargs())

View File

@@ -155,6 +155,20 @@ def onboarding_funnel():
] ]
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(): def flag_adoption():
"""Clubs per feature flag. `everyone` overrides club targeting, so a flag set that """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`.""" way is on (or off) everywhere and its club count says nothing — hence `overridden`."""

View File

@@ -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>

View File

@@ -0,0 +1,43 @@
{% load lucide %}
{% comment %}
Club-scoped admins, and the form to add one. Included with `club`, `admins` 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>
<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-error btn-outline btn-sm gap-1" 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>

View 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 %}

View 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>

View File

@@ -100,7 +100,7 @@
{% elif club.covered_until %} {% elif club.covered_until %}
<span class="badge badge-success">paid</span> <span class="badge badge-success">paid</span>
{% else %} {% else %}
<span class="badge badge-ghost badge-outline">&mdash;</span> -
{% endif %} {% endif %}
</div> </div>
{% else %} {% else %}

View 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 %}

View 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>

View File

@@ -1,5 +1,5 @@
{% extends "controlpanel/base.html" %} {% extends "controlpanel/base.html" %}
{% load lucide ui %} {% load lucide %}
{% block heading %}Grant platform access{% endblock heading %} {% block heading %}Grant platform access{% endblock heading %}
@@ -11,24 +11,7 @@
</div> </div>
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% for error in form.non_field_errors %} {% include "controlpanel/_form_fields.html" %}
<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"> <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> <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> <button class="btn btn-primary gap-2" type="submit">{% lucide "user-plus" size=16 %} Grant access</button>

View File

@@ -1,28 +1,30 @@
{% extends "controlpanel/base.html" %} {% extends "controlpanel/base.html" %}
{% load lucide %} {% load lucide ui %}
{% block heading %}Billing{% endblock heading %} {% block heading %}Billing{% endblock heading %}
{% block subheading %}<p class="text-sm opacity-70">What the platform charges its clubs.</p>{% endblock subheading %}
{% block actions %} {% 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 %} {% endblock actions %}
{% block panel %} {% 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 mb-6 bg-base-100 shadow">
<div class="card-body"> <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 %} {% comment %}
Prices are dated, not edited. A rate change is a new row with a future 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, 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. so raising the price cannot rewrite an invoice you have already sent.
{% endcomment %} {% 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"> <div class="overflow-x-auto">
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th>Tier</th> <th>Plan</th>
<th class="text-right">Clubs</th> <th class="text-right">Clubs</th>
<th>Prices</th> <th>Prices</th>
<th></th> <th></th>
@@ -34,7 +36,8 @@
<td> <td>
<div class="font-medium">{{ tier.name }}</div> <div class="font-medium">{{ tier.name }}</div>
{% if not tier.is_active %}<span class="badge badge-ghost badge-xs">Retired</span>{% endif %} {% 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>
<td class="text-right tabular-nums">{{ tier.club_count }}</td> <td class="text-right tabular-nums">{{ tier.club_count }}</td>
<td> <td>
@@ -48,14 +51,14 @@
<span class="badge badge-error badge-sm">No price — cannot be billed</span> <span class="badge badge-error badge-sm">No price — cannot be billed</span>
{% endfor %} {% endfor %}
</td> </td>
<td class="text-right"> <td class="flex flex-row gap-2 justify-end">
<a class="btn btn-ghost btn-xs gap-1" href="{% url 'controlpanel:tier_price_create' tier.pk %}">{% lucide "euro" size=14 %} New price</a> <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>
<a class="btn btn-ghost btn-xs gap-1" href="{% url 'controlpanel:tier_update' tier.pk %}">{% lucide "pencil" size=14 %} Edit</a> <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> </td>
</tr> </tr>
{% empty %} {% empty %}
<tr> <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> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -64,6 +67,15 @@
</div> </div>
</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 bg-base-100 shadow">
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-base">{% lucide "receipt-euro" size=18 %} Owed</h2> <h2 class="card-title text-base">{% lucide "receipt-euro" size=18 %} Owed</h2>
@@ -74,7 +86,7 @@
<th>Club</th> <th>Club</th>
<th>Period</th> <th>Period</th>
<th class="text-right">Owed</th> <th class="text-right">Owed</th>
<th>Status</th> <th class="text-right">Status</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -90,18 +102,18 @@
<div class="text-xs opacity-60">Grace to {{ due.grace_until|date:"j M Y" }}</div> <div class="text-xs opacity-60">Grace to {{ due.grace_until|date:"j M Y" }}</div>
</td> </td>
<td class="text-right font-semibold tabular-nums">€{{ due.balance|floatformat:2 }}</td> <td class="text-right font-semibold tabular-nums">€{{ due.balance|floatformat:2 }}</td>
<td> <td class="text-right">
{% if due.grace_until < today %} {% if due.grace_until < today %}
<span class="badge badge-error gap-1">{% lucide "triangle-alert" size=12 %} Overdue</span> <span class="badge badge-error gap-1">{% lucide "triangle-alert" size=12 %} Overdue</span>
{% elif due.period_end < today %} {% elif due.period_end < today %}
<span class="badge badge-warning gap-1">{% lucide "hourglass" size=12 %} In grace</span> <span class="badge badge-warning gap-1">{% lucide "hourglass" size=12 %} In grace</span>
{% else %} {% else %}
<span class="badge badge-ghost">{{ due.get_status_display }}</span> <span class="badge badge-outline">{{ due.get_status_display }}</span>
{% endif %} {% endif %}
</td> </td>
<td class="text-right"> <td class="text-right flex flex-row gap-2 justify-end">
<a class="btn btn-primary btn-xs gap-1" href="{% url 'controlpanel:due_pay' due.pk %}">{% lucide "banknote" size=14 %} Record payment</a> <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-ghost btn-xs gap-1" href="{% url 'controlpanel:due_invoice' due.pk %}">{% lucide "file-text" size=14 %} Invoice</a> <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> </td>
</tr> </tr>
{% empty %} {% empty %}
@@ -114,4 +126,10 @@
</div> </div>
</div> </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 %} {% endblock panel %}

View File

@@ -1,5 +1,5 @@
{% extends "controlpanel/base.html" %} {% extends "controlpanel/base.html" %}
{% load lucide ui %} {% load lucide %}
{% block heading %}Add an admin to {{ club }}{% endblock heading %} {% block heading %}Add an admin to {{ club }}{% endblock heading %}
@@ -11,21 +11,7 @@
</div> </div>
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% for error in form.non_field_errors %} {% include "controlpanel/_form_fields.html" %}
<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"> <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> <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> <button class="btn btn-primary" type="submit">Grant admin</button>

View File

@@ -24,6 +24,7 @@
{% block actions %} {% block actions %}
<a class="btn btn-outline 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 %} {% if club.is_archived %}
<form method="post" action="{% url 'controlpanel:club_restore' club.pk %}"> <form method="post" action="{% url 'controlpanel:club_restore' club.pk %}">
{% csrf_token %} {% csrf_token %}
@@ -180,183 +181,9 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<div class="card mb-6 bg-base-100 shadow"> {% include "controlpanel/_club_features_card.html" %}
<div class="card-body"> {% include "controlpanel/_club_billing_card.html" %}
<div class="flex items-center justify-between"> {% include "controlpanel/_club_admins_card.html" %}
<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>
<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 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">
{% 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-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>
{% 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">{% lucide "shield-user" size=18 %} 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-error btn-outline btn-sm gap-1" 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>
{% endblock panel %} {% endblock panel %}
{% block extra_body %} {% block extra_body %}
@@ -396,7 +223,7 @@
// Colour carries the meaning here — unpaid must read as a problem, waived must // 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. // not — so the slices are pinned to the semantic theme colours, in order.
const fees = new Chart(document.getElementById("fees-chart"), { const fees = new Chart(document.getElementById("fees-chart"), {
type: "doughnut", type: "pie",
data: { data: {
labels: data.fees.map((slice) => slice.label), labels: data.fees.map((slice) => slice.label),
datasets: [ datasets: [

View File

@@ -16,14 +16,21 @@
<div class="card mb-6 bg-base-100 shadow {% if maintenance.is_active %}border-l-4 border-error{% endif %}"> <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="card-body">
<div class="flex flex-wrap items-start justify-between gap-4"> <div class="flex flex-wrap items-start justify-between gap-4">
<div> <div class="w-full">
<h2 class="card-title text-base">{% lucide "wrench" size=18 %} Maintenance mode</h2> <h2 class="card-title text-base mb-2">{% lucide "wrench" size=18 %} Maintenance mode</h2>
{% if maintenance.is_active %} {% 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> <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 %}. <div>&middot;</div>
</p> <div>since {{ maintenance.started_at|date:"j M Y, H:i" }}{% if maintenance.started_by %} by {{ maintenance.started_by.email }}{% endif %}</div>
{% if maintenance.message %}<p class="mt-1 text-sm opacity-70">“{{ maintenance.message }}”</p>{% endif %} </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 %} {% else %}
<p class="text-sm opacity-70"> <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. Closes every club subdomain and stands the scheduled jobs down. The control panel and the sign-in screens stay open.
@@ -35,12 +42,12 @@
<form class="mt-2" method="post" action="{% url 'controlpanel:maintenance' %}"> <form class="mt-2" method="post" action="{% url 'controlpanel:maintenance' %}">
{% csrf_token %} {% csrf_token %}
{% if not maintenance.is_active %} {% 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 }}"> <label class="label" for="{{ maintenance_form.message.id_for_label }}">
<span class="label-text">{{ maintenance_form.message.label }}</span> <span class="label-text">{{ maintenance_form.message.label }}</span>
</label> </label>
{{ maintenance_form.message|daisy }} {{ 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> </div>
<button class="btn btn-error gap-2" type="submit">{% lucide "lock" size=16 %} Close the platform</button> <button class="btn btn-error gap-2" type="submit">{% lucide "lock" size=16 %} Close the platform</button>
{% else %} {% else %}

View File

@@ -1,5 +1,5 @@
{% extends "controlpanel/base.html" %} {% extends "controlpanel/base.html" %}
{% load lucide ui %} {% load lucide %}
{% block heading %}{% if object %}Edit {{ object.name }}{% else %}New feature{% endif %}{% endblock heading %} {% block heading %}{% if object %}Edit {{ object.name }}{% else %}New feature{% endif %}{% endblock heading %}
@@ -8,21 +8,7 @@
<div class="card-body"> <div class="card-body">
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% for error in form.non_field_errors %} {% include "controlpanel/_form_fields.html" %}
<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"> <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> <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> <button class="btn btn-primary" type="submit">Save</button>

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -99,6 +99,17 @@ def excluded(field, names):
return field.name in (names or "").split(",") 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 @register.filter
def daisy(field, css=None): def daisy(field, css=None):
"""Render a bound form field with the right daisyUI classes. """Render a bound form field with the right daisyUI classes.

View File

@@ -1297,7 +1297,9 @@ class BillingFormRenderTests(ControlPanelTestBase):
self.tier = Tier.objects.create(name="Standard") 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")) 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) subscribe(self.club, self.tier)
due = self.club.dues.first() due = self.club.dues.first()
@@ -1309,17 +1311,33 @@ class BillingFormRenderTests(ControlPanelTestBase):
reverse("controlpanel:club_open_period", args=[self.club.pk]), reverse("controlpanel:club_open_period", args=[self.club.pk]),
reverse("controlpanel:due_pay", args=[due.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) subscribe(self.club, self.tier)
due = self.club.dues.first() due = self.club.dues.first()
record_payment(due, Decimal("200.00")) record_payment(due, Decimal("200.00"))
due.refresh_from_db() 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): 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"}) self.client.post(reverse("controlpanel:tier_update", args=[self.tier.pk]), {"name": "Standard plus", "description": "", "is_active": "on"})

View File

@@ -1,3 +1,5 @@
from contextlib import contextmanager
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db.models import Count from django.db.models import Count
@@ -5,6 +7,7 @@ from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
from django.utils import timezone 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 django.views.generic import CreateView, DetailView, FormView, ListView, TemplateView, UpdateView, View
from waffle import get_waffle_flag_model, get_waffle_switch_model from waffle import get_waffle_flag_model, get_waffle_switch_model
@@ -16,7 +19,7 @@ from club.models import Club, ClubRole
from features.models import Maintenance from features.models import Maintenance
from .forms import ClubAdminForm, ClubForm, DuePaymentForm, FlagForm, MaintenanceForm, OpenPeriodForm, PlatformAdminForm, SubscriptionForm, TierForm, TierPriceForm from .forms import ClubAdminForm, ClubForm, DuePaymentForm, FlagForm, MaintenanceForm, OpenPeriodForm, PlatformAdminForm, SubscriptionForm, TierForm, TierPriceForm
from .mixins import PlatformStaffRequiredMixin, PlatformSuperuserRequiredMixin from .mixins import PlatformStaffRequiredMixin, PlatformSuperuserRequiredMixin, RedirectOnInvalidMixin
from .services.admins import grant_club_admin, revoke_club_admin from .services.admins import grant_club_admin, revoke_club_admin
from .services.platform_admins import ( from .services.platform_admins import (
PlatformAdminError, PlatformAdminError,
@@ -25,12 +28,26 @@ from .services.platform_admins import (
revoke_platform_access, revoke_platform_access,
set_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() Flag = get_waffle_flag_model()
Switch = get_waffle_switch_model() Switch = get_waffle_switch_model()
@contextmanager
def suppress_billing_errors(request):
"""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:
messages.error(request, str(error))
class DashboardView(PlatformStaffRequiredMixin, TemplateView): class DashboardView(PlatformStaffRequiredMixin, TemplateView):
template_name = "controlpanel/dashboard.html" template_name = "controlpanel/dashboard.html"
@@ -107,16 +124,29 @@ class ClubDetailView(PlatformStaffRequiredMixin, DetailView):
context_object_name = "club" context_object_name = "club"
def get_context_data(self, **kwargs): 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( return super().get_context_data(
nav="clubs", nav="clubs",
groups=club_statistics(self.object), groups=club_statistics(self.object),
attention=club_attention(self.object), attention=club_attention(self.object),
charts=club_charts(self.object), charts=club_charts(self.object),
subscription=getattr(self.object, "subscription", None), subscription=subscription,
dues=self.object.dues.select_related("tier", "invoice").prefetch_related("payments"), dues=dues,
today=timezone.localdate(), today=timezone.localdate(),
admins=ClubRole.objects.filter(club=self.object, role=ClubRole.Roles.ADMIN).select_related("member", "member__user"), admins=ClubRole.objects.filter(club=self.object, role=ClubRole.Roles.ADMIN).select_related("member", "member__user"),
flags=flags_for_club(self.object), 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, **kwargs,
) )
@@ -165,20 +195,6 @@ class ClubAdminRemoveView(PlatformStaffRequiredMixin, View):
return redirect("controlpanel:club_detail", pk=pk) 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): class ClubFeatureToggleView(PlatformStaffRequiredMixin, View):
"""Turn a feature on or off for one club.""" """Turn a feature on or off for one club."""
@@ -328,56 +344,74 @@ class BillingView(PlatformStaffRequiredMixin, TemplateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
today = timezone.localdate() 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( return super().get_context_data(
nav="billing", nav="billing",
tiers=Tier.objects.prefetch_related("prices").annotate(club_count=Count("subscriptions")), tiers=tiers,
owing=Due.objects.filter(status__in=Due.OWING).select_related("club", "tier").order_by("grace_until"), tier_form=TierForm(),
owing=owing,
today=today, today=today,
**kwargs, **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 model = Tier
form_class = TierForm form_class = TierForm
template_name = "controlpanel/tier_form.html" http_method_names = ["post"]
invalid_redirect_url_name = "controlpanel:billing"
def get_context_data(self, **kwargs):
return super().get_context_data(nav="billing", **kwargs)
def get_success_url(self): def get_success_url(self):
messages.success(self.request, f"Tier “{self.object}” created. Give it a price before billing anyone.") messages.success(self.request, f"Tier “{self.object}” created. Give it a price before billing anyone.")
return reverse("controlpanel:billing") 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 model = Tier
form_class = TierForm form_class = TierForm
template_name = "controlpanel/tier_form.html" http_method_names = ["post"]
invalid_redirect_url_name = "controlpanel:billing"
def get_context_data(self, **kwargs):
return super().get_context_data(nav="billing", **kwargs)
def get_success_url(self): def get_success_url(self):
messages.success(self.request, f"Tier “{self.object}” updated.") messages.success(self.request, f"Tier “{self.object}” updated.")
return reverse("controlpanel:billing") 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 """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 model = TierPrice
form_class = TierPriceForm form_class = TierPriceForm
template_name = "controlpanel/tier_price_form.html" http_method_names = ["post"]
invalid_redirect_url_name = "controlpanel:billing"
@property @property
def tier(self): def tier(self):
return get_object_or_404(Tier, pk=self.kwargs["pk"]) 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): def form_valid(self, form):
form.instance.tier = self.tier form.instance.tier = self.tier
response = super().form_valid(form) response = super().form_valid(form)
@@ -388,16 +422,24 @@ class TierPriceCreateView(PlatformStaffRequiredMixin, CreateView):
return reverse("controlpanel:billing") return reverse("controlpanel:billing")
class SubscribeClubView(PlatformStaffRequiredMixin, FormView): class SubscribeClubView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, FormView):
"""Put a club on a tier, which opens its first period.""" """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 form_class = SubscriptionForm
template_name = "controlpanel/subscription_form.html" http_method_names = ["post"]
invalid_redirect_url_name = "controlpanel:club_detail"
@property @property
def club(self): def club(self):
return get_object_or_404(Club, pk=self.kwargs["pk"]) 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): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() kwargs = super().get_form_kwargs()
subscription = getattr(self.club, "subscription", None) subscription = getattr(self.club, "subscription", None)
@@ -405,13 +447,10 @@ class SubscribeClubView(PlatformStaffRequiredMixin, FormView):
kwargs["instance"] = subscription kwargs["instance"] = subscription
return kwargs 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): def form_valid(self, form):
club = self.club club = self.club
existing = getattr(club, "subscription", None) existing = getattr(club, "subscription", None)
try: with suppress_billing_errors(self.request):
if existing: if existing:
# Changing tier does not re-bill: the current period keeps the amount it was # 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. # issued at, and the new rate applies from the next one.
@@ -422,29 +461,29 @@ class SubscribeClubView(PlatformStaffRequiredMixin, FormView):
else: else:
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"]) subscribe(club, form.cleaned_data["tier"], start=form.cleaned_data.get("start"), auto_archive=form.cleaned_data["auto_archive"], auto_renew=form.cleaned_data["auto_renew"])
messages.success(self.request, f"{club} is on {form.cleaned_data['tier']}. Its first period is open.") messages.success(self.request, f"{club} is on {form.cleaned_data['tier']}. Its first period is open.")
except BillingError as error:
messages.error(self.request, str(error))
return redirect("controlpanel:club_detail", pk=club.pk) 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 form_class = DuePaymentForm
template_name = "controlpanel/payment_form.html" http_method_names = ["post"]
invalid_redirect_url_name = "controlpanel:club_detail"
@property @property
def due(self): def due(self):
return get_object_or_404(Due.objects.select_related("club", "tier"), pk=self.kwargs["pk"]) return get_object_or_404(Due.objects.select_related("club", "tier"), pk=self.kwargs["pk"])
def get_initial(self): def get_invalid_redirect_kwargs(self):
return {"amount": self.due.balance} return {"pk": self.due.club_id}
def get_context_data(self, **kwargs):
return super().get_context_data(nav="clubs", due=self.due, **kwargs)
def form_valid(self, form): def form_valid(self, form):
due = self.due due = self.due
try: with suppress_billing_errors(self.request):
record_payment( record_payment(
due, due,
form.cleaned_data["amount"], form.cleaned_data["amount"],
@@ -456,8 +495,6 @@ class RecordPaymentView(PlatformStaffRequiredMixin, FormView):
) )
due.refresh_from_db() due.refresh_from_db()
messages.success(self.request, f"{form.cleaned_data['amount']} recorded. {due.get_status_display().capitalize()} — €{due.balance} outstanding.") 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))
return redirect("controlpanel:club_detail", pk=due.club_id) return redirect("controlpanel:club_detail", pk=due.club_id)
@@ -465,37 +502,37 @@ class RecordPaymentView(PlatformStaffRequiredMixin, FormView):
class WaiveDueView(PlatformStaffRequiredMixin, View): class WaiveDueView(PlatformStaffRequiredMixin, View):
def post(self, request, pk): def post(self, request, pk):
due = get_object_or_404(Due, pk=pk) due = get_object_or_404(Due, pk=pk)
try: with suppress_billing_errors(request):
waive(due) 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.") 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))
return redirect("controlpanel:club_detail", pk=due.club_id) return redirect("controlpanel:club_detail", pk=due.club_id)
class OpenPeriodView(PlatformStaffRequiredMixin, FormView): class OpenPeriodView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, FormView):
"""Renew a club, or reactivate an archived one.""" """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 form_class = OpenPeriodForm
template_name = "controlpanel/period_form.html" http_method_names = ["post"]
invalid_redirect_url_name = "controlpanel:club_detail"
@property @property
def club(self): def club(self):
return get_object_or_404(Club, pk=self.kwargs["pk"]) return get_object_or_404(Club, pk=self.kwargs["pk"])
def get_context_data(self, **kwargs): def get_invalid_redirect_kwargs(self):
club = self.club return {"pk": self.club.pk}
return super().get_context_data(nav="clubs", club=club, next_start=next_period_start(club), **kwargs)
def form_valid(self, form): def form_valid(self, form):
club = self.club club = self.club
start = form.cleaned_data.get("start") start = form.cleaned_data.get("start")
try: with suppress_billing_errors(self.request):
due = reactivate(club, start=start) if club.is_archived else open_period(club, start=start) 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}.") 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))
return redirect("controlpanel:club_detail", pk=club.pk) return redirect("controlpanel:club_detail", pk=club.pk)

File diff suppressed because it is too large Load Diff