Compare commits
2 Commits
10b113f244
...
91270b0cf8
| Author | SHA1 | Date | |
|---|---|---|---|
| 91270b0cf8 | |||
| 19108407c6 |
@@ -11,7 +11,7 @@ from decimal import Decimal
|
|||||||
|
|
||||||
from allauth.mfa.models import Authenticator
|
from allauth.mfa.models import Authenticator
|
||||||
from django.contrib.auth import get_user_model
|
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.db.models.functions import Coalesce, TruncMonth
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from waffle import get_waffle_flag_model
|
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
|
clubs = Club.objects.active() if queryset is None else queryset
|
||||||
|
|
||||||
in_season = Q(season__start_date__lte=today, season__end_date__gte=today)
|
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(
|
managed_this_season = Q(
|
||||||
staff_assignments__season__start_date__lte=today,
|
staff_assignments__season__start_date__lte=today,
|
||||||
staff_assignments__season__end_date__gte=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_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_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]),
|
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
|
# How far the club is covered: the furthest-out period that is settled. PAID and
|
||||||
# grace would start if nothing is renewed. Null when the club owes, or was never billed.
|
# WAIVED both mean nothing is owed for that period, and its end is the day grace
|
||||||
paid_until=Subquery(Due.objects.filter(club=OuterRef("pk"), status=Due.Status.PAID).order_by("-period_end").values("period_end")[:1]),
|
# 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"))
|
.annotate(teams_without_coach=F("team_count") - F("teams_managed"))
|
||||||
.order_by("name")
|
.order_by("name")
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
<th class="text-right">Events</th>
|
<th class="text-right">Events</th>
|
||||||
<th class="text-right">Plan</th>
|
<th class="text-right">Plan</th>
|
||||||
<th class="text-right">Dues</th>
|
<th class="text-right">Dues</th>
|
||||||
|
<th class="text-right">Plan end</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -79,7 +80,7 @@
|
|||||||
{% if club.tier_name %}
|
{% if club.tier_name %}
|
||||||
<span class="badge badge-accent">{{ club.tier_name|lower }}</span>
|
<span class="badge badge-accent">{{ club.tier_name|lower }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge badge-ghost badge-outline">n/a</span>
|
-
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
@@ -87,15 +88,23 @@
|
|||||||
<div class="flex flex-row gap-2 items-center justify-end">
|
<div class="flex flex-row gap-2 items-center justify-end">
|
||||||
{% if not club.dues_owed %}
|
{% if not club.dues_owed %}
|
||||||
{% if club.tier_name %}
|
{% 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">
|
<div class="flex flex-col items-end gap-1">
|
||||||
|
{% if club.covered_status == "waived" %}
|
||||||
|
<span class="badge badge-ghost badge-outline">waived</span>
|
||||||
|
{% elif club.covered_until %}
|
||||||
<span class="badge badge-success">paid</span>
|
<span class="badge badge-success">paid</span>
|
||||||
{% if club.paid_until %}
|
{% else %}
|
||||||
<span class="whitespace-nowrap text-xs opacity-60">until {{ club.paid_until|date:"j M Y" }}</span>
|
<span class="badge badge-ghost badge-outline">—</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge badge-ghost badge-outline">n/a</span>
|
-
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="font-semibold">€{{ club.dues_owed|floatformat:2 }}</span>
|
<span class="font-semibold">€{{ club.dues_owed|floatformat:2 }}</span>
|
||||||
@@ -108,6 +117,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
<td class="text-right">
|
||||||
|
<span class="whitespace-nowrap">{{ club.covered_until|date:"j M Y"|default:"-" }}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<a class="btn btn-sm gap-2" href="{% url "controlpanel:club_detail" club.pk %}">{% lucide "pencil" size=14 %} Edit</a>
|
<a class="btn btn-sm gap-2" href="{% url "controlpanel:club_detail" club.pk %}">{% lucide "pencil" size=14 %} Edit</a>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from waffle import get_waffle_flag_model, get_waffle_switch_model
|
|||||||
|
|
||||||
from billing.models import GRACE_DAYS, Due, Tier, TierPrice
|
from billing.models import GRACE_DAYS, Due, Tier, TierPrice
|
||||||
from billing.services import BillingError
|
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 club.models import Club, ClubMembership, ClubRole, Season
|
||||||
from events.models import Attendance, Event
|
from events.models import Attendance, Event
|
||||||
from features.models import Maintenance
|
from features.models import Maintenance
|
||||||
@@ -1139,12 +1139,27 @@ class PlatformDuesMetricTests(TestCase):
|
|||||||
|
|
||||||
club = clubs_with_health().get(pk=self.club.pk)
|
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
|
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):
|
def test_the_health_table_still_costs_one_query_with_billing_on_it(self):
|
||||||
subscribe(self.club, self.tier)
|
subscribe(self.club, self.tier)
|
||||||
|
|||||||
Reference in New Issue
Block a user