Compare commits

...

7 Commits

Author SHA1 Message Date
40255805c3 Modularize confirmation modal for destructive POST actions and add notify helper for concise message handling across the UI. 2026-07-16 18:34:03 +02:00
127d0e338e Modularize and streamline billing templates; replace _billing_form.html with reusable modals and shared partials, and update styles and interactions for consistency and clarity. billing, feature management, and forms
Refactored club detail templates to modularize common UI components. Standardized layout, interactions, and styles across admin, billing, and feature cards for consistency and reusability.
2026-07-16 16:44:48 +02:00
83caa233d7 Replace doughnut chart with pie chart in fees visualization and clean up unused CSS styles. 2026-07-16 08:38:17 +02:00
fc7a349f8f Add "Open" button to club detail for direct access to club subdomain 2026-07-16 00:12:29 +02:00
f403128f57 Improve club detail layout: enhance logo display, adjust badge styles, refine card layouts, and optimize spacing 2026-07-16 00:06:54 +02:00
91270b0cf8 Add "Plan end" column to health table and simplify badge styles
The health table now includes a "Plan end" column showing the coverage end date ("-" if none). Simplified several badge styles for consistency, replacing "n/a" with "-" and updating waived badges to use a ghost-outline class.
2026-07-15 18:28:02 +02:00
19108407c6 Show the cover-end date for waived periods too, not just paid
The "until <date>" only appeared for a PAID due, because the annotation filtered
status=PAID. A WAIVED period is settled just the same — the club is covered for
that time, and its end is still when grace would start — so it belongs there too.

The annotation is now `covered_until` (furthest-out period end where status is PAID
or WAIVED) plus `covered_status`, read from the same ordered row so the table can
badge "paid" vs "waived" and still show the date for both. Rides the same single
query — assertNumQueries(1) still holds.

Full matrix now: no plan -> n/a; paid -> "paid, until X"; waived -> "waived, until
X"; unpaid/partial -> the amount owed (no date); a paid-then-owing club shows the
amount, not the stale cover date. A subscribed club with no covering period at all
(only cancelled dues) shows a dash rather than a bare "paid".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:16:56 +02:00
40 changed files with 5714 additions and 741 deletions

View File

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

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.http import Http404
from django.shortcuts import redirect
from .messages import notify
class PlatformStaffRequiredMixin(UserPassesTestMixin):
@@ -38,3 +41,21 @@ class PlatformSuperuserRequiredMixin(PlatformStaffRequiredMixin):
def test_func(self):
return self.request.user.is_superuser
class RedirectOnInvalidMixin:
"""A form submitted from a modal has nowhere sensible to re-render on error: the page
that opened it has already moved on, and the view has no standalone template of its
own. Redirect back to ``invalid_redirect_url_name`` instead, with the errors flattened
into messages, rather than Django's default of re-rendering ``template_name``.
"""
invalid_redirect_url_name = None
def get_invalid_redirect_kwargs(self):
return {}
def form_invalid(self, form):
for error in form.errors.values():
notify(self.request, f"e|Couldn't save|{' '.join(error)}")
return redirect(self.invalid_redirect_url_name, **self.get_invalid_redirect_kwargs())

View File

@@ -11,7 +11,7 @@ from decimal import Decimal
from allauth.mfa.models import Authenticator
from django.contrib.auth import get_user_model
from django.db.models import Count, DecimalField, Exists, F, IntegerField, OuterRef, Q, Subquery, Sum, Value
from django.db.models import Count, DateField, DecimalField, Exists, F, IntegerField, OuterRef, Q, Subquery, Sum, Value
from django.db.models.functions import Coalesce, TruncMonth
from django.utils import timezone
from waffle import get_waffle_flag_model
@@ -64,6 +64,8 @@ def clubs_with_health(queryset=None, today=None, now=None):
clubs = Club.objects.active() if queryset is None else queryset
in_season = Q(season__start_date__lte=today, season__end_date__gte=today)
# A period the club is covered for, most recent first — paid or waived, both settled.
_covered = Due.objects.filter(club=OuterRef("pk"), status__in=(Due.Status.PAID, Due.Status.WAIVED)).order_by("-period_end")
managed_this_season = Q(
staff_assignments__season__start_date__lte=today,
staff_assignments__season__end_date__gte=today,
@@ -84,9 +86,13 @@ def clubs_with_health(queryset=None, today=None, now=None):
dues_owed=_subquery(Due.objects.filter(status__in=Due.OWING), Sum(F("amount") - F("amount_paid")), DecimalField(max_digits=10, decimal_places=2)),
dues_grace_until=Subquery(Due.objects.filter(club=OuterRef("pk"), status__in=Due.OWING).order_by("grace_until").values("grace_until")[:1]),
dues_period_end=Subquery(Due.objects.filter(club=OuterRef("pk"), status__in=Due.OWING).order_by("period_end").values("period_end")[:1]),
# How far a fully-paid club is covered: the furthest-out PAID period end — the day
# grace would start if nothing is renewed. Null when the club owes, or was never billed.
paid_until=Subquery(Due.objects.filter(club=OuterRef("pk"), status=Due.Status.PAID).order_by("-period_end").values("period_end")[:1]),
# How far the club is covered: the furthest-out period that is settled. PAID and
# WAIVED both mean nothing is owed for that period, and its end is the day grace
# would start if nothing renews — so both count. `covered_status` is read from the
# same top row, so the table can badge "paid" vs "waived". Null when the club owes
# or was never billed.
covered_until=Subquery(_covered.values("period_end")[:1], output_field=DateField()),
covered_status=Subquery(_covered.values("status")[:1]),
)
.annotate(teams_without_coach=F("team_count") - F("teams_managed"))
.order_by("name")
@@ -149,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():
"""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`."""

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

@@ -20,6 +20,7 @@
<th class="text-right">Events</th>
<th class="text-right">Plan</th>
<th class="text-right">Dues</th>
<th class="text-right">Plan end</th>
<th></th>
</tr>
</thead>
@@ -79,7 +80,7 @@
{% if club.tier_name %}
<span class="badge badge-accent">{{ club.tier_name|lower }}</span>
{% else %}
<span class="badge badge-ghost badge-outline">n/a</span>
-
{% endif %}
</td>
@@ -87,15 +88,23 @@
<div class="flex flex-row gap-2 items-center justify-end">
{% if not club.dues_owed %}
{% if club.tier_name %}
{% comment %} paid_until is the current period's end — the day grace would start if nothing renews. On its own row under the badge. {% endcomment %}
{% comment %}
Not owing and on a plan. covered_until is the settled period's end — the day
grace would start if nothing renews — shown on its own row under the badge,
for a paid period AND a waived one (both cover the club, they just differ in
how). No covered period at all (only cancelled dues, say) shows a dash.
{% endcomment %}
<div class="flex flex-col items-end gap-1">
<span class="badge badge-success">paid</span>
{% if club.paid_until %}
<span class="whitespace-nowrap text-xs opacity-60">until {{ club.paid_until|date:"j M Y" }}</span>
{% if club.covered_status == "waived" %}
<span class="badge badge-ghost badge-outline">waived</span>
{% elif club.covered_until %}
<span class="badge badge-success">paid</span>
{% else %}
-
{% endif %}
</div>
{% else %}
<span class="badge badge-ghost badge-outline">n/a</span>
-
{% endif %}
{% else %}
<span class="font-semibold">€{{ club.dues_owed|floatformat:2 }}</span>
@@ -108,8 +117,12 @@
</div>
</td>
<td class="text-right">
<span class="whitespace-nowrap">{{ club.covered_until|date:"j M Y"|default:"-" }}</span>
</td>
<td>
<a class="btn btn-sm 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>
</tr>
{% 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. #}
<li>
<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>
</li>
{% 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" %}
{% load lucide %}
{% load lucide ui %}
{% block heading %}Platform admins{% endblock heading %}
@@ -8,10 +8,13 @@
{% endblock subheading %}
{% block actions %}
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:admin_add' %}">{% lucide "user-plus" size=16 %} Grant access</a>
<button class="btn btn-primary gap-2" type="button" onclick="document.getElementById('admin_add_modal').showModal()">{% lucide "user-plus" size=16 %} Grant access</button>
{% endblock actions %}
{% block panel %}
{% url 'controlpanel:admin_add' as admin_add_url %}
{% include "controlpanel/_modal_form.html" with modal_id="admin_add_modal" title="Grant platform access" form=admin_form action_url=admin_add_url submit_label="Grant access" submit_icon="user-plus" blurb="Platform admins can manage every club. They must set up two-factor authentication before they can sign in." %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="overflow-x-auto">
@@ -30,15 +33,16 @@
<tr>
<td>
<div class="font-medium">{{ admin.email }}</div>
{% if admin.pk == user.pk %}<div class="text-xs opacity-60">That's you</div>{% endif %}
{% if admin.pk == user.pk %}
<div class="text-xs opacity-60">That's you</div>{% endif %}
</td>
<td>
<form method="post" action="{% url 'controlpanel:admin_update' admin.pk %}">
{% csrf_token %}
<input type="hidden" name="is_staff" value="{% if admin.is_staff %}0{% else %}1{% endif %}">
<input type="hidden" name="is_superuser" value="{% if admin.is_superuser %}1{% else %}0{% endif %}">
<button class="btn btn-xs gap-1 {% if admin.is_staff %}btn-success{% else %}btn-ghost{% endif %}" type="submit">
{% if admin.is_staff %}{% lucide "check" size=14 %} Yes{% else %}No{% endif %}
<button class="btn btn-sm gap-1 {% if admin.is_staff %}btn-success{% else %}btn-outline{% endif %}" type="submit">
{% if admin.is_staff %}{% lucide "user" size=14 %} Yes{% else %}{% lucide "x" size=14 %} No{% endif %}
</button>
</form>
</td>
@@ -47,17 +51,14 @@
{% csrf_token %}
<input type="hidden" name="is_staff" value="{% if admin.is_staff %}1{% else %}0{% endif %}">
<input type="hidden" name="is_superuser" value="{% if admin.is_superuser %}0{% else %}1{% endif %}">
<button class="btn btn-xs gap-1 {% if admin.is_superuser %}btn-warning{% else %}btn-ghost{% endif %}" type="submit">
{% if admin.is_superuser %}{% lucide "shield" size=14 %} Yes{% else %}No{% endif %}
<button class="btn btn-sm gap-1 {% if admin.is_superuser %}btn-warning{% else %}btn-outline{% endif %}" type="submit">
{% if admin.is_superuser %}{% lucide "shield" size=14 %} Yes{% else %}{% lucide "x" size=14 %} No{% endif %}
</button>
</form>
</td>
<td class="opacity-70">{{ admin.last_login|date:"j M Y"|default:"Never" }}</td>
<td class="text-right">
<form method="post" action="{% url 'controlpanel:admin_revoke' admin.pk %}">
{% csrf_token %}
<button class="btn btn-ghost btn-xs gap-1 text-error" type="submit">{% lucide "user-minus" size=14 %} Revoke</button>
</form>
<button class="btn btn-error btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ admin.pk|dom_id:"admin_revoke_modal" }}').showModal()">{% lucide "user-minus" size=14 %} Revoke</button>
</td>
</tr>
{% empty %}
@@ -68,6 +69,12 @@
</tbody>
</table>
</div>
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
{% for admin in admins %}
{% url 'controlpanel:admin_revoke' admin.pk as admin_revoke_url %}
{% include "controlpanel/_confirm_modal.html" with modal_id=admin.pk|dom_id:"admin_revoke_modal" title="Revoke platform access" body="Revoke platform access for "|add:admin.email|add:"? They will no longer be able to reach the control panel." action_url=admin_revoke_url submit_label="Revoke" submit_icon="user-minus" %}
{% endfor %}
</div>
</div>
{% endblock panel %}

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>
</div>
{% endif %}
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-col gap-2">
<div class="mb-6 flex flex-wrap flex-row items-center justify-between gap-3">
{% block logo %}{% endblock logo %}
<div class="flex flex-col gap-2 grow">
<h1 class="text-3xl font-bold">
{% block heading %}Control panel{% endblock heading %}
</h1>

View File

@@ -1,28 +1,30 @@
{% extends "controlpanel/base.html" %}
{% load lucide %}
{% load lucide ui %}
{% block heading %}Billing{% endblock heading %}
{% block subheading %}<p class="text-sm opacity-70">What the platform charges its clubs.</p>{% endblock subheading %}
{% block actions %}
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:tier_create' %}">{% lucide "plus" size=16 %} New tier</a>
<button class="btn btn-primary gap-2" type="button" onclick="document.getElementById('tier_create_modal').showModal()">{% lucide "plus" size=16 %} New plan</button>
{% endblock actions %}
{% block panel %}
{% url 'controlpanel:tier_create' as tier_create_url %}
{% include "controlpanel/_modal_form.html" with modal_id="tier_create_modal" title="New plan" form=tier_form action_url=tier_create_url submit_label="Create plan" submit_icon="plus" %}
<div class="card mb-6 bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "layers" size=18 %} Tiers</h2>
<h2 class="card-title text-base">{% lucide "layers" size=18 %} Plans</h2>
{% comment %}
Prices are dated, not edited. A rate change is a new row with a future
active_from; every period already opened keeps the amount it was billed at,
so raising the price cannot rewrite an invoice you have already sent.
{% endcomment %}
<p class="text-sm opacity-70">A rate change is a new dated price. Periods already billed keep the amount they were issued at.</p>
<p class="text-sm opacity-70">A rate change only takes effect as of a certain date. Periods already billed keep the amount they were issued at.</p>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Tier</th>
<th>Plan</th>
<th class="text-right">Clubs</th>
<th>Prices</th>
<th></th>
@@ -34,7 +36,8 @@
<td>
<div class="font-medium">{{ tier.name }}</div>
{% if not tier.is_active %}<span class="badge badge-ghost badge-xs">Retired</span>{% endif %}
{% if tier.description %}<div class="text-xs opacity-60">{{ tier.description }}</div>{% endif %}
{% if tier.description %}
<div class="text-xs opacity-60">{{ tier.description }}</div>{% endif %}
</td>
<td class="text-right tabular-nums">{{ tier.club_count }}</td>
<td>
@@ -48,14 +51,14 @@
<span class="badge badge-error badge-sm">No price — cannot be billed</span>
{% endfor %}
</td>
<td class="text-right">
<a class="btn btn-ghost btn-xs gap-1" href="{% url 'controlpanel:tier_price_create' tier.pk %}">{% lucide "euro" size=14 %} New price</a>
<a class="btn btn-ghost btn-xs gap-1" href="{% url 'controlpanel:tier_update' tier.pk %}">{% lucide "pencil" size=14 %} Edit</a>
<td class="flex flex-row gap-2 justify-end">
<button class="btn btn-primary btn-sm btn-outline gap-1" type="button" onclick="document.getElementById('{{ tier.pk|dom_id:"tier_price_modal" }}').showModal()">{% lucide "euro" size=14 %} New price</button>
<button class="btn btn-sm btn-outline gap-1" type="button" onclick="document.getElementById('{{ tier.pk|dom_id:"tier_edit_modal" }}').showModal()">{% lucide "pencil" size=14 %} Edit</button>
</td>
</tr>
{% empty %}
<tr>
<td colspan="4" class="text-center opacity-60">No tiers yet.</td>
<td colspan="4" class="text-center opacity-60">No plans yet.</td>
</tr>
{% endfor %}
</tbody>
@@ -64,6 +67,15 @@
</div>
</div>
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
{% for tier in tiers %}
{% url 'controlpanel:tier_price_create' tier.pk as tier_price_url %}
{% include "controlpanel/_modal_form.html" with modal_id=tier.pk|dom_id:"tier_price_modal" title="New price — "|add:tier.name form=tier.price_form action_url=tier_price_url submit_label="Add price" submit_icon="euro" %}
{% url 'controlpanel:tier_update' tier.pk as tier_update_url %}
{% include "controlpanel/_modal_form.html" with modal_id=tier.pk|dom_id:"tier_edit_modal" title="Edit "|add:tier.name form=tier.edit_form action_url=tier_update_url submit_label="Save" submit_icon="check" %}
{% endfor %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "receipt-euro" size=18 %} Owed</h2>
@@ -74,7 +86,7 @@
<th>Club</th>
<th>Period</th>
<th class="text-right">Owed</th>
<th>Status</th>
<th class="text-right">Status</th>
<th></th>
</tr>
</thead>
@@ -90,18 +102,18 @@
<div class="text-xs opacity-60">Grace to {{ due.grace_until|date:"j M Y" }}</div>
</td>
<td class="text-right font-semibold tabular-nums">€{{ due.balance|floatformat:2 }}</td>
<td>
<td class="text-right">
{% if due.grace_until < today %}
<span class="badge badge-error gap-1">{% lucide "triangle-alert" size=12 %} Overdue</span>
{% elif due.period_end < today %}
<span class="badge badge-warning gap-1">{% lucide "hourglass" size=12 %} In grace</span>
{% else %}
<span class="badge badge-ghost">{{ due.get_status_display }}</span>
<span class="badge badge-outline">{{ due.get_status_display }}</span>
{% endif %}
</td>
<td class="text-right">
<a class="btn btn-primary btn-xs gap-1" href="{% url 'controlpanel:due_pay' due.pk %}">{% lucide "banknote" size=14 %} Record payment</a>
<a class="btn btn-ghost btn-xs gap-1" href="{% url 'controlpanel:due_invoice' due.pk %}">{% lucide "file-text" size=14 %} Invoice</a>
<td class="text-right flex flex-row gap-2 justify-end">
<button class="btn btn-primary btn-sm btn-outline gap-1" type="button" onclick="document.getElementById('{{ due.pk|dom_id:"due_pay_modal" }}').showModal()">{% lucide "banknote" size=14 %} Record payment</button>
<a class="btn btn-accent btn-outline btn-sm gap-1" href="{% url 'controlpanel:due_invoice' due.pk %}">{% lucide "file-down" size=14 %} Download invoice</a>
</td>
</tr>
{% empty %}
@@ -114,4 +126,10 @@
</div>
</div>
</div>
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
{% for due in owing %}
{% url 'controlpanel:due_pay' due.pk as due_pay_url %}
{% include "controlpanel/_modal_form.html" with modal_id=due.pk|dom_id:"due_pay_modal" title="Record payment — "|add:due.club.name form=due.payment_form action_url=due_pay_url submit_label="Record payment" submit_icon="banknote" %}
{% endfor %}
{% endblock panel %}

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" %}
{% load static lucide %}
{% block logo %}
{% if club.logo %}
<img class="h-16 w-16 object-contain" src="{{ club.logo.url }}" alt="{{ club.name }}">
{% else %}
<div class="avatar avatar-placeholder">
<div class="w-16 text-xl rounded-full bg-neutral text-neutral-content">
<span>{{ club.initials }}</span>
</div>
</div>
{% endif %}
{% endblock logo %}
{% block heading %}{{ club.name }}{% endblock heading %}
{% block subheading %}
<p class="text-sm opacity-70">
{{ club.slug }}
{% if club.is_archived %}
<span class="badge badge-warning badge-sm ml-2">Archived</span>
{% endif %}
</p>
{{ club.slug }}.rosterchief.app
{% if club.is_archived %}
<span class="badge badge-warning badge-sm ml-2">Archived</span>
{% endif %}
{% endblock subheading %}
{% block actions %}
<a class="btn btn-ghost gap-2" href="{% url 'controlpanel:club_update' club.pk %}">{% lucide "pencil" size=16 %} Edit</a>
<a class="btn btn-outline gap-2" href="{% url 'controlpanel:club_update' club.pk %}">{% lucide "pencil" size=16 %} Edit</a>
<a class="btn btn-primary gap-2" href="https://{{ club.slug }}.rosterchief.app">{% lucide "external-link" size=16 %} Open</a>
{% if club.is_archived %}
<form method="post" action="{% url 'controlpanel:club_restore' club.pk %}">
{% csrf_token %}
@@ -30,6 +41,7 @@
{% block panel %}
{% if club.is_archived %}
<div class="alert alert-warning mb-6">
{% lucide "alert-triangle" size=20 %}
<span>This club is archived: its subdomain no longer resolves. Nothing has been deleted — restore it to bring it back.</span>
</div>
{% endif %}
@@ -47,96 +59,91 @@
the club's setup, not a statistic: with nobody in a management position the access
service grants no authority over that team, so nobody can pick the squad.
{% endcomment %}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div class="card bg-base-100 shadow {% if attention.outstanding %}border-l-4 border-error{% endif %}">
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-6">
{% comment %}<div class="card bg-base-100 shadow {% if attention.outstanding %}border-l-4 border-error{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "banknote" size=16 %} Outstanding</div>
<div class="text-3xl font-bold tabular-nums">€{{ attention.outstanding|floatformat:2 }}</div>
<div class="text-xs opacity-60">{{ attention.unpaid_members }} member{{ attention.unpaid_members|pluralize }} unpaid this season</div>
</div>
</div>
<div class="card bg-base-100 shadow {% if attention.teams_without_manager %}border-l-4 border-error{% endif %}">
</div>{% endcomment %}
<div class="card bg-base-100 shadow border-l-4 {% if attention.teams_without_manager %}border-error{% else %}border-success{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "user-x" size=16 %} No coach</div>
<div class="text-3xl font-bold tabular-nums">{{ attention.teams_without_manager }}</div>
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-x" size=16 %} Teams without coach</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.teams_without_manager }}</div>
<div class="text-xs opacity-60">Teams nobody can pick a squad for</div>
</div>
</div>
<div class="card bg-base-100 shadow {% if attention.unrostered %}border-l-4 border-warning{% endif %}">
<div class="card bg-base-100 shadow border-l-4 {% if attention.unrostered %}border-warning{% else %}border-success{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "user-minus" size=16 %} Unrostered</div>
<div class="text-3xl font-bold tabular-nums">{{ attention.unrostered }}</div>
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-minus" size=16 %} Unrostered members</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.unrostered }}</div>
<div class="text-xs opacity-60">Active members on no team</div>
</div>
</div>
<div class="card bg-base-100 shadow {% if attention.pending_approvals %}border-l-4 border-warning{% endif %}">
<div class="card bg-base-100 shadow border-l-4 {% if attention.pending_approvals %}border-warning{% else %}border-success{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "clock" size=16 %} Pending</div>
<div class="text-3xl font-bold tabular-nums">{{ attention.pending_approvals }}</div>
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "clock" size=16 %} Pending</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.pending_approvals }}</div>
<div class="text-xs opacity-60">Memberships awaiting approval</div>
</div>
</div>
</div>
<div class="mb-6 grid gap-4 lg:grid-cols-4">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "sparkles" size=18 %} New members</h2>
<div class="text-4xl font-bold tabular-nums">{{ attention.new_members }}</div>
<div class="card bg-base-100 shadow border-l-4 border-info">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "sparkles" size=16 %} New members</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.new_members }}</div>
{# First season at this club — someone returning after a year away is a renewal. #}
<p class="text-sm opacity-70">first season at this club</p>
<div class="text-xs opacity-60">First season at this club</div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "repeat" size=18 %} Renewal</h2>
{% if attention.renewal_rate is None %}
{# No prior season to compare against: a first-season club has not failed to renew anyone. #}
<p class="text-sm opacity-60">No previous season to compare against yet.</p>
{% else %}
<div class="text-4xl font-bold tabular-nums">{{ attention.renewal_rate }}%</div>
<p class="text-sm opacity-70">of last season's active members signed up again</p>
<progress class="progress progress-primary w-full" value="{{ attention.renewal_rate }}" max="100"></progress>
{% endif %}
<div class="card bg-base-100 shadow border-l-4 {% if attention.renewal_rate is None %}border-info{% elif attention.renewal_rate < 30 %}border-error{% elif attention.renewal_rate < 65 %}border-warning{% else %}border-success{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "repeat" size=16 %} Renewal rate</div>
<div class="text-4xl font-bold tabular-nums font-mono">
{% if attention.renewal_rate is None %}
N/A
{% else %}
{{ attention.renewal_rate }}%
{% endif %}
</div>
<div class="text-xs opacity-60">
{% if attention.renewal_rate is None %}
No previous season
{% else %}
<progress class="progress w-full {% if attention.renewal_rate < 30 %}progress-error{% elif attention.renewal_rate < 65 %}progress-warning{% else %}progress-success{% endif %}" value="{{ attention.renewal_rate }}"
max="100"></progress>
{% endif %}
</div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "user-check" size=18 %} Attendance</h2>
{% if attention.attendance.turnout is None %}
<p class="text-sm opacity-60">No past events with responses this season.</p>
{% else %}
<div class="text-4xl font-bold tabular-nums">{{ attention.attendance.turnout }}%</div>
<p class="text-sm opacity-70">turnout of those who answered</p>
<p class="mt-2 text-sm">
{# The leading indicator: it measures whether members use the app at all. #}
<span class="font-semibold {% if attention.attendance.no_response > 30 %}text-warning{% endif %}">{{ attention.attendance.no_response }}%</span>
<span class="opacity-70">never responded</span>
</p>
{% endif %}
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "hourglass" size=18 %} Unpaid, by age</h2>
<table class="table table-sm">
<tbody>
{% for bucket in attention.aging %}
<tr>
<td class="{% if bucket.overdue and bucket.total %}font-semibold text-error{% endif %}">{{ bucket.label }}</td>
<td class="text-right tabular-nums">€{{ bucket.total|floatformat:2 }}</td>
<td class="text-right opacity-60">{{ bucket.count }} order{{ bucket.count|pluralize }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="card bg-base-100 shadow border-l-4 {% if attention.attendance.turnout is None %}border-info{% elif attention.attendance.turnout < 30 %}border-error{% elif attention.attendance.turnout < 65 %}border-warning{% else %}border-success{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-check" size=16 %} Attendance rate</div>
<div class="text-4xl font-bold tabular-nums font-mono">
{% if attention.attendance.turnout is None %}
N/A
{% else %}
{{ attention.attendance.turnout }}%
{% endif %}
</div>
<div class="text-xs opacity-60">
{% if attention.attendance.turnout is None %}
No events this season
{% else %}
<progress class="progress w-full {% if attention.attendance.turnout < 30 %}progress-error{% elif attention.attendance.turnout < 65 %}progress-warning{% else %}progress-success{% endif %}"
value="{{ attention.attendance.turnout }}" max="100"></progress>
{% endif %}
</div>
</div>
</div>
</div>
<div class="mb-6 grid gap-4 lg:grid-cols-2">
<div class="card bg-base-100 shadow">
<div class="card-body">
@@ -149,7 +156,7 @@
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "wallet" size=18 %} Fee status this season</h2>
<h2 class="card-title text-base">{% lucide "wallet" size=18 %} Club fee status this season</h2>
<div class="h-56">
<canvas id="fees-chart"></canvas>
</div>
@@ -157,7 +164,7 @@
</div>
</div>
<div class="mb-6 grid gap-4 md:grid-cols-2">
<div class="mb-6 grid gap-4 md:grid-cols-4">
{% for group in groups %}
<div class="card bg-base-100 shadow">
<div class="card-body">
@@ -166,7 +173,7 @@
{% for label, value in group.stats %}
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{{ label }}</dt>
<dd class="font-semibold tabular-nums">{{ value }}</dd>
<dd class="font-semibold tabular-nums font-mono">{% if group.title == "Shop" and label == "Outstanding" or label == "Revenue" %}&euro;{% endif %}{{ value }}</dd>
</div>
{% endfor %}
</dl>
@@ -174,183 +181,9 @@
</div>
{% endfor %}
</div>
<div class="card mb-6 bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% lucide "toggle-right" size=18 %} Features</h2>
<a class="btn btn-ghost btn-xs" href="{% url 'controlpanel:features' %}">Manage features</a>
</div>
<div class="overflow-x-auto">
<table class="table">
<tbody>
{% for entry in flags %}
<tr>
<td class="font-mono font-medium">{{ entry.flag.name }}</td>
<td class="opacity-70">{{ entry.flag.note|default:"—" }}</td>
<td class="text-right">
{% if entry.overridden %}
{# `everyone` overrides club targeting, so a per-club toggle would be a lie. #}
<span class="badge {% if entry.flag.everyone %}badge-success{% else %}badge-error{% endif %}">
{% if entry.flag.everyone %}On for all clubs{% else %}Off everywhere{% endif %}
</span>
{% else %}
<form method="post" action="{% url 'controlpanel:club_feature_toggle' club.pk entry.flag.pk %}">
{% csrf_token %}
<button class="btn btn-sm gap-1 {% if entry.enabled %}btn-success{% else %}btn-ghost{% endif %}" type="submit">
{% if entry.enabled %}{% lucide "toggle-right" size=16 %} On{% else %}{% lucide "toggle-left" size=16 %} Off{% endif %}
</button>
</form>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td class="text-center opacity-60">No features defined yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="card mb-6 bg-base-100 shadow">
<div class="card-body">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="card-title text-base">{% lucide "receipt-euro" size=18 %} Billing</h2>
<div class="flex flex-wrap gap-2">
<a class="btn btn-outline btn-sm gap-2" href="{% url 'controlpanel:club_subscribe' club.pk %}">
{% lucide "layers" size=14 %} {% if subscription %}Change plan{% else %}Start billing{% endif %}
</a>
{% if subscription %}
<a class="btn btn-primary btn-sm gap-2" href="{% url 'controlpanel:club_open_period' club.pk %}">
{% lucide "calendar-plus" size=14 %} {% if club.is_archived %}Reactivate{% else %}Open period{% endif %}
</a>
{% endif %}
</div>
</div>
{% if not subscription %}
<p class="text-sm opacity-70">This club is not billed for anything. Put it on a tier to start.</p>
{% else %}
<p class="text-sm opacity-70">
On <strong>{{ subscription.tier.name }}</strong>.
{% if subscription.auto_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>
{% include "controlpanel/_club_features_card.html" %}
{% include "controlpanel/_club_billing_card.html" %}
{% include "controlpanel/_club_admins_card.html" %}
{% endblock panel %}
{% block extra_body %}
@@ -372,17 +205,17 @@
data: {
labels: data.signups.map((point) => point.month),
datasets: [
{ label: "New", data: data.signups.map((point) => point.new), backgroundColor: css("--color-primary", "#4f46e5") },
{ label: "Returning", data: data.signups.map((point) => point.returning), backgroundColor: css("--color-accent", "#0ea5e9") },
{label: "New", data: data.signups.map((point) => point.new), backgroundColor: css("--color-primary", "#4f46e5")},
{label: "Returning", data: data.signups.map((point) => point.returning), backgroundColor: css("--color-accent", "#0ea5e9")},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: "bottom", labels: { color: ink } } },
plugins: {legend: {position: "bottom", labels: {color: ink}}},
scales: {
x: { stacked: true, ticks: { color: ink }, grid: { color: grid } },
y: { stacked: true, beginAtZero: true, ticks: { color: ink, precision: 0 }, grid: { color: grid } },
x: {stacked: true, ticks: {color: ink}, grid: {color: grid}},
y: {stacked: true, beginAtZero: true, ticks: {color: ink, precision: 0}, grid: {color: grid}},
},
},
});
@@ -390,7 +223,7 @@
// Colour carries the meaning here — unpaid must read as a problem, waived must
// not — so the slices are pinned to the semantic theme colours, in order.
const fees = new Chart(document.getElementById("fees-chart"), {
type: "doughnut",
type: "pie",
data: {
labels: data.fees.map((slice) => slice.label),
datasets: [
@@ -403,7 +236,7 @@
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: "right", labels: { color: ink } } },
plugins: {legend: {position: "right", labels: {color: ink}}},
},
});
@@ -416,7 +249,7 @@
new MutationObserver(() => {
charts.forEach((chart) => chart.destroy());
charts = render();
}).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
}).observe(document.documentElement, {attributes: true, attributeFilter: ["data-theme"]});
})();
</script>
{% endblock extra_body %}

View File

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

View File

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

View File

@@ -91,7 +91,7 @@
<div>
<div class="mb-1 flex items-center justify-between text-sm">
<span class="flex items-center gap-2">{% lucide step.icon size=14 %} {{ step.label }}</span>
<span class="font-semibold tabular-nums">{{ step.count }}</span>
<span class="font-semibold tabular-nums font-mono">{{ step.count }}</span>
</div>
<progress class="progress {% 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>

View File

@@ -4,10 +4,13 @@
{% block heading %}Features{% endblock heading %}
{% block actions %}
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:flag_create' %}">{% lucide "plus" size=16 %} New feature</a>
<button class="btn btn-primary gap-2" type="button" onclick="document.getElementById('flag_create_modal').showModal()">{% lucide "plus" size=16 %} New feature</button>
{% endblock actions %}
{% block panel %}
{% url 'controlpanel:flag_create' as flag_create_url %}
{% include "controlpanel/_modal_form.html" with modal_id="flag_create_modal" title="New feature" form=flag_form action_url=flag_create_url submit_label="Create" submit_icon="plus" %}
{% comment %}
The lock-down. Clubs get a maintenance page, the scheduled jobs stand down, and the
control panel and the auth screens stay open — otherwise you could not sign in to
@@ -16,14 +19,21 @@
<div class="card mb-6 bg-base-100 shadow {% if maintenance.is_active %}border-l-4 border-error{% endif %}">
<div class="card-body">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="card-title text-base">{% lucide "wrench" size=18 %} Maintenance mode</h2>
<div class="w-full">
<h2 class="card-title text-base mb-2">{% lucide "wrench" size=18 %} Maintenance mode</h2>
{% if maintenance.is_active %}
<p class="text-sm">
<div class="flex flex-row gap-2 text-sm">
<span class="badge badge-error gap-1">{% lucide "lock" size=12 %} Platform closed</span>
since {{ maintenance.started_at|date:"j M Y, H:i" }}{% if maintenance.started_by %} by {{ maintenance.started_by.email }}{% endif %}.
</p>
{% if maintenance.message %}<p class="mt-1 text-sm opacity-70">“{{ maintenance.message }}”</p>{% endif %}
<div>&middot;</div>
<div>since {{ maintenance.started_at|date:"j M Y, H:i" }}{% if maintenance.started_by %} by {{ maintenance.started_by.email }}{% endif %}</div>
</div>
{% if maintenance.message %}
<div class="my-4">
<div class="font-semibold mb-1">Message</div>
<div class="p-4 bg-base-300 border-l-4 border-info w-full font-mono">{{ maintenance.message }}</div>
</div>
{% endif %}
{% else %}
<p class="text-sm opacity-70">
Closes every club subdomain and stands the scheduled jobs down. The control panel and the sign-in screens stay open.
@@ -35,12 +45,12 @@
<form class="mt-2" method="post" action="{% url 'controlpanel:maintenance' %}">
{% csrf_token %}
{% if not maintenance.is_active %}
<div class="form-control my-2 w-full max-w-xl">
<div class="form-control my-2 w-full pb-2">
<label class="label" for="{{ maintenance_form.message.id_for_label }}">
<span class="label-text">{{ maintenance_form.message.label }}</span>
</label>
{{ maintenance_form.message|daisy }}
<span class="label-text-alt mt-1 block text-base-content/70">{{ maintenance_form.message.help_text }}</span>
<span class="label-text-alt mt-1 block text-xs text-base-content/70">{{ maintenance_form.message.help_text }}</span>
</div>
<button class="btn btn-error gap-2" type="submit">{% lucide "lock" size=16 %} Close the platform</button>
{% else %}
@@ -77,13 +87,13 @@
{% elif flag.everyone is False %}
<span class="badge badge-error">Off everywhere</span>
{% else %}
<span class="badge badge-ghost">Per club</span>
<span class="badge badge-info">Per club</span>
{% endif %}
</td>
<td>{{ flag.clubs.count }}</td>
<td class="max-w-xs truncate opacity-70">{{ flag.note|default:"" }}</td>
<td class="max-w-xs truncate opacity-70">{{ flag.note|default:"-" }}</td>
<td class="text-right">
<a class="btn btn-ghost btn-xs gap-1" href="{% url 'controlpanel:flag_update' flag.pk %}">{% lucide "pencil" size=14 %} Edit</a>
<button class="btn btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ flag.pk|dom_id:"flag_edit_modal" }}').showModal()">{% lucide "pencil" size=14 %} Edit</button>
</td>
</tr>
{% empty %}
@@ -94,6 +104,12 @@
</tbody>
</table>
</div>
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
{% for flag in flags %}
{% url 'controlpanel:flag_update' flag.pk as flag_update_url %}
{% include "controlpanel/_modal_form.html" with modal_id=flag.pk|dom_id:"flag_edit_modal" title="Edit "|add:flag.name form=flag.edit_form action_url=flag_update_url submit_label="Save" submit_icon="check" %}
{% endfor %}
</div>
</div>
<div class="card bg-base-100 shadow">
@@ -106,7 +122,7 @@
{% for switch in switches %}
<tr>
<td class="font-mono font-medium">{{ switch.name }}</td>
<td class="opacity-70">{{ switch.note|default:"" }}</td>
<td class="opacity-70">{{ switch.note|default:"-" }}</td>
<td class="text-right">
<form method="post" action="{% url 'controlpanel:switch_toggle' switch.pk %}">
{% csrf_token %}

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.
MESSAGE_ALERTS = {
"debug": ("bug", "Debug", "alert-info"),
"info": ("info", "Heads up", "alert-info"),
"success": ("circle-check", "Done", "alert-success"),
"warning": ("triangle-alert", "Careful", "alert-warning"),
"error": ("circle-x", "Something went wrong", "alert-error"),
"debug": ("bug", "Debug", "alert-info border-info"),
"info": ("info", "Heads up", "alert-info border-info"),
"success": ("circle-check", "Done", "alert-success border-success"),
"warning": ("triangle-alert", "Careful", "alert-warning border-warning"),
"error": ("circle-x", "Something went wrong", "alert-error border-error"),
}
DEFAULT_MESSAGE_ALERT = MESSAGE_ALERTS["info"]
@@ -32,10 +32,11 @@ def as_alert(message):
"""Presentation for one Django message: icon, bold title, body, colour.
Django messages carry a level and a string — there is no title field — so the
title comes from the level, and a call site that wants a specific one passes it
as ``extra_tags``::
title comes from the level, unless the message carries one as ``extra_tags``.
Call sites queue messages with ``notify`` (controlpanel/messages.py), which sets
exactly that from a compact ``"<level>|<title>|<body>"`` spec::
messages.success(request, f"{club} is live.", extra_tags="Club created")
notify(request, f"s|Club created|{club} is live.")
Keyed on ``level_tag``, never ``tags``: ``tags`` is extra_tags and level_tag
joined, so a message carrying a custom title would stop matching its own level
@@ -99,6 +100,17 @@ def excluded(field, names):
return field.name in (names or "").split(",")
@register.filter
def dom_id(pk, prefix):
"""A stable per-row DOM id, e.g. ``{{ due.pk|dom_id:"due_pay_modal" }}``.
Django templates cannot concatenate a string literal with a non-string filter
argument directly (``"prefix_"|add:some_uuid`` raises), which is what a per-row modal
id needs. This is the one place that builds one, so every call site reads the same way.
"""
return f"{prefix}_{pk}"
@register.filter
def daisy(field, css=None):
"""Render a bound form field with the right daisyUI classes.

View File

@@ -9,15 +9,16 @@ from django.conf import settings
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.messages.storage.base import Message
from django.contrib.messages.storage.fallback import FallbackStorage
from django.core.cache import cache
from django.test import TestCase, override_settings
from django.test import RequestFactory, TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from waffle import get_waffle_flag_model, get_waffle_switch_model
from billing.models import GRACE_DAYS, Due, Tier, TierPrice
from billing.services import BillingError
from billing.services.dues import record_payment, subscribe
from billing.services.dues import record_payment, subscribe, waive
from club.models import Club, ClubMembership, ClubRole, Season
from events.models import Attendance, Event
from features.models import Maintenance
@@ -25,6 +26,7 @@ from members.models import Member
from shop.models import Order
from teams.models import Position, StaffAssignment, Team, TeamMembership
from .messages import LEVELS, notify
from .services.admins import grant_club_admin
from .services.platform_admins import PlatformAdminError, is_last_superuser, set_platform_access
from .services.statistics import (
@@ -175,11 +177,18 @@ class ClubAdminManagementTests(ControlPanelTestBase):
self.assertEqual(ClubRole.objects.get(club=self.club, member__user=user).role, ClubRole.Roles.ADMIN)
def test_a_new_email_must_come_with_a_name(self):
response = self.add_admin(email="nameless@example.com", first_name="", last_name="")
# Reachable only via the "Add admin" modal on the club detail page, so a rejected
# submission bounces back there with the error as a message.
response = self.client.post(reverse("controlpanel:club_admin_add", args=[self.club.pk]), {"email": "nameless@example.com", "first_name": "", "last_name": ""}, follow=True)
self.assertEqual(response.status_code, 200)
self.assertRedirects(response, reverse("controlpanel:club_detail", args=[self.club.pk]))
self.assertFalse(ClubRole.objects.exists())
self.assertFormError(response.context["form"], "first_name", "Required: this email has no account yet.")
self.assertContains(response, "Required: this email has no account yet.")
def test_add_admin_is_post_only(self):
response = self.client.get(reverse("controlpanel:club_admin_add", args=[self.club.pk]))
self.assertEqual(response.status_code, 405)
def test_an_existing_member_is_promoted_rather_than_duplicated(self):
user = User.objects.create_user(email="existing@example.com", password="pw-secret-123")
@@ -315,8 +324,10 @@ class PlatformAdminTests(TestCase):
def test_superuser_sees_the_admins_section(self):
self.assertEqual(self.client.get(reverse("controlpanel:admins")).status_code, 200)
def test_the_grant_form_renders(self):
self.assertEqual(self.client.get(reverse("controlpanel:admin_add")).status_code, 200)
def test_the_grant_form_is_post_only(self):
# Reachable only via the "Grant access" modal on the admins page: there is no
# standalone template to render on a GET.
self.assertEqual(self.client.get(reverse("controlpanel:admin_add")).status_code, 405)
def test_grant_access_to_a_new_email_creates_a_staff_account(self):
self.client.post(reverse("controlpanel:admin_add"), {"email": "New.Admin@Example.com"})
@@ -407,9 +418,17 @@ class FeatureViewTests(ControlPanelTestBase):
self.assertContains(response, "shop")
self.assertContains(response, "maintenance")
def test_the_flag_forms_render(self):
self.assertEqual(self.client.get(reverse("controlpanel:flag_create")).status_code, 200)
self.assertEqual(self.client.get(reverse("controlpanel:flag_update", args=[self.flag.pk])).status_code, 200)
def test_the_flag_forms_are_post_only(self):
# Reachable only through a modal on the features page: there is no standalone
# template to render on a GET.
self.assertEqual(self.client.get(reverse("controlpanel:flag_create")).status_code, 405)
self.assertEqual(self.client.get(reverse("controlpanel:flag_update", args=[self.flag.pk])).status_code, 405)
def test_an_invalid_flag_submission_redirects_with_a_message(self):
response = self.client.post(reverse("controlpanel:flag_create"), {"name": "", "note": "", "percent": "", "everyone": ""}, follow=True)
self.assertRedirects(response, reverse("controlpanel:features"))
self.assertContains(response, "This field is required")
def test_create_a_flag(self):
self.client.post(reverse("controlpanel:flag_create"), {"name": "news", "note": "News module", "percent": "", "everyone": ""})
@@ -456,33 +475,75 @@ class FeatureViewTests(ControlPanelTestBase):
self.assertNotContains(response, reverse("controlpanel:club_feature_toggle", args=[self.club.pk, self.flag.pk]))
class NotifyTests(TestCase):
def request(self):
request = RequestFactory().get("/")
request.session = {}
storage = FallbackStorage(request)
request._messages = storage
return request, storage
def test_splits_level_title_and_body(self):
request, storage = self.request()
notify(request, "s|Club created|Ajax United is live.")
[message] = list(storage)
self.assertEqual(message.level, messages.SUCCESS)
self.assertEqual(message.extra_tags, "Club created")
self.assertEqual(message.message, "Ajax United is live.")
def test_maps_every_level_code_to_its_django_level(self):
self.assertEqual(LEVELS, {"s": messages.SUCCESS, "i": messages.INFO, "w": messages.WARNING, "e": messages.ERROR, "d": messages.DEBUG})
def test_a_pipe_inside_the_body_is_preserved_intact(self):
# maxsplit=2 stops after the level and the title, so a "|" a club/tier/flag name
# might contain stays part of the body rather than truncating it.
request, storage = self.request()
notify(request, "s|Title|Before | after.")
[message] = list(storage)
self.assertEqual(message.message, "Before | after.")
def test_an_empty_title_falls_back_to_the_generic_one_at_render_time(self):
request, storage = self.request()
notify(request, "s||No custom title.")
[message] = list(storage)
self.assertEqual(as_alert(message)["title"], "Done")
class MessageAlertTests(TestCase):
def alert(self, level, text, extra_tags=None):
return as_alert(Message(level, text, extra_tags=extra_tags))
def test_each_level_gets_its_own_icon_title_and_colour(self):
self.assertEqual(self.alert(messages.SUCCESS, "Saved.")["icon"], "circle-check")
self.assertEqual(self.alert(messages.WARNING, "Careful.")["css"], "alert-warning")
self.assertEqual(self.alert(messages.WARNING, "Careful.")["css"], "alert-warning border-warning")
self.assertEqual(self.alert(messages.ERROR, "Boom.")["title"], "Something went wrong")
self.assertEqual(self.alert(messages.INFO, "FYI.")["css"], "alert-info")
self.assertEqual(self.alert(messages.INFO, "FYI.")["css"], "alert-info border-info")
def test_extra_tags_override_the_title(self):
alert = self.alert(messages.SUCCESS, "Ajax United is live.", extra_tags="Club created")
self.assertEqual(alert["title"], "Club created")
self.assertEqual(alert["body"], "Ajax United is live.")
self.assertEqual(alert["css"], "alert-success") # a custom title must not change the level
self.assertEqual(alert["css"], "alert-success border-success") # a custom title must not change the level
def test_an_unknown_level_falls_back_to_info(self):
self.assertEqual(self.alert(999, "Odd.")["css"], "alert-info")
self.assertEqual(self.alert(999, "Odd.")["css"], "alert-info border-info")
class MessageRenderingTests(ControlPanelTestBase):
def test_a_message_renders_as_a_soft_alert_with_icon_and_title(self):
# club_archive queues its message through `notify`, which sets a custom title —
# so the generic per-level one ("Careful") must not show.
response = self.client.post(reverse("controlpanel:club_archive", args=[self.club.pk]), follow=True)
self.assertContains(response, "alert alert-soft alert-warning")
self.assertContains(response, '<div class="font-bold">Careful</div>', html=False)
self.assertContains(response, "alert alert-soft border-2 alert-warning border-warning")
self.assertContains(response, '<div class="font-bold">Club archived</div>', html=False)
self.assertContains(response, "<svg") # the lucide icon
@@ -1139,12 +1200,27 @@ class PlatformDuesMetricTests(TestCase):
club = clubs_with_health().get(pk=self.club.pk)
self.assertEqual(club.paid_until, due.period_end)
self.assertEqual(club.covered_until, due.period_end)
self.assertEqual(club.covered_status, Due.Status.PAID)
def test_a_club_that_owes_has_no_paid_until(self):
def test_a_waived_period_also_shows_its_cover_end(self):
# Waived is settled too — the club is covered for that time, so its end date shows,
# badged "waived" rather than "paid".
subscribe(self.club, self.tier)
due = self.club.dues.first()
waive(due)
club = clubs_with_health().get(pk=self.club.pk)
self.assertEqual(club.covered_until, due.period_end)
self.assertEqual(club.covered_status, Due.Status.WAIVED)
def test_a_club_that_owes_has_no_cover(self):
subscribe(self.club, self.tier) # unpaid
self.assertIsNone(clubs_with_health().get(pk=self.club.pk).paid_until)
club = clubs_with_health().get(pk=self.club.pk)
self.assertIsNone(club.covered_until)
self.assertIsNone(club.covered_status)
def test_the_health_table_still_costs_one_query_with_billing_on_it(self):
subscribe(self.club, self.tier)
@@ -1282,7 +1358,9 @@ class BillingFormRenderTests(ControlPanelTestBase):
self.tier = Tier.objects.create(name="Standard")
TierPrice.objects.create(tier=self.tier, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00"))
def test_the_billing_forms_render(self):
def test_the_billing_forms_are_post_only(self):
# Every one of these is reachable only through a modal on the billing or club
# detail page: there is no standalone template to render on a GET.
subscribe(self.club, self.tier)
due = self.club.dues.first()
@@ -1294,17 +1372,33 @@ class BillingFormRenderTests(ControlPanelTestBase):
reverse("controlpanel:club_open_period", args=[self.club.pk]),
reverse("controlpanel:due_pay", args=[due.pk]),
):
self.assertEqual(self.client.get(url).status_code, 200, url)
self.assertEqual(self.client.get(url).status_code, 405, url)
def test_the_payment_form_defaults_to_the_outstanding_balance(self):
def test_the_billing_forms_redirect_with_a_message_on_invalid_input(self):
# Rejected input has nowhere to re-render — the modal that submitted it is on a
# page this view no longer serves — so it must bounce back with an error message
# rather than 500 or silently drop the submission.
subscribe(self.club, self.tier)
due = self.club.dues.first()
response = self.client.post(reverse("controlpanel:tier_create"), {"name": "", "description": "", "is_active": "on"}, follow=True)
self.assertRedirects(response, reverse("controlpanel:billing"))
self.assertContains(response, "This field is required")
response = self.client.post(reverse("controlpanel:due_pay", args=[due.pk]), {"amount": "not-a-number", "method": "bank_transfer", "reference": "", "paid_at": "", "note": ""}, follow=True)
self.assertRedirects(response, reverse("controlpanel:club_detail", args=[self.club.pk]))
self.assertContains(response, "Enter a number")
def test_the_payment_modal_defaults_to_the_outstanding_balance(self):
subscribe(self.club, self.tier)
due = self.club.dues.first()
record_payment(due, Decimal("200.00"))
due.refresh_from_db()
response = self.client.get(reverse("controlpanel:due_pay", args=[due.pk]))
response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk]))
self.assertEqual(response.context["form"].initial["amount"], Decimal("300.00"))
rendered_due = next(rendered for rendered in response.context["dues"] if rendered.pk == due.pk)
self.assertEqual(rendered_due.payment_form.initial["amount"], Decimal("300.00"))
def test_a_tier_can_be_renamed(self):
self.client.post(reverse("controlpanel:tier_update", args=[self.tier.pk]), {"name": "Standard plus", "description": "", "is_active": "on"})

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.db.models import Count
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils import timezone
from django.utils.formats import date_format
from django.views.generic import CreateView, DetailView, FormView, ListView, TemplateView, UpdateView, View
from waffle import get_waffle_flag_model, get_waffle_switch_model
@@ -16,7 +18,8 @@ from club.models import Club, ClubRole
from features.models import Maintenance
from .forms import ClubAdminForm, ClubForm, DuePaymentForm, FlagForm, MaintenanceForm, OpenPeriodForm, PlatformAdminForm, SubscriptionForm, TierForm, TierPriceForm
from .mixins import PlatformStaffRequiredMixin, PlatformSuperuserRequiredMixin
from .messages import notify
from .mixins import PlatformStaffRequiredMixin, PlatformSuperuserRequiredMixin, RedirectOnInvalidMixin
from .services.admins import grant_club_admin, revoke_club_admin
from .services.platform_admins import (
PlatformAdminError,
@@ -25,12 +28,26 @@ from .services.platform_admins import (
revoke_platform_access,
set_platform_access,
)
from .services.statistics import club_attention, club_charts, club_statistics, clubs_with_health, flag_adoption, onboarding_funnel, platform_attention, platform_charts, platform_totals
from .services.statistics import club_attention, club_charts, club_statistics, clubs_with_health, flag_adoption, flags_for_club, onboarding_funnel, platform_attention, platform_charts, platform_totals
Flag = get_waffle_flag_model()
Switch = get_waffle_switch_model()
@contextmanager
def suppress_billing_errors(request, title="Billing error"):
"""Turn a BillingError into an error message rather than letting it propagate.
Fits call sites that fall through to the same redirect on the happy and unhappy path
alike — the success message is set inside the block, the failure message by this
context manager, and whichever fired, the caller's next line runs unchanged.
"""
try:
yield
except BillingError as error:
notify(request, f"e|{title}|{error}")
class DashboardView(PlatformStaffRequiredMixin, TemplateView):
template_name = "controlpanel/dashboard.html"
@@ -74,12 +91,15 @@ class ClubCreateView(PlatformStaffRequiredMixin, CreateView):
def form_valid(self, form):
response = super().form_valid(form)
messages.success(self.request, f"Club “{self.object}” created.")
notify(self.request, f"s|Club created|Club “{self.object}” created.")
return response
def get_success_url(self):
return reverse("controlpanel:club_detail", args=[self.object.pk])
def get_context_data(self, **kwargs):
return super().get_context_data(nav="clubs", **kwargs)
class ClubUpdateView(PlatformStaffRequiredMixin, UpdateView):
model = Club
@@ -88,12 +108,15 @@ class ClubUpdateView(PlatformStaffRequiredMixin, UpdateView):
def form_valid(self, form):
response = super().form_valid(form)
messages.success(self.request, f"Club “{self.object}” updated.")
notify(self.request, f"s|Club updated|Club “{self.object}” updated.")
return response
def get_success_url(self):
return reverse("controlpanel:club_detail", args=[self.object.pk])
def get_context_data(self, **kwargs):
return super().get_context_data(nav="clubs", update_view=True, **kwargs)
class ClubDetailView(PlatformStaffRequiredMixin, DetailView):
model = Club
@@ -101,16 +124,30 @@ class ClubDetailView(PlatformStaffRequiredMixin, DetailView):
context_object_name = "club"
def get_context_data(self, **kwargs):
subscription = getattr(self.object, "subscription", None)
next_start = next_period_start(self.object)
# Bound per-row so each due's "Add payment" modal can render its own form without
# the template calling DuePaymentForm(initial=...) itself.
dues = list(self.object.dues.select_related("tier", "invoice").prefetch_related("payments"))
for due in dues:
if due.is_owing:
due.payment_form = DuePaymentForm(initial={"amount": due.balance})
return super().get_context_data(
nav="clubs",
groups=club_statistics(self.object),
attention=club_attention(self.object),
charts=club_charts(self.object),
subscription=getattr(self.object, "subscription", None),
dues=self.object.dues.select_related("tier", "invoice").prefetch_related("payments"),
subscription=subscription,
dues=dues,
today=timezone.localdate(),
admins=ClubRole.objects.filter(club=self.object, role=ClubRole.Roles.ADMIN).select_related("member", "member__user"),
admin_form=ClubAdminForm(),
flags=flags_for_club(self.object),
open_period_form=OpenPeriodForm(),
open_period_blurb=f"Next period starts {date_format(next_start, 'j M Y')} unless you say otherwise. By default it continues from the end of the last one, so a lapsed year is still owed — pick a start date to forgive the gap.",
subscription_form=SubscriptionForm(instance=subscription) if subscription else SubscriptionForm(),
**kwargs,
)
@@ -121,7 +158,7 @@ class ClubArchiveView(PlatformStaffRequiredMixin, View):
def post(self, request, pk):
club = get_object_or_404(Club, pk=pk)
club.archive()
messages.warning(request, f"Club “{club}” archived. Its subdomain no longer resolves.")
notify(request, f"w|Club archived|Club “{club}” archived. Its subdomain no longer resolves.")
return redirect("controlpanel:club_detail", pk=club.pk)
@@ -129,24 +166,28 @@ class ClubRestoreView(PlatformStaffRequiredMixin, View):
def post(self, request, pk):
club = get_object_or_404(Club, pk=pk)
club.restore()
messages.success(request, f"Club “{club}” restored.")
notify(request, f"s|Club restored|Club “{club}” restored.")
return redirect("controlpanel:club_detail", pk=club.pk)
class ClubAdminAddView(PlatformStaffRequiredMixin, FormView):
class ClubAdminAddView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, FormView):
"""Reachable only via the "Add admin" modal on the club detail page — POST-only, and
there is no standalone template to render on GET or on a rejected submission."""
form_class = ClubAdminForm
template_name = "controlpanel/club_admin_form.html"
http_method_names = ["post"]
invalid_redirect_url_name = "controlpanel:club_detail"
@property
def club(self):
return get_object_or_404(Club, pk=self.kwargs["pk"])
def get_context_data(self, **kwargs):
return super().get_context_data(nav="clubs", club=self.club, **kwargs)
def get_invalid_redirect_kwargs(self):
return {"pk": self.kwargs["pk"]}
def form_valid(self, form):
role = grant_club_admin(self.club, **form.cleaned_data)
messages.success(self.request, f"{role.member} is now an admin of {role.club}. They must set up two-factor authentication before they can sign in.")
notify(self.request, f"s|Admin added|{role.member} is now an admin of {role.club}. They must set up two-factor authentication before they can sign in.")
return redirect("controlpanel:club_detail", pk=self.kwargs["pk"])
@@ -155,24 +196,10 @@ class ClubAdminRemoveView(PlatformStaffRequiredMixin, View):
role = get_object_or_404(ClubRole, pk=role_pk, club_id=pk, role=ClubRole.Roles.ADMIN)
member = role.member
revoke_club_admin(role)
messages.warning(request, f"{member} is no longer an admin of this club.")
notify(request, f"w|Admin removed|{member} is no longer an admin of this club.")
return redirect("controlpanel:club_detail", pk=pk)
def flags_for_club(club):
"""Every flag, annotated with whether it is on for this club and why."""
enabled_ids = set(club.flags.values_list("pk", flat=True))
return [
{
"flag": flag,
"enabled": flag.pk in enabled_ids,
# `everyone` overrides club targeting, so the per-club toggle is moot.
"overridden": flag.everyone is not None,
}
for flag in Flag.objects.order_by("name")
]
class ClubFeatureToggleView(PlatformStaffRequiredMixin, View):
"""Turn a feature on or off for one club."""
@@ -182,10 +209,10 @@ class ClubFeatureToggleView(PlatformStaffRequiredMixin, View):
if flag.clubs.filter(pk=club.pk).exists():
flag.clubs.remove(club)
messages.warning(request, f"{flag.name}” turned off for {club}.")
notify(request, f"w|Feature disabled|{flag.name}” turned off for {club}.")
else:
flag.clubs.add(club)
messages.success(request, f"{flag.name}” turned on for {club}.")
notify(request, f"s|Feature enabled|{flag.name}” turned on for {club}.")
return redirect("controlpanel:club_detail", pk=club.pk)
@@ -194,9 +221,16 @@ class FeatureListView(PlatformStaffRequiredMixin, TemplateView):
template_name = "controlpanel/features.html"
def get_context_data(self, **kwargs):
# Bound per-row so each flag's "Edit" modal can render its own form: the template
# can't call FlagForm(instance=flag) itself, so the form rides along on the flag.
flags = list(Flag.objects.prefetch_related("clubs").order_by("name"))
for flag in flags:
flag.edit_form = FlagForm(instance=flag)
return super().get_context_data(
nav="features",
flags=Flag.objects.prefetch_related("clubs").order_by("name"),
flags=flags,
flag_form=FlagForm(),
switches=Switch.objects.order_by("name"),
maintenance=Maintenance.current(),
maintenance_form=MaintenanceForm(),
@@ -210,45 +244,46 @@ class MaintenanceView(PlatformStaffRequiredMixin, View):
def post(self, request):
if Maintenance.is_on():
Maintenance.stop()
messages.success(request, "Maintenance ended. The clubs are back.")
notify(request, "s|Maintenance ended|The clubs are back.")
else:
form = MaintenanceForm(request.POST)
message = form.cleaned_data["message"] if form.is_valid() else ""
Maintenance.start(message=message, user=request.user)
messages.warning(request, "Platform closed. Every club subdomain now serves a maintenance page, and the scheduled jobs stand down.")
notify(request, "w|Platform closed|Every club subdomain now serves a maintenance page, and the scheduled jobs stand down.")
return redirect("controlpanel:features")
class FlagCreateView(PlatformStaffRequiredMixin, CreateView):
class FlagCreateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, CreateView):
"""Reachable only via the "New feature" modal on the features page — POST-only, and
there is no standalone template to render on GET or on a rejected submission."""
model = Flag
form_class = FlagForm
template_name = "controlpanel/flag_form.html"
success_url = None
def get_context_data(self, **kwargs):
return super().get_context_data(nav="features", **kwargs)
http_method_names = ["post"]
invalid_redirect_url_name = "controlpanel:features"
def form_valid(self, form):
response = super().form_valid(form)
messages.success(self.request, f"Feature “{self.object.name}” created.")
notify(self.request, f"s|Feature created|Feature “{self.object.name}” created.")
return response
def get_success_url(self):
return reverse("controlpanel:features")
class FlagUpdateView(PlatformStaffRequiredMixin, UpdateView):
class FlagUpdateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, UpdateView):
"""Reachable only via a flag's "Edit" modal on the features page — POST-only, and
there is no standalone template to render on GET or on a rejected submission."""
model = Flag
form_class = FlagForm
template_name = "controlpanel/flag_form.html"
def get_context_data(self, **kwargs):
return super().get_context_data(nav="features", **kwargs)
http_method_names = ["post"]
invalid_redirect_url_name = "controlpanel:features"
def form_valid(self, form):
response = super().form_valid(form)
messages.success(self.request, f"Feature “{self.object.name}” updated.")
notify(self.request, f"s|Feature updated|Feature “{self.object.name}” updated.")
return response
def get_success_url(self):
@@ -262,7 +297,8 @@ class SwitchToggleView(PlatformStaffRequiredMixin, View):
switch = get_object_or_404(Switch, pk=pk)
switch.active = not switch.active
switch.save()
messages.success(request, f"Switch {switch.name}” is now {'on' if switch.active else 'off'}.")
title = "Switch on" if switch.active else "Switch off"
notify(request, f"s|{title}|Switch “{switch.name}” is now {'on' if switch.active else 'off'}.")
return redirect("controlpanel:features")
@@ -270,19 +306,20 @@ class PlatformAdminListView(PlatformSuperuserRequiredMixin, TemplateView):
template_name = "controlpanel/admins.html"
def get_context_data(self, **kwargs):
return super().get_context_data(nav="admins", admins=platform_admins(), **kwargs)
return super().get_context_data(nav="admins", admins=platform_admins(), admin_form=PlatformAdminForm(), **kwargs)
class PlatformAdminAddView(PlatformSuperuserRequiredMixin, FormView):
class PlatformAdminAddView(PlatformSuperuserRequiredMixin, RedirectOnInvalidMixin, FormView):
"""Reachable only via the "Grant access" modal on the admins page — POST-only, and
there is no standalone template to render on GET or on a rejected submission."""
form_class = PlatformAdminForm
template_name = "controlpanel/admin_form.html"
def get_context_data(self, **kwargs):
return super().get_context_data(nav="admins", **kwargs)
http_method_names = ["post"]
invalid_redirect_url_name = "controlpanel:admins"
def form_valid(self, form):
user = grant_platform_access(form.cleaned_data["email"], is_superuser=form.cleaned_data["is_superuser"])
messages.success(self.request, f"{user.email} now has platform access. They must set up two-factor authentication before they can sign in.")
notify(self.request, f"s|Platform access granted|{user.email} now has platform access. They must set up two-factor authentication before they can sign in.")
return redirect("controlpanel:admins")
@@ -297,9 +334,9 @@ class PlatformAdminUpdateView(PlatformSuperuserRequiredMixin, View):
is_superuser=request.POST.get("is_superuser") == "1",
)
except PlatformAdminError as error:
messages.error(request, str(error))
notify(request, f"e|Couldn't update access|{error}")
else:
messages.success(request, f"Updated platform access for {user.email}.")
notify(request, f"s|Access updated|Updated platform access for {user.email}.")
return redirect("controlpanel:admins")
@@ -309,9 +346,9 @@ class PlatformAdminRevokeView(PlatformSuperuserRequiredMixin, View):
try:
revoke_platform_access(request.user, user)
except PlatformAdminError as error:
messages.error(request, str(error))
notify(request, f"e|Couldn't revoke access|{error}")
else:
messages.warning(request, f"{user.email} no longer has platform access.")
notify(request, f"w|Access revoked|{user.email} no longer has platform access.")
return redirect("controlpanel:admins")
@@ -322,76 +359,102 @@ class BillingView(PlatformStaffRequiredMixin, TemplateView):
def get_context_data(self, **kwargs):
today = timezone.localdate()
# Bound per-row so each "Edit" / "New price" modal can render its own form: the
# template can't call TierForm(instance=tier) itself, so the form rides along on
# the object it belongs to.
tiers = list(Tier.objects.prefetch_related("prices").annotate(club_count=Count("subscriptions")))
for tier in tiers:
tier.edit_form = TierForm(instance=tier)
tier.price_form = TierPriceForm()
owing = list(Due.objects.filter(status__in=Due.OWING).select_related("club", "tier").order_by("grace_until"))
for due in owing:
due.payment_form = DuePaymentForm(initial={"amount": due.balance})
return super().get_context_data(
nav="billing",
tiers=Tier.objects.prefetch_related("prices").annotate(club_count=Count("subscriptions")),
owing=Due.objects.filter(status__in=Due.OWING).select_related("club", "tier").order_by("grace_until"),
tiers=tiers,
tier_form=TierForm(),
owing=owing,
today=today,
**kwargs,
)
class TierCreateView(PlatformStaffRequiredMixin, CreateView):
class TierCreateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, CreateView):
"""Reachable only via the "New plan" modal on the billing page — POST-only, and there
is no standalone template to render on GET or on a rejected submission."""
model = Tier
form_class = TierForm
template_name = "controlpanel/tier_form.html"
def get_context_data(self, **kwargs):
return super().get_context_data(nav="billing", **kwargs)
http_method_names = ["post"]
invalid_redirect_url_name = "controlpanel:billing"
def get_success_url(self):
messages.success(self.request, f"Tier “{self.object}” created. Give it a price before billing anyone.")
notify(self.request, f"s|Plan created|Tier “{self.object}” created. Give it a price before billing anyone.")
return reverse("controlpanel:billing")
class TierUpdateView(PlatformStaffRequiredMixin, UpdateView):
class TierUpdateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, UpdateView):
"""Reachable only via a tier's "Edit" modal on the billing page — POST-only, and there
is no standalone template to render on GET or on a rejected submission."""
model = Tier
form_class = TierForm
template_name = "controlpanel/tier_form.html"
def get_context_data(self, **kwargs):
return super().get_context_data(nav="billing", **kwargs)
http_method_names = ["post"]
invalid_redirect_url_name = "controlpanel:billing"
def get_success_url(self):
messages.success(self.request, f"Tier “{self.object}” updated.")
notify(self.request, f"s|Plan updated|Tier “{self.object}” updated.")
return reverse("controlpanel:billing")
class TierPriceCreateView(PlatformStaffRequiredMixin, CreateView):
class TierPriceCreateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, CreateView):
"""A rate change is a new dated price, never an edit of the old one — periods already
billed keep the amount they were billed at."""
billed keep the amount they were billed at.
Reachable only via a tier's "New price" modal on the billing page — POST-only, and
there is no standalone template to render on GET or on a rejected submission.
"""
model = TierPrice
form_class = TierPriceForm
template_name = "controlpanel/tier_price_form.html"
http_method_names = ["post"]
invalid_redirect_url_name = "controlpanel:billing"
@property
def tier(self):
return get_object_or_404(Tier, pk=self.kwargs["pk"])
def get_context_data(self, **kwargs):
return super().get_context_data(nav="billing", tier=self.tier, **kwargs)
def form_valid(self, form):
form.instance.tier = self.tier
response = super().form_valid(form)
messages.success(self.request, f"{self.tier} is €{self.object.amount} for periods opening from {self.object.active_from}.")
notify(self.request, f"s|Price added|{self.tier} is €{self.object.amount} for periods opening from {self.object.active_from}.")
return response
def get_success_url(self):
return reverse("controlpanel:billing")
class SubscribeClubView(PlatformStaffRequiredMixin, FormView):
"""Put a club on a tier, which opens its first period."""
class SubscribeClubView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, FormView):
"""Put a club on a tier, which opens its first period.
Reachable only via the "Change plan" modal on the club detail page — POST-only, and
there is no standalone template to render on GET or on a rejected submission.
"""
form_class = SubscriptionForm
template_name = "controlpanel/subscription_form.html"
http_method_names = ["post"]
invalid_redirect_url_name = "controlpanel:club_detail"
@property
def club(self):
return get_object_or_404(Club, pk=self.kwargs["pk"])
def get_invalid_redirect_kwargs(self):
return {"pk": self.club.pk}
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
subscription = getattr(self.club, "subscription", None)
@@ -399,46 +462,43 @@ class SubscribeClubView(PlatformStaffRequiredMixin, FormView):
kwargs["instance"] = subscription
return kwargs
def get_context_data(self, **kwargs):
return super().get_context_data(nav="clubs", club=self.club, subscription=getattr(self.club, "subscription", None), **kwargs)
def form_valid(self, form):
club = self.club
existing = getattr(club, "subscription", None)
try:
with suppress_billing_errors(self.request, title="Couldn't change plan"):
if existing:
# Changing tier does not re-bill: the current period keeps the amount it was
# issued at, and the new rate applies from the next one.
subscription = form.save(commit=False)
subscription.club = club
subscription.save()
messages.success(self.request, f"{club} is now on {subscription.tier}. The current period keeps the amount it was billed at.")
notify(self.request, f"s|Plan changed|{club} is now on {subscription.tier}. The current period keeps the amount it was billed at.")
else:
subscribe(club, form.cleaned_data["tier"], start=form.cleaned_data.get("start"), auto_archive=form.cleaned_data["auto_archive"], auto_renew=form.cleaned_data["auto_renew"])
messages.success(self.request, f"{club} is on {form.cleaned_data['tier']}. Its first period is open.")
except BillingError as error:
messages.error(self.request, str(error))
notify(self.request, f"s|Billing started|{club} is on {form.cleaned_data['tier']}. Its first period is open.")
return redirect("controlpanel:club_detail", pk=club.pk)
class RecordPaymentView(PlatformStaffRequiredMixin, FormView):
class RecordPaymentView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, FormView):
"""Reachable only via a due's "Record payment" modal on the club or billing page —
POST-only, and there is no standalone template to render on GET or on a rejected
submission."""
form_class = DuePaymentForm
template_name = "controlpanel/payment_form.html"
http_method_names = ["post"]
invalid_redirect_url_name = "controlpanel:club_detail"
@property
def due(self):
return get_object_or_404(Due.objects.select_related("club", "tier"), pk=self.kwargs["pk"])
def get_initial(self):
return {"amount": self.due.balance}
def get_context_data(self, **kwargs):
return super().get_context_data(nav="clubs", due=self.due, **kwargs)
def get_invalid_redirect_kwargs(self):
return {"pk": self.due.club_id}
def form_valid(self, form):
due = self.due
try:
with suppress_billing_errors(self.request, title="Couldn't record payment"):
record_payment(
due,
form.cleaned_data["amount"],
@@ -449,9 +509,7 @@ class RecordPaymentView(PlatformStaffRequiredMixin, FormView):
user=self.request.user,
)
due.refresh_from_db()
messages.success(self.request, f"{form.cleaned_data['amount']} recorded. {due.get_status_display().capitalize()} — €{due.balance} outstanding.")
except BillingError as error:
messages.error(self.request, str(error))
notify(self.request, f"s|Payment recorded|{form.cleaned_data['amount']} recorded. {due.get_status_display().capitalize()} — €{due.balance} outstanding.")
return redirect("controlpanel:club_detail", pk=due.club_id)
@@ -459,37 +517,37 @@ class RecordPaymentView(PlatformStaffRequiredMixin, FormView):
class WaiveDueView(PlatformStaffRequiredMixin, View):
def post(self, request, pk):
due = get_object_or_404(Due, pk=pk)
try:
with suppress_billing_errors(request, title="Couldn't waive period"):
waive(due)
messages.warning(request, f"Period {due.period_start} to {due.period_end} waived. Nothing is owed and the club will not be archived for it.")
except BillingError as error:
messages.error(request, str(error))
notify(request, f"w|Period waived|Period {due.period_start} to {due.period_end} waived. Nothing is owed and the club will not be archived for it.")
return redirect("controlpanel:club_detail", pk=due.club_id)
class OpenPeriodView(PlatformStaffRequiredMixin, FormView):
"""Renew a club, or reactivate an archived one."""
class OpenPeriodView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, FormView):
"""Renew a club, or reactivate an archived one.
Reachable only via the "Open period" modal on the club detail page — POST-only, and
there is no standalone template to render on GET or on a rejected submission.
"""
form_class = OpenPeriodForm
template_name = "controlpanel/period_form.html"
http_method_names = ["post"]
invalid_redirect_url_name = "controlpanel:club_detail"
@property
def club(self):
return get_object_or_404(Club, pk=self.kwargs["pk"])
def get_context_data(self, **kwargs):
club = self.club
return super().get_context_data(nav="clubs", club=club, next_start=next_period_start(club), **kwargs)
def get_invalid_redirect_kwargs(self):
return {"pk": self.club.pk}
def form_valid(self, form):
club = self.club
start = form.cleaned_data.get("start")
try:
with suppress_billing_errors(self.request, title="Couldn't open period"):
due = reactivate(club, start=start) if club.is_archived else open_period(club, start=start)
messages.success(self.request, f"Period {due.period_start} to {due.period_end} opened for €{due.amount}. Invoice {due.invoice.number}.")
except BillingError as error:
messages.error(self.request, str(error))
notify(self.request, f"s|Period opened|Period {due.period_start} to {due.period_end} opened for €{due.amount}. Invoice {due.invoice.number}.")
return redirect("controlpanel:club_detail", pk=club.pk)
@@ -502,7 +560,7 @@ class InvoicePdfView(PlatformStaffRequiredMixin, View):
pdf = invoice_pdf(invoice)
except BillingError as error:
# The native PDF libraries are missing: say so rather than 500.
messages.error(request, str(error))
notify(request, f"e|PDF unavailable|{error}")
return redirect("controlpanel:club_detail", pk=due.club_id)
response = HttpResponse(pdf, content_type="application/pdf")

View File

@@ -3,13 +3,13 @@
from django.db import transaction
from django.utils import timezone
from formbuilder.models import Answer, Field, Submission
from formbuilder.models import Answer, Submission
from .options import allowed_values
from .form_factory import build_form
class FormSubmissionError(Exception):
"""Raised when a submission is rejected. ``errors`` maps field key -> message."""
"""Raised when a submission is rejected. ``errors`` maps field key -> messages."""
def __init__(self, message, *, errors=None):
super().__init__(message)
@@ -21,12 +21,17 @@ def _is_empty(value):
@transaction.atomic
def submit_form(form, member, data, *, when=None):
"""Create a Submission (with Answers) for ``data`` or raise FormSubmissionError."""
def submit_form(form, member, data, *, files=None, when=None):
"""Create a Submission (with Answers) for ``data``/``files`` or raise FormSubmissionError.
Validation goes through ``build_form`` — the same dynamic Django Form the UI would
render — so a NUMBER field is actually checked as a decimal, an EMAIL as an email, a
CHOICE against its real options, and so on, rather than a hand-rolled subset of that.
"""
when = when or timezone.now()
_check_open(form, member, when)
cleaned = _clean_answers(form, data)
cleaned = _clean_answers(form, data, files)
submission = Submission.objects.create(form=form, member=member)
Answer.objects.bulk_create([Answer(submission=submission, field=field, value=value) for field, value in cleaned])
@@ -48,37 +53,13 @@ def _check_open(form, member, when):
raise FormSubmissionError("You have reached the maximum number of submissions for this form.")
def _clean_answers(form, data):
errors = {}
cleaned = []
for field in form.fields.filter(is_active=True):
raw = data.get(field.key)
if _is_empty(raw):
if field.required:
errors[field.key] = "This field is required."
continue
message = _validate_choice(field, raw)
if message is not None:
errors[field.key] = message
continue
cleaned.append((field, raw))
if errors:
def _clean_answers(form, data, files):
bound_form = build_form(form, data=data, files=files or {})
if not bound_form.is_valid():
errors = {key: list(messages) for key, messages in bound_form.errors.items()}
raise FormSubmissionError("The submission has errors.", errors=errors)
return cleaned
def _validate_choice(field, raw):
if field.field_type == Field.FieldType.CHOICE:
allowed = allowed_values(field)
if allowed and raw not in allowed:
return "Select a valid choice."
elif field.field_type == Field.FieldType.MULTICHOICE:
allowed = allowed_values(field)
values = raw if isinstance(raw, list) else [raw]
if allowed and not set(values) <= allowed:
return "Select valid choices."
return None
# Blank optional answers are validated (they may legitimately be empty) but not
# stored — an Answer row exists only where the submitter actually said something.
fields_by_key = {field.key: field for field in form.fields.filter(is_active=True)}
return [(fields_by_key[key], value) for key, value in bound_form.cleaned_data.items() if not _is_empty(value)]

View File

@@ -211,6 +211,24 @@ class SubmitFormTests(FormbuilderTestBase):
self.assertIn("size", ctx.exception.errors)
def test_number_field_rejects_non_numeric_input(self):
# Validation goes through the same dynamic Django Form the UI renders, so a
# NUMBER field is checked as a decimal — not merely "present".
Field.objects.create(form=self.form, key="age", label="Age", field_type=Field.FieldType.NUMBER, required=True, order=3)
with self.assertRaises(FormSubmissionError) as ctx:
submit_form(self.form, self.member, {"name": "Jane", "age": "not-a-number"})
self.assertIn("age", ctx.exception.errors)
def test_email_field_rejects_an_invalid_address(self):
Field.objects.create(form=self.form, key="contact", label="Contact", field_type=Field.FieldType.EMAIL, required=True, order=3)
with self.assertRaises(FormSubmissionError) as ctx:
submit_form(self.form, self.member, {"name": "Jane", "contact": "not-an-email"})
self.assertIn("contact", ctx.exception.errors)
def test_multichoice_validation(self):
field = Field.objects.create(form=self.form, key="days", label="Days", field_type=Field.FieldType.MULTICHOICE, required=False, order=3, options=["mon", "tue", "wed"])

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)
_, membership_created = ClubMembership.objects.update_or_create(
@@ -141,6 +141,19 @@ class MemberCsvImporter:
membership_created=membership_created,
)
def get_club(self, club_name) -> Club:
# Never get_or_create: Club.name isn't unique, so a typo'd or differently-cased
# value would otherwise either spin up a duplicate club or raise
# MultipleObjectsReturned against one that already exists. Matching
# case-insensitively absorbs the harmless variety (a CSV export's casing rarely
# matches the platform's own); an unknown club is a data problem the importer
# must not paper over by inventing one.
club = Club.objects.filter(name__iexact=club_name).first()
if club is None:
raise ValueError(f"Unknown club '{club_name}'.")
return club
def get_current_season(self, club) -> Season:
# Season.get_current() is tenant-scoped, so bind the row's club as the
# active tenant for the lookup.

View File

@@ -554,8 +554,9 @@ class ImportMembersCsvCommandTests(TestCase):
self.assertFalse(Member.objects.filter(email="nofirst@example.com").exists())
def test_import_skips_row_when_club_has_no_current_season(self):
# "New Club" has no season, so the row is skipped and — because the row
# is atomic — the member and club creation roll back too.
# The club exists but has no season, so the row is skipped and — because
# the row is atomic — the member creation rolls back too.
Club.objects.create(name="New Club")
csv_path = self.write_csv(
"\n".join(
[
@@ -571,7 +572,45 @@ class ImportMembersCsvCommandTests(TestCase):
self.assertIn("No current season for club 'New Club'.", stderr)
self.assertIn("Rows skipped: 1.", stdout)
self.assertFalse(Member.objects.filter(email="jane@example.com").exists())
self.assertFalse(Club.objects.filter(name="New Club").exists())
def test_import_skips_row_for_an_unknown_club(self):
# The importer must not silently spin up a club for a typo'd or unknown
# name — that is a data problem, not something to paper over.
csv_path = self.write_csv(
"\n".join(
[
"first_name,last_name,email,date_of_birth,create_account,club_name,license_number",
"Jane,Doe,jane@example.com,2010-04-12,false,Nonexistent Club,LIC-001",
]
)
)
stdout, stderr = self.call_import_command(csv_path)
self.assertIn("Row 2 skipped:", stderr)
self.assertIn("Unknown club 'Nonexistent Club'.", stderr)
self.assertIn("Rows skipped: 1.", stdout)
self.assertFalse(Member.objects.filter(email="jane@example.com").exists())
self.assertFalse(Club.objects.filter(name="Nonexistent Club").exists())
def test_import_matches_a_club_name_case_insensitively(self):
# A CSV export's casing rarely matches the platform's own; that is
# harmless variation, not a different club.
csv_path = self.write_csv(
"\n".join(
[
"first_name,last_name,email,date_of_birth,create_account,club_name,license_number",
"Jane,Doe,jane@example.com,2010-04-12,false,city swim club,LIC-001",
]
)
)
stdout, stderr = self.call_import_command(csv_path)
self.assertEqual(stderr, "")
self.assertIn("Rows skipped: 0.", stdout)
membership = ClubMembership.objects.get(club=self.club, member__email="jane@example.com")
self.assertEqual(membership.season, self.season)
class MemberImportResultTests(TestCase):

View File

@@ -11,6 +11,14 @@ from rosterchief.base import ClubScopedModel, UUIDModel, validate_club_scope
from teams.models import Position, Team
class DiscountType(models.TextChoices):
"""Shared by ``Product`` (early-bird), ``Discount`` and ``AppliedDiscount`` (its
snapshot on an order) — one discount vocabulary, not three copies of it."""
PERCENTAGE = "percentage", _("Percentage")
FIXED_AMOUNT = "fixed_amount", _("Fixed amount")
def next_scoped_number(instance, code):
"""Next per-club sequential number for the current year: ``<code>-<year>-<seq>``."""
prefix = f"{code}-{timezone.now().year}-"
@@ -46,10 +54,6 @@ class Product(ClubScopedModel):
MERCHANDISE = "merchandise", _("Merchandise")
DONATION = "donation", _("Donation")
class DiscountType(models.TextChoices):
PERCENTAGE = "percentage", _("Percentage")
FIXED_AMOUNT = "fixed_amount", _("Fixed amount")
name = models.CharField(_("name"), max_length=255)
slug = models.SlugField(_("slug"), max_length=255, blank=True)
@@ -191,10 +195,6 @@ class OrderLine(UUIDModel):
class Discount(ClubScopedModel):
class DiscountType(models.TextChoices):
PERCENTAGE = "percentage", _("Percentage")
FIXED_AMOUNT = "fixed_amount", _("Fixed amount")
name = models.CharField(_("name"), max_length=255)
slug = models.SlugField(_("slug"), max_length=255, blank=True)
description = models.TextField(_("description"), blank=True)
@@ -219,10 +219,6 @@ class Discount(ClubScopedModel):
class AppliedDiscount(UUIDModel):
class DiscountType(models.TextChoices):
PERCENTAGE = "percentage", _("Percentage")
FIXED_AMOUNT = "fixed_amount", _("Fixed amount")
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="applied_discounts", verbose_name=_("order"))
discount = models.ForeignKey(Discount, on_delete=models.PROTECT, related_name="applied_discounts", verbose_name=_("discount"))
@@ -240,7 +236,7 @@ class AppliedDiscount(UUIDModel):
]
def __str__(self):
suffix = "%" if self.discount_type == self.DiscountType.PERCENTAGE else ""
suffix = "%" if self.discount_type == DiscountType.PERCENTAGE else ""
return f"{self.discount} - {self.discount_amount}{suffix}"
def clean(self):

View File

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

File diff suppressed because one or more lines are too long

View File

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