Compare commits

...

5 Commits

40 changed files with 5667 additions and 728 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)

35
controlpanel/messages.py Normal file
View 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)

View File

@@ -1,5 +1,8 @@
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
from .messages import notify
class PlatformStaffRequiredMixin(UserPassesTestMixin): class PlatformStaffRequiredMixin(UserPassesTestMixin):
@@ -38,3 +41,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():
notify(self.request, f"e|Couldn't save|{' '.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,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 %}

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 %}
@@ -122,7 +122,7 @@
</td> </td>
<td> <td>
<a class="btn btn-sm gap-2" href="{% url "controlpanel:club_detail" club.pk %}">{% lucide "pencil" size=14 %} Edit</a> <a class="btn btn-sm btn-outline gap-2" href="{% url "controlpanel:club_detail" club.pk %}">{% lucide "pencil" size=14 %} Edit</a>
</td> </td>
</tr> </tr>
{% empty %} {% empty %}

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

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

@@ -31,7 +31,7 @@
{# Superusers only, exactly as the view is gated: a link staff cannot follow is a lie. #} {# Superusers only, exactly as the view is gated: a link staff cannot follow is a lie. #}
<li> <li>
<a class="{% if nav == 'admins' %}menu-active{% endif %}" href="{% url 'controlpanel:admins' %}"> <a class="{% if nav == 'admins' %}menu-active{% endif %}" href="{% url 'controlpanel:admins' %}">
{% lucide "user-cog" size=16 %} Admins {% lucide "user-cog" size=16 %} Platform admins
</a> </a>
</li> </li>
{% endif %} {% endif %}

View File

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

View File

@@ -1,5 +1,5 @@
{% extends "controlpanel/base.html" %} {% extends "controlpanel/base.html" %}
{% load lucide %} {% load lucide ui %}
{% block heading %}Platform admins{% endblock heading %} {% block heading %}Platform admins{% endblock heading %}
@@ -8,10 +8,13 @@
{% endblock subheading %} {% endblock subheading %}
{% block actions %} {% 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 %} {% endblock actions %}
{% block panel %} {% 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 bg-base-100 shadow">
<div class="card-body"> <div class="card-body">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
@@ -30,15 +33,16 @@
<tr> <tr>
<td> <td>
<div class="font-medium">{{ admin.email }}</div> <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>
<td> <td>
<form method="post" action="{% url 'controlpanel:admin_update' admin.pk %}"> <form method="post" action="{% url 'controlpanel:admin_update' admin.pk %}">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="is_staff" value="{% if admin.is_staff %}0{% else %}1{% endif %}"> <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 %}"> <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"> <button class="btn btn-sm gap-1 {% if admin.is_staff %}btn-success{% else %}btn-outline{% endif %}" type="submit">
{% if admin.is_staff %}{% lucide "check" size=14 %} Yes{% else %}No{% endif %} {% if admin.is_staff %}{% lucide "user" size=14 %} Yes{% else %}{% lucide "x" size=14 %} No{% endif %}
</button> </button>
</form> </form>
</td> </td>
@@ -47,17 +51,14 @@
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="is_staff" value="{% if admin.is_staff %}1{% else %}0{% endif %}"> <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 %}"> <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"> <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 %}No{% endif %} {% if admin.is_superuser %}{% lucide "shield" size=14 %} Yes{% else %}{% lucide "x" size=14 %} No{% endif %}
</button> </button>
</form> </form>
</td> </td>
<td class="opacity-70">{{ admin.last_login|date:"j M Y"|default:"Never" }}</td> <td class="opacity-70">{{ admin.last_login|date:"j M Y"|default:"Never" }}</td>
<td class="text-right"> <td class="text-right">
<form method="post" action="{% url 'controlpanel:admin_revoke' admin.pk %}"> <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>
{% csrf_token %}
<button class="btn btn-ghost btn-xs gap-1 text-error" type="submit">{% lucide "user-minus" size=14 %} Revoke</button>
</form>
</td> </td>
</tr> </tr>
{% empty %} {% empty %}
@@ -68,6 +69,12 @@
</tbody> </tbody>
</table> </table>
</div> </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>
</div> </div>
{% endblock panel %} {% endblock panel %}

View File

@@ -28,8 +28,10 @@
<a class="btn btn-sm gap-2 btn-error btn-soft" href="{% url 'controlpanel:features' %}">{% lucide "unlock" size=16 %} Reopen platform</a> <a class="btn btn-sm gap-2 btn-error btn-soft" href="{% url 'controlpanel:features' %}">{% lucide "unlock" size=16 %} Reopen platform</a>
</div> </div>
{% endif %} {% endif %}
<div class="mb-6 flex flex-wrap items-center justify-between gap-3"> <div class="mb-6 flex flex-wrap flex-row items-center justify-between gap-3">
<div class="flex flex-col gap-2"> {% block logo %}{% endblock logo %}
<div class="flex flex-col gap-2 grow">
<h1 class="text-3xl font-bold"> <h1 class="text-3xl font-bold">
{% block heading %}Control panel{% endblock heading %} {% block heading %}Control panel{% endblock heading %}
</h1> </h1>

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

View File

@@ -1,19 +1,30 @@
{% extends "controlpanel/base.html" %} {% extends "controlpanel/base.html" %}
{% load static lucide %} {% 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 heading %}{{ club.name }}{% endblock heading %}
{% block subheading %} {% block subheading %}
<p class="text-sm opacity-70"> {{ club.slug }}.rosterchief.app
{{ club.slug }} {% if club.is_archived %}
{% if club.is_archived %} <span class="badge badge-warning badge-sm ml-2">Archived</span>
<span class="badge badge-warning badge-sm ml-2">Archived</span> {% endif %}
{% endif %}
</p>
{% endblock subheading %} {% endblock subheading %}
{% block actions %} {% 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 %} {% 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 %}
@@ -30,6 +41,7 @@
{% block panel %} {% block panel %}
{% if club.is_archived %} {% if club.is_archived %}
<div class="alert alert-warning mb-6"> <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> <span>This club is archived: its subdomain no longer resolves. Nothing has been deleted — restore it to bring it back.</span>
</div> </div>
{% endif %} {% endif %}
@@ -47,96 +59,91 @@
the club's setup, not a statistic: with nobody in a management position the access 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. service grants no authority over that team, so nobody can pick the squad.
{% endcomment %} {% endcomment %}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-6">
<div class="card bg-base-100 shadow {% if attention.outstanding %}border-l-4 border-error{% endif %}"> {% 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="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "banknote" size=16 %} Outstanding</div> <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-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 class="text-xs opacity-60">{{ attention.unpaid_members }} member{{ attention.unpaid_members|pluralize }} unpaid this season</div>
</div> </div>
</div> </div>{% endcomment %}
<div class="card bg-base-100 shadow {% if attention.teams_without_manager %}border-l-4 border-error{% endif %}"> <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="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="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-x" size=16 %} Teams without coach</div>
<div class="text-3xl font-bold tabular-nums">{{ attention.teams_without_manager }}</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 class="text-xs opacity-60">Teams nobody can pick a squad for</div>
</div> </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="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "user-minus" size=16 %} 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-3xl font-bold tabular-nums">{{ attention.unrostered }}</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 class="text-xs opacity-60">Active members on no team</div>
</div> </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="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "clock" size=16 %} Pending</div> <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "clock" size=16 %} Pending</div>
<div class="text-3xl font-bold tabular-nums">{{ attention.pending_approvals }}</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 class="text-xs opacity-60">Memberships awaiting approval</div>
</div> </div>
</div> </div>
</div>
<div class="mb-6 grid gap-4 lg:grid-cols-4"> <div class="card bg-base-100 shadow border-l-4 border-info">
<div class="card bg-base-100 shadow"> <div class="card-body p-4">
<div class="card-body"> <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "sparkles" size=16 %} New members</div>
<h2 class="card-title text-base">{% lucide "sparkles" size=18 %} New members</h2> <div class="text-4xl font-bold tabular-nums font-mono">{{ attention.new_members }}</div>
<div class="text-4xl font-bold tabular-nums">{{ attention.new_members }}</div>
{# First season at this club — someone returning after a year away is a renewal. #} {# 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> </div>
<div class="card bg-base-100 shadow"> <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"> <div class="card-body p-4">
<h2 class="card-title text-base">{% lucide "repeat" size=18 %} Renewal</h2> <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "repeat" size=16 %} Renewal rate</div>
{% if attention.renewal_rate is None %} <div class="text-4xl font-bold tabular-nums font-mono">
{# No prior season to compare against: a first-season club has not failed to renew anyone. #} {% if attention.renewal_rate is None %}
<p class="text-sm opacity-60">No previous season to compare against yet.</p> N/A
{% else %} {% else %}
<div class="text-4xl font-bold tabular-nums">{{ attention.renewal_rate }}%</div> {{ attention.renewal_rate }}%
<p class="text-sm opacity-70">of last season's active members signed up again</p> {% endif %}
<progress class="progress progress-primary w-full" value="{{ attention.renewal_rate }}" max="100"></progress> </div>
{% endif %} <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> </div>
<div class="card bg-base-100 shadow"> <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"> <div class="card-body p-4">
<h2 class="card-title text-base">{% lucide "user-check" size=18 %} Attendance</h2> <div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-check" size=16 %} Attendance rate</div>
{% if attention.attendance.turnout is None %} <div class="text-4xl font-bold tabular-nums font-mono">
<p class="text-sm opacity-60">No past events with responses this season.</p> {% if attention.attendance.turnout is None %}
{% else %} N/A
<div class="text-4xl font-bold tabular-nums">{{ attention.attendance.turnout }}%</div> {% else %}
<p class="text-sm opacity-70">turnout of those who answered</p> {{ attention.attendance.turnout }}%
<p class="mt-2 text-sm"> {% endif %}
{# The leading indicator: it measures whether members use the app at all. #} </div>
<span class="font-semibold {% if attention.attendance.no_response > 30 %}text-warning{% endif %}">{{ attention.attendance.no_response }}%</span> <div class="text-xs opacity-60">
<span class="opacity-70">never responded</span> {% if attention.attendance.turnout is None %}
</p> No events this season
{% endif %} {% else %}
</div> <progress class="progress w-full {% if attention.attendance.turnout < 30 %}progress-error{% elif attention.attendance.turnout < 65 %}progress-warning{% else %}progress-success{% endif %}"
</div> value="{{ attention.attendance.turnout }}" max="100"></progress>
{% endif %}
<div class="card bg-base-100 shadow"> </div>
<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> </div>
</div> </div>
</div> </div>
<div class="mb-6 grid gap-4 lg:grid-cols-2"> <div class="mb-6 grid gap-4 lg:grid-cols-2">
<div class="card bg-base-100 shadow"> <div class="card bg-base-100 shadow">
<div class="card-body"> <div class="card-body">
@@ -149,7 +156,7 @@
</div> </div>
<div class="card bg-base-100 shadow"> <div class="card bg-base-100 shadow">
<div class="card-body"> <div class="card-body">
<h2 class="card-title 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"> <div class="h-56">
<canvas id="fees-chart"></canvas> <canvas id="fees-chart"></canvas>
</div> </div>
@@ -157,7 +164,7 @@
</div> </div>
</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 %} {% for group in groups %}
<div class="card bg-base-100 shadow"> <div class="card bg-base-100 shadow">
<div class="card-body"> <div class="card-body">
@@ -166,7 +173,7 @@
{% for label, value in group.stats %} {% for label, value in group.stats %}
<div class="flex items-center justify-between py-2"> <div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{{ label }}</dt> <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" %}&euro;{% endif %}{{ value }}</dd>
</div> </div>
{% endfor %} {% endfor %}
</dl> </dl>
@@ -174,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-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_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>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>
{% endblock panel %} {% endblock panel %}
{% block extra_body %} {% block extra_body %}
@@ -372,17 +205,17 @@
data: { data: {
labels: data.signups.map((point) => point.month), labels: data.signups.map((point) => point.month),
datasets: [ datasets: [
{ label: "New", data: data.signups.map((point) => point.new), backgroundColor: css("--color-primary", "#4f46e5") }, {label: "New", data: data.signups.map((point) => point.new), backgroundColor: css("--color-primary", "#4f46e5")},
{ label: "Returning", data: data.signups.map((point) => point.returning), backgroundColor: css("--color-accent", "#0ea5e9") }, {label: "Returning", data: data.signups.map((point) => point.returning), backgroundColor: css("--color-accent", "#0ea5e9")},
], ],
}, },
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
plugins: { legend: { position: "bottom", labels: { color: ink } } }, plugins: {legend: {position: "bottom", labels: {color: ink}}},
scales: { scales: {
x: { stacked: true, ticks: { color: ink }, grid: { color: grid } }, x: {stacked: true, ticks: {color: ink}, grid: {color: grid}},
y: { stacked: true, beginAtZero: true, ticks: { color: ink, precision: 0 }, grid: { color: grid } }, y: {stacked: true, beginAtZero: true, ticks: {color: ink, precision: 0}, grid: {color: grid}},
}, },
}, },
}); });
@@ -390,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: [
@@ -403,7 +236,7 @@
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
plugins: { legend: { position: "right", labels: { color: ink } } }, plugins: {legend: {position: "right", labels: {color: ink}}},
}, },
}); });
@@ -416,7 +249,7 @@
new MutationObserver(() => { new MutationObserver(() => {
charts.forEach((chart) => chart.destroy()); charts.forEach((chart) => chart.destroy());
charts = render(); charts = render();
}).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); }).observe(document.documentElement, {attributes: true, attributeFilter: ["data-theme"]});
})(); })();
</script> </script>
{% endblock extra_body %} {% endblock extra_body %}

View File

@@ -4,28 +4,33 @@
{% block heading %}{% if object %}Edit {{ object }}{% else %}New club{% endif %}{% endblock heading %} {% block heading %}{% if object %}Edit {{ object }}{% else %}New club{% endif %}{% endblock heading %}
{% block panel %} {% 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"> <div class="card-body">
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
{% for error in form.non_field_errors %} {% for error in form.non_field_errors %}
<div class="alert alert-error my-2"> <div class="alert alert-error my-2">
<span>{{ error }}</span> <span>{{ error }}</span>
</div> </div>
{% endfor %} {% endfor %}
{% for field in form %}
<div class="form-control my-3 w-full"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<label class="label" for="{{ field.id_for_label }}"> {% for field in form %}
<span class="label-text">{{ field.label }}</span> <div class="my-3 w-full">
</label> <label class="label" for="{{ field.id_for_label }}">
{{ field|daisy }} <span class="label-text">{{ field.label }}</span>
{% if field.help_text %}<span class="label-text-alt mt-1 text-base-content/70">{{ field.help_text }}</span>{% endif %} </label>
{% for error in field.errors %}<span class="label-text-alt mt-1 text-error">{{ error }}</span>{% endfor %} {{ field|daisy }}
</div> {% if field.help_text and not field.errors %}<span class="label-text-alt mt-1 text-base-content/70">{{ field.help_text }}</span>{% endif %}
{% endfor %} {% for error in field.errors %}<span class="label-text-alt mt-1 text-error">{{ error }}</span>{% endfor %}
<div class="card-actions justify-end pt-2"> </div>
<a class="btn btn-outline gap-2" href="{% url 'controlpanel:club_list' %}">{% lucide "arrow-left" size=16 %} Cancel</a> {% endfor %}
<button class="btn btn-primary" type="submit">Save</button> </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> </div>
</form> </form>
</div> </div>

View File

@@ -5,9 +5,9 @@
{% block actions %} {% block actions %}
{% if show_archived %} {% 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 %} {% 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 %} {% endif %}
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:club_create' %}">{% lucide "plus" size=16 %} New club</a> <a class="btn btn-primary gap-2" href="{% url 'controlpanel:club_create' %}">{% lucide "plus" size=16 %} New club</a>
{% endblock actions %} {% endblock actions %}
@@ -15,12 +15,19 @@
{% block panel %} {% block panel %}
<form method="get" class="mb-4 flex gap-2"> <form method="get" class="mb-4 flex gap-2">
{% if show_archived %}<input type="hidden" name="archived" value="1">{% endif %} {% if show_archived %}<input type="hidden" name="archived" value="1">{% endif %}
<input type="search" <label class="input">
name="q" <span class="opacity-50">{% lucide "search" size=16 %}</span>
value="{{ search }}" <input type="search"
placeholder="Search clubs…" name="q"
class="input input-bordered w-full max-w-xs"> value="{{ search }}"
<button class="btn gap-2" type="submit">{% lucide "search" size=16 %} Search</button> 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> </form>
<div class="card bg-base-100 shadow"> <div class="card bg-base-100 shadow">
<div class="card-body"> <div class="card-body">

View File

@@ -91,7 +91,7 @@
<div> <div>
<div class="mb-1 flex items-center justify-between text-sm"> <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="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> </div>
<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 }}" <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> max="{{ funnel.0.count }}"></progress>

View File

@@ -4,10 +4,13 @@
{% block heading %}Features{% endblock heading %} {% block heading %}Features{% endblock heading %}
{% block actions %} {% 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 %} {% endblock actions %}
{% block panel %} {% 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 %} {% comment %}
The lock-down. Clubs get a maintenance page, the scheduled jobs stand down, and the 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 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 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 +45,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 %}
@@ -77,13 +87,13 @@
{% elif flag.everyone is False %} {% elif flag.everyone is False %}
<span class="badge badge-error">Off everywhere</span> <span class="badge badge-error">Off everywhere</span>
{% else %} {% else %}
<span class="badge badge-ghost">Per club</span> <span class="badge badge-info">Per club</span>
{% endif %} {% endif %}
</td> </td>
<td>{{ flag.clubs.count }}</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"> <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> </td>
</tr> </tr>
{% empty %} {% empty %}
@@ -94,6 +104,12 @@
</tbody> </tbody>
</table> </table>
</div> </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> </div>
<div class="card bg-base-100 shadow"> <div class="card bg-base-100 shadow">
@@ -106,7 +122,7 @@
{% for switch in switches %} {% for switch in switches %}
<tr> <tr>
<td class="font-mono font-medium">{{ switch.name }}</td> <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"> <td class="text-right">
<form method="post" action="{% url 'controlpanel:switch_toggle' switch.pk %}"> <form method="post" action="{% url 'controlpanel:switch_toggle' switch.pk %}">
{% csrf_token %} {% csrf_token %}

View File

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

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

@@ -18,11 +18,11 @@ DEFAULT_WIDGET_CLASS = "input input-bordered w-full"
#: Icon, default heading and daisyUI colour per message level. #: Icon, default heading and daisyUI colour per message level.
MESSAGE_ALERTS = { MESSAGE_ALERTS = {
"debug": ("bug", "Debug", "alert-info"), "debug": ("bug", "Debug", "alert-info border-info"),
"info": ("info", "Heads up", "alert-info"), "info": ("info", "Heads up", "alert-info border-info"),
"success": ("circle-check", "Done", "alert-success"), "success": ("circle-check", "Done", "alert-success border-success"),
"warning": ("triangle-alert", "Careful", "alert-warning"), "warning": ("triangle-alert", "Careful", "alert-warning border-warning"),
"error": ("circle-x", "Something went wrong", "alert-error"), "error": ("circle-x", "Something went wrong", "alert-error border-error"),
} }
DEFAULT_MESSAGE_ALERT = MESSAGE_ALERTS["info"] DEFAULT_MESSAGE_ALERT = MESSAGE_ALERTS["info"]
@@ -32,10 +32,11 @@ def as_alert(message):
"""Presentation for one Django message: icon, bold title, body, colour. """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 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 title comes from the level, unless the message carries one as ``extra_tags``.
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 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 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(",") 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

@@ -9,8 +9,9 @@ from django.conf import settings
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.contrib.messages.storage.base import Message from django.contrib.messages.storage.base import Message
from django.contrib.messages.storage.fallback import FallbackStorage
from django.core.cache import cache 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.urls import reverse
from django.utils import timezone from django.utils import timezone
from waffle import get_waffle_flag_model, get_waffle_switch_model from waffle import get_waffle_flag_model, get_waffle_switch_model
@@ -25,6 +26,7 @@ from members.models import Member
from shop.models import Order from shop.models import Order
from teams.models import Position, StaffAssignment, Team, TeamMembership from teams.models import Position, StaffAssignment, Team, TeamMembership
from .messages import LEVELS, notify
from .services.admins import grant_club_admin from .services.admins import grant_club_admin
from .services.platform_admins import PlatformAdminError, is_last_superuser, set_platform_access from .services.platform_admins import PlatformAdminError, is_last_superuser, set_platform_access
from .services.statistics import ( 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) 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): 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.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): def test_an_existing_member_is_promoted_rather_than_duplicated(self):
user = User.objects.create_user(email="existing@example.com", password="pw-secret-123") 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): def test_superuser_sees_the_admins_section(self):
self.assertEqual(self.client.get(reverse("controlpanel:admins")).status_code, 200) self.assertEqual(self.client.get(reverse("controlpanel:admins")).status_code, 200)
def test_the_grant_form_renders(self): def test_the_grant_form_is_post_only(self):
self.assertEqual(self.client.get(reverse("controlpanel:admin_add")).status_code, 200) # 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): 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"}) 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, "shop")
self.assertContains(response, "maintenance") self.assertContains(response, "maintenance")
def test_the_flag_forms_render(self): def test_the_flag_forms_are_post_only(self):
self.assertEqual(self.client.get(reverse("controlpanel:flag_create")).status_code, 200) # Reachable only through a modal on the features page: there is no standalone
self.assertEqual(self.client.get(reverse("controlpanel:flag_update", args=[self.flag.pk])).status_code, 200) # 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): def test_create_a_flag(self):
self.client.post(reverse("controlpanel:flag_create"), {"name": "news", "note": "News module", "percent": "", "everyone": ""}) 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])) 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): class MessageAlertTests(TestCase):
def alert(self, level, text, extra_tags=None): def alert(self, level, text, extra_tags=None):
return as_alert(Message(level, text, extra_tags=extra_tags)) return as_alert(Message(level, text, extra_tags=extra_tags))
def test_each_level_gets_its_own_icon_title_and_colour(self): 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.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.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): def test_extra_tags_override_the_title(self):
alert = self.alert(messages.SUCCESS, "Ajax United is live.", extra_tags="Club created") alert = self.alert(messages.SUCCESS, "Ajax United is live.", extra_tags="Club created")
self.assertEqual(alert["title"], "Club created") self.assertEqual(alert["title"], "Club created")
self.assertEqual(alert["body"], "Ajax United is live.") 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): 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): class MessageRenderingTests(ControlPanelTestBase):
def test_a_message_renders_as_a_soft_alert_with_icon_and_title(self): 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) 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, "alert alert-soft border-2 alert-warning border-warning")
self.assertContains(response, '<div class="font-bold">Careful</div>', html=False) self.assertContains(response, '<div class="font-bold">Club archived</div>', html=False)
self.assertContains(response, "<svg") # the lucide icon self.assertContains(response, "<svg") # the lucide icon
@@ -1297,7 +1358,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 +1372,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,10 +1,12 @@
from django.contrib import messages from contextlib import contextmanager
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
from django.http import HttpResponse 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 +18,8 @@ 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 .messages import notify
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, 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): class DashboardView(PlatformStaffRequiredMixin, TemplateView):
template_name = "controlpanel/dashboard.html" template_name = "controlpanel/dashboard.html"
@@ -74,12 +91,15 @@ class ClubCreateView(PlatformStaffRequiredMixin, CreateView):
def form_valid(self, form): def form_valid(self, form):
response = super().form_valid(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 return response
def get_success_url(self): def get_success_url(self):
return reverse("controlpanel:club_detail", args=[self.object.pk]) 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): class ClubUpdateView(PlatformStaffRequiredMixin, UpdateView):
model = Club model = Club
@@ -88,12 +108,15 @@ class ClubUpdateView(PlatformStaffRequiredMixin, UpdateView):
def form_valid(self, form): def form_valid(self, form):
response = super().form_valid(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 return response
def get_success_url(self): def get_success_url(self):
return reverse("controlpanel:club_detail", args=[self.object.pk]) 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): class ClubDetailView(PlatformStaffRequiredMixin, DetailView):
model = Club model = Club
@@ -101,16 +124,30 @@ 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"),
admin_form=ClubAdminForm(),
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,
) )
@@ -121,7 +158,7 @@ class ClubArchiveView(PlatformStaffRequiredMixin, View):
def post(self, request, pk): def post(self, request, pk):
club = get_object_or_404(Club, pk=pk) club = get_object_or_404(Club, pk=pk)
club.archive() 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) return redirect("controlpanel:club_detail", pk=club.pk)
@@ -129,24 +166,28 @@ class ClubRestoreView(PlatformStaffRequiredMixin, View):
def post(self, request, pk): def post(self, request, pk):
club = get_object_or_404(Club, pk=pk) club = get_object_or_404(Club, pk=pk)
club.restore() 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) 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 form_class = ClubAdminForm
template_name = "controlpanel/club_admin_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):
return super().get_context_data(nav="clubs", club=self.club, **kwargs) return {"pk": self.kwargs["pk"]}
def form_valid(self, form): def form_valid(self, form):
role = grant_club_admin(self.club, **form.cleaned_data) 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"]) 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) role = get_object_or_404(ClubRole, pk=role_pk, club_id=pk, role=ClubRole.Roles.ADMIN)
member = role.member member = role.member
revoke_club_admin(role) 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) 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."""
@@ -182,10 +209,10 @@ class ClubFeatureToggleView(PlatformStaffRequiredMixin, View):
if flag.clubs.filter(pk=club.pk).exists(): if flag.clubs.filter(pk=club.pk).exists():
flag.clubs.remove(club) 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: else:
flag.clubs.add(club) 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) return redirect("controlpanel:club_detail", pk=club.pk)
@@ -194,9 +221,16 @@ class FeatureListView(PlatformStaffRequiredMixin, TemplateView):
template_name = "controlpanel/features.html" template_name = "controlpanel/features.html"
def get_context_data(self, **kwargs): 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( return super().get_context_data(
nav="features", nav="features",
flags=Flag.objects.prefetch_related("clubs").order_by("name"), flags=flags,
flag_form=FlagForm(),
switches=Switch.objects.order_by("name"), switches=Switch.objects.order_by("name"),
maintenance=Maintenance.current(), maintenance=Maintenance.current(),
maintenance_form=MaintenanceForm(), maintenance_form=MaintenanceForm(),
@@ -210,45 +244,46 @@ class MaintenanceView(PlatformStaffRequiredMixin, View):
def post(self, request): def post(self, request):
if Maintenance.is_on(): if Maintenance.is_on():
Maintenance.stop() Maintenance.stop()
messages.success(request, "Maintenance ended. The clubs are back.") notify(request, "s|Maintenance ended|The clubs are back.")
else: else:
form = MaintenanceForm(request.POST) form = MaintenanceForm(request.POST)
message = form.cleaned_data["message"] if form.is_valid() else "" message = form.cleaned_data["message"] if form.is_valid() else ""
Maintenance.start(message=message, user=request.user) 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") 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 model = Flag
form_class = FlagForm form_class = FlagForm
template_name = "controlpanel/flag_form.html" http_method_names = ["post"]
success_url = None invalid_redirect_url_name = "controlpanel:features"
def get_context_data(self, **kwargs):
return super().get_context_data(nav="features", **kwargs)
def form_valid(self, form): def form_valid(self, form):
response = super().form_valid(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 return response
def get_success_url(self): def get_success_url(self):
return reverse("controlpanel:features") 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 model = Flag
form_class = FlagForm form_class = FlagForm
template_name = "controlpanel/flag_form.html" http_method_names = ["post"]
invalid_redirect_url_name = "controlpanel:features"
def get_context_data(self, **kwargs):
return super().get_context_data(nav="features", **kwargs)
def form_valid(self, form): def form_valid(self, form):
response = super().form_valid(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 return response
def get_success_url(self): def get_success_url(self):
@@ -262,7 +297,8 @@ class SwitchToggleView(PlatformStaffRequiredMixin, View):
switch = get_object_or_404(Switch, pk=pk) switch = get_object_or_404(Switch, pk=pk)
switch.active = not switch.active switch.active = not switch.active
switch.save() 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") return redirect("controlpanel:features")
@@ -270,19 +306,20 @@ class PlatformAdminListView(PlatformSuperuserRequiredMixin, TemplateView):
template_name = "controlpanel/admins.html" template_name = "controlpanel/admins.html"
def get_context_data(self, **kwargs): 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 form_class = PlatformAdminForm
template_name = "controlpanel/admin_form.html" http_method_names = ["post"]
invalid_redirect_url_name = "controlpanel:admins"
def get_context_data(self, **kwargs):
return super().get_context_data(nav="admins", **kwargs)
def form_valid(self, form): def form_valid(self, form):
user = grant_platform_access(form.cleaned_data["email"], is_superuser=form.cleaned_data["is_superuser"]) 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") return redirect("controlpanel:admins")
@@ -297,9 +334,9 @@ class PlatformAdminUpdateView(PlatformSuperuserRequiredMixin, View):
is_superuser=request.POST.get("is_superuser") == "1", is_superuser=request.POST.get("is_superuser") == "1",
) )
except PlatformAdminError as error: except PlatformAdminError as error:
messages.error(request, str(error)) notify(request, f"e|Couldn't update access|{error}")
else: 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") return redirect("controlpanel:admins")
@@ -309,9 +346,9 @@ class PlatformAdminRevokeView(PlatformSuperuserRequiredMixin, View):
try: try:
revoke_platform_access(request.user, user) revoke_platform_access(request.user, user)
except PlatformAdminError as error: except PlatformAdminError as error:
messages.error(request, str(error)) notify(request, f"e|Couldn't revoke access|{error}")
else: 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") return redirect("controlpanel:admins")
@@ -322,76 +359,102 @@ 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.") notify(self.request, f"s|Plan created|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.") notify(self.request, f"s|Plan updated|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)
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 return response
def get_success_url(self): def get_success_url(self):
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)
@@ -399,46 +462,43 @@ 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, title="Couldn't change plan"):
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.
subscription = form.save(commit=False) subscription = form.save(commit=False)
subscription.club = club subscription.club = club
subscription.save() subscription.save()
messages.success(self.request, f"{club} is now on {subscription.tier}. The current period keeps the amount it was billed at.") notify(self.request, f"s|Plan changed|{club} is now on {subscription.tier}. The current period keeps the amount it was billed at.")
else: else:
subscribe(club, form.cleaned_data["tier"], start=form.cleaned_data.get("start"), auto_archive=form.cleaned_data["auto_archive"], 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.") notify(self.request, f"s|Billing started|{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, title="Couldn't record payment"):
record_payment( record_payment(
due, due,
form.cleaned_data["amount"], form.cleaned_data["amount"],
@@ -449,9 +509,7 @@ class RecordPaymentView(PlatformStaffRequiredMixin, FormView):
user=self.request.user, user=self.request.user,
) )
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.") notify(self.request, f"s|Payment recorded|{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)
@@ -459,37 +517,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, title="Couldn't waive period"):
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.") 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.")
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, title="Couldn't open period"):
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}.") notify(self.request, f"s|Period opened|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)
@@ -502,7 +560,7 @@ class InvoicePdfView(PlatformStaffRequiredMixin, View):
pdf = invoice_pdf(invoice) pdf = invoice_pdf(invoice)
except BillingError as error: except BillingError as error:
# The native PDF libraries are missing: say so rather than 500. # 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) return redirect("controlpanel:club_detail", pk=due.club_id)
response = HttpResponse(pdf, content_type="application/pdf") response = HttpResponse(pdf, content_type="application/pdf")

View File

@@ -3,13 +3,13 @@
from django.db import transaction from django.db import transaction
from django.utils import timezone 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): 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): def __init__(self, message, *, errors=None):
super().__init__(message) super().__init__(message)
@@ -21,12 +21,17 @@ def _is_empty(value):
@transaction.atomic @transaction.atomic
def submit_form(form, member, data, *, when=None): def submit_form(form, member, data, *, files=None, when=None):
"""Create a Submission (with Answers) for ``data`` or raise FormSubmissionError.""" """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() when = when or timezone.now()
_check_open(form, member, when) _check_open(form, member, when)
cleaned = _clean_answers(form, data) cleaned = _clean_answers(form, data, files)
submission = Submission.objects.create(form=form, member=member) submission = Submission.objects.create(form=form, member=member)
Answer.objects.bulk_create([Answer(submission=submission, field=field, value=value) for field, value in cleaned]) 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.") raise FormSubmissionError("You have reached the maximum number of submissions for this form.")
def _clean_answers(form, data): def _clean_answers(form, data, files):
errors = {} bound_form = build_form(form, data=data, files=files or {})
cleaned = [] if not bound_form.is_valid():
errors = {key: list(messages) for key, messages in bound_form.errors.items()}
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:
raise FormSubmissionError("The submission has errors.", errors=errors) raise FormSubmissionError("The submission has errors.", errors=errors)
return cleaned
# Blank optional answers are validated (they may legitimately be empty) but not
def _validate_choice(field, raw): # stored — an Answer row exists only where the submitter actually said something.
if field.field_type == Field.FieldType.CHOICE: fields_by_key = {field.key: field for field in form.fields.filter(is_active=True)}
allowed = allowed_values(field) return [(fields_by_key[key], value) for key, value in bound_form.cleaned_data.items() if not _is_empty(value)]
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

View File

@@ -211,6 +211,24 @@ class SubmitFormTests(FormbuilderTestBase):
self.assertIn("size", ctx.exception.errors) 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): 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"]) field = Field.objects.create(form=self.form, key="days", label="Days", field_type=Field.FieldType.MULTICHOICE, required=False, order=3, options=["mon", "tue", "wed"])

View File

@@ -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) season = self.get_current_season(club)
_, membership_created = ClubMembership.objects.update_or_create( _, membership_created = ClubMembership.objects.update_or_create(
@@ -141,6 +141,19 @@ class MemberCsvImporter:
membership_created=membership_created, 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: def get_current_season(self, club) -> Season:
# Season.get_current() is tenant-scoped, so bind the row's club as the # Season.get_current() is tenant-scoped, so bind the row's club as the
# active tenant for the lookup. # active tenant for the lookup.

View File

@@ -554,8 +554,9 @@ class ImportMembersCsvCommandTests(TestCase):
self.assertFalse(Member.objects.filter(email="nofirst@example.com").exists()) self.assertFalse(Member.objects.filter(email="nofirst@example.com").exists())
def test_import_skips_row_when_club_has_no_current_season(self): 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 # The club exists but has no season, so the row is skipped and — because
# is atomic — the member and club creation roll back too. # the row is atomic — the member creation rolls back too.
Club.objects.create(name="New Club")
csv_path = self.write_csv( csv_path = self.write_csv(
"\n".join( "\n".join(
[ [
@@ -571,7 +572,45 @@ class ImportMembersCsvCommandTests(TestCase):
self.assertIn("No current season for club 'New Club'.", stderr) self.assertIn("No current season for club 'New Club'.", stderr)
self.assertIn("Rows skipped: 1.", stdout) self.assertIn("Rows skipped: 1.", stdout)
self.assertFalse(Member.objects.filter(email="jane@example.com").exists()) 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): class MemberImportResultTests(TestCase):

View File

@@ -11,6 +11,14 @@ from rosterchief.base import ClubScopedModel, UUIDModel, validate_club_scope
from teams.models import Position, Team 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): def next_scoped_number(instance, code):
"""Next per-club sequential number for the current year: ``<code>-<year>-<seq>``.""" """Next per-club sequential number for the current year: ``<code>-<year>-<seq>``."""
prefix = f"{code}-{timezone.now().year}-" prefix = f"{code}-{timezone.now().year}-"
@@ -46,10 +54,6 @@ class Product(ClubScopedModel):
MERCHANDISE = "merchandise", _("Merchandise") MERCHANDISE = "merchandise", _("Merchandise")
DONATION = "donation", _("Donation") DONATION = "donation", _("Donation")
class DiscountType(models.TextChoices):
PERCENTAGE = "percentage", _("Percentage")
FIXED_AMOUNT = "fixed_amount", _("Fixed amount")
name = models.CharField(_("name"), max_length=255) name = models.CharField(_("name"), max_length=255)
slug = models.SlugField(_("slug"), max_length=255, blank=True) slug = models.SlugField(_("slug"), max_length=255, blank=True)
@@ -191,10 +195,6 @@ class OrderLine(UUIDModel):
class Discount(ClubScopedModel): class Discount(ClubScopedModel):
class DiscountType(models.TextChoices):
PERCENTAGE = "percentage", _("Percentage")
FIXED_AMOUNT = "fixed_amount", _("Fixed amount")
name = models.CharField(_("name"), max_length=255) name = models.CharField(_("name"), max_length=255)
slug = models.SlugField(_("slug"), max_length=255, blank=True) slug = models.SlugField(_("slug"), max_length=255, blank=True)
description = models.TextField(_("description"), blank=True) description = models.TextField(_("description"), blank=True)
@@ -219,10 +219,6 @@ class Discount(ClubScopedModel):
class AppliedDiscount(UUIDModel): 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")) 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")) 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): 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}" return f"{self.discount} - {self.discount_amount}{suffix}"
def clean(self): def clean(self):

View File

@@ -21,6 +21,7 @@ from .models import (
Cart, Cart,
CartItem, CartItem,
Discount, Discount,
DiscountType,
Invoice, Invoice,
Order, Order,
OrderLine, OrderLine,
@@ -251,12 +252,12 @@ class AppliedDiscountTests(ShopEntitiesTestBase):
return AppliedDiscount.objects.create(**kwargs) return AppliedDiscount.objects.create(**kwargs)
def test_str_percentage_shows_percent(self): 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%") self.assertEqual(str(applied), "Sibling - 10.00%")
def test_str_fixed_amount_has_no_percent(self): 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") self.assertEqual(str(applied), "Sibling - 10.00")

File diff suppressed because one or more lines are too long

View File

@@ -85,7 +85,7 @@
<div class="mb-6 w-full space-y-2"> <div class="mb-6 w-full space-y-2">
{% for message in messages %} {% for message in messages %}
{% with alert=message|as_alert %} {% with alert=message|as_alert %}
<div class="alert alert-soft {{ alert.css }}" role="alert"> <div class="alert alert-soft border-2 {{ alert.css }}" role="alert">
{% lucide alert.icon size=20 %} {% lucide alert.icon size=20 %}
<div> <div>
<div class="font-bold">{{ alert.title }}</div> <div class="font-bold">{{ alert.title }}</div>