diff --git a/billing/services/dues.py b/billing/services/dues.py index a39837f..17ebe4a 100644 --- a/billing/services/dues.py +++ b/billing/services/dues.py @@ -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) diff --git a/controlpanel/mixins.py b/controlpanel/mixins.py index d573a09..31b438b 100644 --- a/controlpanel/mixins.py +++ b/controlpanel/mixins.py @@ -1,5 +1,7 @@ +from django.contrib import messages from django.contrib.auth.mixins import UserPassesTestMixin from django.http import Http404 +from django.shortcuts import redirect class PlatformStaffRequiredMixin(UserPassesTestMixin): @@ -38,3 +40,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(): + messages.error(self.request, " ".join(error)) + return redirect(self.invalid_redirect_url_name, **self.get_invalid_redirect_kwargs()) diff --git a/controlpanel/services/statistics.py b/controlpanel/services/statistics.py index 724eb3f..dada718 100644 --- a/controlpanel/services/statistics.py +++ b/controlpanel/services/statistics.py @@ -155,6 +155,20 @@ def onboarding_funnel(): ] +def flags_for_club(club): + """Every flag, annotated with whether it is on for this club and why.""" + enabled_ids = set(club.flags.values_list("pk", flat=True)) + return [ + { + "flag": flag, + "enabled": flag.pk in enabled_ids, + # `everyone` overrides club targeting, so the per-club toggle is moot. + "overridden": flag.everyone is not None, + } + for flag in get_waffle_flag_model().objects.order_by("name") + ] + + def flag_adoption(): """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`.""" diff --git a/controlpanel/templates/controlpanel/_billing_form.html b/controlpanel/templates/controlpanel/_billing_form.html deleted file mode 100644 index 1ec68c7..0000000 --- a/controlpanel/templates/controlpanel/_billing_form.html +++ /dev/null @@ -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 %} -
{{ blurb }}
{% endif %} - -| Name | ++ | |
|---|---|---|
| {{ role.member }} | +{{ role.member.user.email|default:"—" }} | ++ + | +
| No admins yet. | +||
This club is not billed for anything. Put it on a tier to start.
+ {% else %} ++ On plan {{ subscription.tier.name }}. + {% if subscription.auto_renew %} + Renews automatically 30 days before the period ends. + {% else %} + Auto-renew off — 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 %} + Auto-archive off — it will never be archived for non-payment. + {% endif %} +
+ +| Period | +Billed | +Paid | +Status | ++ |
|---|---|---|---|---|
|
+ {{ due.period_start|date:"j M Y" }} — {{ due.period_end|date:"j M Y" }}
+ {{ due.tier.name }} · {{ due.invoice.number }} · grace to {{ due.grace_until|date:"j M Y" }}
+ |
+ €{{ due.amount|floatformat:2 }} | +€{{ due.amount_paid|floatformat:2 }} | ++ {% if due.status == "paid" %} + {% lucide "check" size=12 %} Paid + {% elif due.status == "waived" %} + {% lucide "check" size=12 %} Waived + {% elif due.grace_until < today %} + {% lucide "triangle-alert" size=12 %} Overdue + {% elif due.period_end < today %} + {% lucide "hourglass" size=12 %} In grace + {% else %} + {{ due.get_status_display }} + {% endif %} + | ++ {% if due.is_owing %} + + {% if not due.payments.all %} + + {% endif %} + {% endif %} + {% lucide "file-down" size=14 %} Download invoice + | +
| + {% lucide "corner-down-right" size=12 %} + {{ payment.paid_at|date:"j M Y" }} · {{ payment.get_method_display }}{% if payment.reference %} · {{ payment.reference }}{% endif %} + | +€{{ payment.amount|floatformat:2 }} | ++ | ||
| No periods billed yet. | +||||
| {{ entry.flag.name }} | +{{ entry.flag.note|default:"—" }} | ++ {% if entry.overridden %} + {# `everyone` overrides club targeting, so a per-club toggle would be a lie. #} + + {% if entry.flag.everyone %}On for all clubs{% else %}Off everywhere{% endif %} + + {% else %} + + {% endif %} + | +
| No features defined yet. | +