Add a Payments & dues section to the Me page, matching the design's M5 row
Home already had the M1 dues card; Me's own "Payments & dues" row was explicitly stubbed out with nowhere to lead. Give it a real destination: a Payments screen (open balances for everyone managed, reusing Home's dues-card layout via a shared _dues_row.html partial) and a "N OPEN" pill on the Me row itself, only shown once something is actually owed. club.services.fees.open_dues_rows is factored out so Home and Payments can never drift apart on what counts as "still open". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
@@ -16,6 +16,23 @@ def remaining_balance(membership):
|
|||||||
return max(membership.fee_amount - membership.amount_paid, Decimal("0.00"))
|
return max(membership.fee_amount - membership.amount_paid, Decimal("0.00"))
|
||||||
|
|
||||||
|
|
||||||
|
def open_dues_rows(club, people, season):
|
||||||
|
"""Every season-dues row still owed by ``people`` in ``season`` -- shared by
|
||||||
|
mobile's Home dues card and its Payments & dues screen so the two never
|
||||||
|
drift out of sync on what counts as "still open". WAIVED memberships and
|
||||||
|
fully-paid balances are excluded."""
|
||||||
|
if season is None or not people:
|
||||||
|
return []
|
||||||
|
|
||||||
|
memberships = ClubMembership.objects.filter(club=club, member__in=people, season=season).exclude(fee_status=ClubMembership.FeeStatus.WAIVED).select_related("dues_invoice", "member")
|
||||||
|
rows = []
|
||||||
|
for membership in memberships:
|
||||||
|
balance = remaining_balance(membership)
|
||||||
|
if balance > 0:
|
||||||
|
rows.append({"membership": membership, "balance": balance, "invoice": getattr(membership, "dues_invoice", None)})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def record_payment(membership, *, amount, method=FeePayment.Method.BANK_TRANSFER, reference="", note="", recorded_by=None):
|
def record_payment(membership, *, amount, method=FeePayment.Method.BANK_TRANSFER, reference="", note="", recorded_by=None):
|
||||||
"""Record money received against one membership's fee. Several payments may
|
"""Record money received against one membership's fee. Several payments may
|
||||||
land on one membership -- a family paying in two installments must not read as
|
land on one membership -- a family paying in two installments must not read as
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ from .services.access import (
|
|||||||
teams_managed_by,
|
teams_managed_by,
|
||||||
teams_staffed_by,
|
teams_staffed_by,
|
||||||
)
|
)
|
||||||
from .services.fees import mark_as_paid, record_payment, remaining_balance
|
from .services.fees import mark_as_paid, open_dues_rows, record_payment, remaining_balance
|
||||||
from .services.invoicing import create_or_resend_invoice, invoice_pdf, invoices_due_for_reminder, recipient_for, resolve_document_address
|
from .services.invoicing import create_or_resend_invoice, invoice_pdf, invoices_due_for_reminder, recipient_for, resolve_document_address
|
||||||
from .services.onboarding import (
|
from .services.onboarding import (
|
||||||
annotate_onboarding_status,
|
annotate_onboarding_status,
|
||||||
@@ -1562,6 +1562,69 @@ class FeeServiceTests(TestCase):
|
|||||||
self.assertEqual(payment.recorded_by, user)
|
self.assertEqual(payment.recorded_by, user)
|
||||||
|
|
||||||
|
|
||||||
|
class OpenDuesRowsTests(TestCase):
|
||||||
|
"""club.services.fees.open_dues_rows -- the shared source behind mobile's
|
||||||
|
Home dues card and its Payments & dues screen (mobile/views.py's HomeView
|
||||||
|
and PaymentsView)."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||||
|
cls.season = make_season(cls.club)
|
||||||
|
cls.member = Member.objects.create(first_name="Jane", last_name="Doe")
|
||||||
|
|
||||||
|
def test_returns_nothing_without_a_season(self):
|
||||||
|
ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, fee_amount=Decimal("150.00"))
|
||||||
|
|
||||||
|
self.assertEqual(open_dues_rows(self.club, [self.member], None), [])
|
||||||
|
|
||||||
|
def test_returns_nothing_without_any_people(self):
|
||||||
|
self.assertEqual(open_dues_rows(self.club, [], self.season), [])
|
||||||
|
|
||||||
|
def test_a_membership_with_a_remaining_balance_is_included(self):
|
||||||
|
membership = ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, fee_amount=Decimal("150.00"))
|
||||||
|
|
||||||
|
rows = open_dues_rows(self.club, [self.member], self.season)
|
||||||
|
|
||||||
|
self.assertEqual(len(rows), 1)
|
||||||
|
self.assertEqual(rows[0]["membership"], membership)
|
||||||
|
self.assertEqual(rows[0]["balance"], Decimal("150.00"))
|
||||||
|
self.assertIsNone(rows[0]["invoice"])
|
||||||
|
|
||||||
|
def test_a_fully_paid_membership_is_excluded(self):
|
||||||
|
membership = ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, fee_amount=Decimal("150.00"))
|
||||||
|
record_payment(membership, amount=Decimal("150.00"))
|
||||||
|
|
||||||
|
self.assertEqual(open_dues_rows(self.club, [self.member], self.season), [])
|
||||||
|
|
||||||
|
def test_a_waived_membership_is_excluded_even_with_an_unpaid_balance(self):
|
||||||
|
ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, fee_amount=Decimal("150.00"), fee_status=ClubMembership.FeeStatus.WAIVED)
|
||||||
|
|
||||||
|
self.assertEqual(open_dues_rows(self.club, [self.member], self.season), [])
|
||||||
|
|
||||||
|
def test_a_membership_with_no_fee_priced_is_excluded(self):
|
||||||
|
ClubMembership.objects.create(club=self.club, member=self.member, season=self.season)
|
||||||
|
|
||||||
|
self.assertEqual(open_dues_rows(self.club, [self.member], self.season), [])
|
||||||
|
|
||||||
|
def test_the_linked_invoice_is_included_when_one_exists(self):
|
||||||
|
membership = ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, fee_amount=Decimal("150.00"))
|
||||||
|
invoice = DuesInvoice.objects.create(club=self.club, membership=membership, amount=Decimal("150.00"), due_date=timezone.now().date(), sent_at=timezone.now())
|
||||||
|
|
||||||
|
rows = open_dues_rows(self.club, [self.member], self.season)
|
||||||
|
|
||||||
|
self.assertEqual(rows[0]["invoice"], invoice)
|
||||||
|
|
||||||
|
def test_only_includes_the_given_people(self):
|
||||||
|
other_member = Member.objects.create(first_name="Tom", last_name="Roe")
|
||||||
|
ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, fee_amount=Decimal("150.00"))
|
||||||
|
ClubMembership.objects.create(club=self.club, member=other_member, season=self.season, fee_amount=Decimal("150.00"))
|
||||||
|
|
||||||
|
rows = open_dues_rows(self.club, [self.member], self.season)
|
||||||
|
|
||||||
|
self.assertEqual([row["membership"].member for row in rows], [self.member])
|
||||||
|
|
||||||
|
|
||||||
class RecipientForTests(TestCase):
|
class RecipientForTests(TestCase):
|
||||||
"""club.services.invoicing.recipient_for -- the member's own email, else the
|
"""club.services.invoicing.recipient_for -- the member's own email, else the
|
||||||
first parent/guardian who has one, else nobody reachable at all."""
|
first parent/guardian who has one, else nobody reachable at all."""
|
||||||
|
|||||||
23
mobile/templates/mobile/_dues_row.html
Normal file
23
mobile/templates/mobile/_dues_row.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
{% comment %}
|
||||||
|
One open season-dues card. Expects ``row`` ({membership, balance, invoice})
|
||||||
|
in scope -- included from both home.html's dues card and payments.html so
|
||||||
|
the two surfaces render identically (see club.services.fees.open_dues_rows,
|
||||||
|
their shared data source).
|
||||||
|
{% endcomment %}
|
||||||
|
<div class="m-card flex items-center gap-3 p-4">
|
||||||
|
<div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-danger-bg">
|
||||||
|
<span class="font-display text-lg font-extrabold text-club">€</span>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="text-sm font-semibold text-ink">{% blocktrans with name=row.membership.member.first_name %}Season dues — {{ name }}{% endblocktrans %}</div>
|
||||||
|
<div class="text-xs text-muted">
|
||||||
|
{% if row.invoice %}
|
||||||
|
{% blocktrans with amount=row.balance|floatformat:2 due=row.invoice.due_date|date:"d M" %}€ {{ amount }} · due {{ due }}{% endblocktrans %}
|
||||||
|
{% else %}
|
||||||
|
{% blocktrans with amount=row.balance|floatformat:2 %}€ {{ amount }}{% endblocktrans %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="btn btn-dark shrink-0">{% trans "Pay" %}</span>
|
||||||
|
</div>
|
||||||
@@ -112,22 +112,7 @@
|
|||||||
{% if dues_rows %}
|
{% if dues_rows %}
|
||||||
<div class="flex flex-col gap-2.5">
|
<div class="flex flex-col gap-2.5">
|
||||||
{% for row in dues_rows %}
|
{% for row in dues_rows %}
|
||||||
<div class="m-card flex items-center gap-3 p-4">
|
{% include "mobile/_dues_row.html" %}
|
||||||
<div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-danger-bg">
|
|
||||||
<span class="font-display text-lg font-extrabold text-club">€</span>
|
|
||||||
</div>
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="text-sm font-semibold text-ink">{% blocktrans with name=row.membership.member.first_name %}Season dues — {{ name }}{% endblocktrans %}</div>
|
|
||||||
<div class="text-xs text-muted">
|
|
||||||
{% if row.invoice %}
|
|
||||||
{% blocktrans with amount=row.balance|floatformat:2 due=row.invoice.due_date|date:"d M" %}€ {{ amount }} · due {{ due }}{% endblocktrans %}
|
|
||||||
{% else %}
|
|
||||||
{% blocktrans with amount=row.balance|floatformat:2 %}€ {{ amount }}{% endblocktrans %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span class="btn btn-dark shrink-0">{% trans "Pay" %}</span>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@
|
|||||||
M5 -- design_handoff_rosterchief_platform/README.md's M5 section, "Me &
|
M5 -- design_handoff_rosterchief_platform/README.md's M5 section, "Me &
|
||||||
my people". See MeView's own docstring (mobile/views.py) for the judgment
|
my people". See MeView's own docstring (mobile/views.py) for the judgment
|
||||||
calls: no license/eligibility field backing "licence OK", so each row's
|
calls: no license/eligibility field backing "licence OK", so each row's
|
||||||
meta line is real roster data instead; "Household & contacts" and
|
meta line is real roster data instead; "Household & contacts" has no
|
||||||
"Payments & dues" have no screen to lead to and are omitted, same for the
|
screen to lead to and is omitted, same for the mockup's "Coach mode"
|
||||||
mockup's "Coach mode" promo (base.html's own precedent -- no Coach mode
|
promo (base.html's own precedent -- no Coach mode screens exist yet, so
|
||||||
screens exist yet, so it's never rendered, not even as a dead/inert link).
|
it's never rendered, not even as a dead/inert link). "Payments & dues"
|
||||||
|
does lead somewhere (mobile:payments) and carries its "N OPEN" pill only
|
||||||
|
once open_dues_count is actually > 0.
|
||||||
The avatar/name/subtitle row lives in header_extra -- merged into the
|
The avatar/name/subtitle row lives in header_extra -- merged into the
|
||||||
shared navy app-header (base.html) rather than a separately-coloured
|
shared navy app-header (base.html) rather than a separately-coloured
|
||||||
block of its own, matching the design canvas's own M5 markup.
|
block of its own, matching the design canvas's own M5 markup.
|
||||||
@@ -70,6 +72,16 @@
|
|||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" class="shrink-0 text-dim" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" class="shrink-0 text-dim" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg>
|
||||||
</a>
|
</a>
|
||||||
<div class="h-px bg-rule"></div>
|
<div class="h-px bg-rule"></div>
|
||||||
|
<a class="flex items-center gap-3 p-3.5" href="{% url "mobile:payments" %}">
|
||||||
|
<span class="flex-1 text-[15px] font-semibold text-ink">{% trans "Payments & dues" %}</span>
|
||||||
|
{% if open_dues_count %}
|
||||||
|
<span class="pill pill-danger shrink-0">
|
||||||
|
{% blocktrans count counter=open_dues_count %}{{ counter }} OPEN{% plural %}{{ counter }} OPEN{% endblocktrans %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" class="shrink-0 text-dim" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg>
|
||||||
|
</a>
|
||||||
|
<div class="h-px bg-rule"></div>
|
||||||
<a class="flex items-center gap-3 p-3.5" href="{% url "mobile:notifications" %}">
|
<a class="flex items-center gap-3 p-3.5" href="{% url "mobile:notifications" %}">
|
||||||
<span class="flex-1 text-[15px] font-semibold text-ink">{% trans "Notifications" %}</span>
|
<span class="flex-1 text-[15px] font-semibold text-ink">{% trans "Notifications" %}</span>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" class="shrink-0 text-dim" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" class="shrink-0 text-dim" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg>
|
||||||
|
|||||||
32
mobile/templates/mobile/payments.html
Normal file
32
mobile/templates/mobile/payments.html
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{% extends "mobile/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
M5's "Payments & dues" row -- every open season-dues balance across every
|
||||||
|
managed person, one card per open row via mobile/_dues_row.html (the same
|
||||||
|
partial Home's dues card uses, so the two never drift apart visually).
|
||||||
|
White sticky back-header, same pattern as calendar_feed_settings.html --
|
||||||
|
not a tab of its own, active_tab stays "me" (PaymentsView's own docstring).
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="-mx-4 -mt-4 flex items-center gap-1 border-b border-line bg-white px-4 py-3">
|
||||||
|
<a class="-ml-2.5 flex h-11 w-11 shrink-0 items-center justify-center" href="{% url "mobile:me" %}" aria-label="{% trans "Back" %}">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#0b1220" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M15 5l-7 7 7 7"/></svg>
|
||||||
|
</a>
|
||||||
|
<span class="min-w-0 flex-1 truncate font-display text-xl leading-none font-extrabold text-ink uppercase">{% trans "Payments & dues" %}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if dues_rows %}
|
||||||
|
<div class="flex flex-col gap-2.5">
|
||||||
|
{% for row in dues_rows %}
|
||||||
|
{% include "mobile/_dues_row.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="m-card p-6 text-center">
|
||||||
|
<p class="font-display text-lg font-extrabold text-ink uppercase">{% trans "All settled up" %}</p>
|
||||||
|
<p class="mt-1 text-sm text-muted">{% trans "There's nothing open right now." %}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock content %}
|
||||||
100
mobile/tests.py
100
mobile/tests.py
@@ -1238,8 +1238,9 @@ class MeViewTests(TestCase):
|
|||||||
"""M5 -- design_handoff_rosterchief_platform/README.md's M5 section, "Me
|
"""M5 -- design_handoff_rosterchief_platform/README.md's M5 section, "Me
|
||||||
& my people". See MeView's own docstring for the judgment calls: no
|
& my people". See MeView's own docstring for the judgment calls: no
|
||||||
licence/eligibility field backing "licence OK" (real roster data used
|
licence/eligibility field backing "licence OK" (real roster data used
|
||||||
instead), "Household & contacts"/"Payments & dues"/"Coach mode" all
|
instead), "Household & contacts"/"Coach mode" omitted since neither has
|
||||||
omitted since none has anywhere to lead in this build.
|
anywhere to lead in this build; "Payments & dues" does (PaymentsView,
|
||||||
|
tested separately below).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -1333,6 +1334,101 @@ class MeViewTests(TestCase):
|
|||||||
self.assertContains(response, "No one to show yet")
|
self.assertContains(response, "No one to show yet")
|
||||||
self.assertNotContains(response, "Coach mode")
|
self.assertNotContains(response, "Coach mode")
|
||||||
|
|
||||||
|
def test_payments_row_links_to_the_payments_screen(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertContains(response, reverse("mobile:payments"))
|
||||||
|
|
||||||
|
def test_open_dues_pill_is_hidden_with_nothing_owed(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertNotContains(response, "OPEN")
|
||||||
|
|
||||||
|
def test_open_dues_pill_shows_the_open_count(self):
|
||||||
|
membership = ClubMembership.objects.get(club=self.club, member=self.child, season=self.season)
|
||||||
|
membership.fee_amount = Decimal("150.00")
|
||||||
|
membership.save()
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertEqual(response.context["open_dues_count"], 1)
|
||||||
|
self.assertContains(response, "1 OPEN")
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
|
||||||
|
class PaymentsViewTests(TestCase):
|
||||||
|
"""M5's "Payments & dues" row -- every open season-dues balance across
|
||||||
|
every managed person, reusing club.services.fees.open_dues_rows (see
|
||||||
|
club.tests.OpenDuesRowsTests for the exclusion rules themselves)."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.club = make_club()
|
||||||
|
today = timezone.localdate()
|
||||||
|
cls.season = Season.objects.create(club=cls.club, start_date=today - datetime.timedelta(days=30), end_date=today + datetime.timedelta(days=300))
|
||||||
|
cls.user = User.objects.create_user(email="parent@example.com", password="pw-secret-123")
|
||||||
|
cls.member = Member.objects.create(first_name="Lars", last_name="Bakker", email="parent@example.com", user=cls.user)
|
||||||
|
cls.membership = ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season)
|
||||||
|
|
||||||
|
def _get(self):
|
||||||
|
return self.client.get(reverse("mobile:payments"), HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
def test_requires_login(self):
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
def test_back_link_returns_to_me(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertContains(response, reverse("mobile:me"))
|
||||||
|
|
||||||
|
def test_nothing_owed_shows_a_settled_up_empty_state(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertContains(response, "All settled up")
|
||||||
|
|
||||||
|
def test_an_open_balance_shows_the_amount_and_a_pay_button(self):
|
||||||
|
self.membership.fee_amount = Decimal("150.00")
|
||||||
|
self.membership.save()
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertEqual(len(response.context["dues_rows"]), 1)
|
||||||
|
self.assertContains(response, "150.00")
|
||||||
|
self.assertContains(response, "Pay")
|
||||||
|
self.assertNotContains(response, "All settled up")
|
||||||
|
|
||||||
|
def test_a_fully_paid_balance_does_not_show(self):
|
||||||
|
self.membership.fee_amount = Decimal("150.00")
|
||||||
|
self.membership.fee_status = ClubMembership.FeeStatus.PAID
|
||||||
|
self.membership.amount_paid = Decimal("150.00")
|
||||||
|
self.membership.save()
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertEqual(response.context["dues_rows"], [])
|
||||||
|
|
||||||
|
def test_only_shows_balances_for_managed_people(self):
|
||||||
|
other_member = Member.objects.create(first_name="Tom", last_name="Roe")
|
||||||
|
ClubMembership.objects.create(club=self.club, member=other_member, season=self.season, fee_amount=Decimal("200.00"))
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertEqual(response.context["dues_rows"], [])
|
||||||
|
|
||||||
|
|
||||||
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
|
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
|
||||||
class EditProfileViewTests(TestCase):
|
class EditProfileViewTests(TestCase):
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ urlpatterns = [
|
|||||||
path("news/", views.NewsListView.as_view(), name="news_list"),
|
path("news/", views.NewsListView.as_view(), name="news_list"),
|
||||||
path("news/<slug:slug>/", views.NewsDetailView.as_view(), name="news_detail"),
|
path("news/<slug:slug>/", views.NewsDetailView.as_view(), name="news_detail"),
|
||||||
path("me/", views.MeView.as_view(), name="me"),
|
path("me/", views.MeView.as_view(), name="me"),
|
||||||
|
path("me/payments/", views.PaymentsView.as_view(), name="payments"),
|
||||||
path("me/calendar-sync/", views.CalendarFeedSettingsView.as_view(), name="calendar_feed_settings"),
|
path("me/calendar-sync/", views.CalendarFeedSettingsView.as_view(), name="calendar_feed_settings"),
|
||||||
path("me/<uuid:member_id>/edit/", views.EditProfileView.as_view(), name="edit_profile"),
|
path("me/<uuid:member_id>/edit/", views.EditProfileView.as_view(), name="edit_profile"),
|
||||||
path("notifications/", views.NotificationsView.as_view(), name="notifications"),
|
path("notifications/", views.NotificationsView.as_view(), name="notifications"),
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from django.views.generic import TemplateView
|
|||||||
|
|
||||||
from club.models import ClubMembership
|
from club.models import ClubMembership
|
||||||
from club.services.access import current_season, has_management_access, teams_managed_by
|
from club.services.access import current_season, has_management_access, teams_managed_by
|
||||||
from club.services.fees import remaining_balance
|
from club.services.fees import open_dues_rows
|
||||||
from club.services.onboarding import checklist_for
|
from club.services.onboarding import checklist_for
|
||||||
from club.services.sponsors import active_sponsors
|
from club.services.sponsors import active_sponsors
|
||||||
from controlpanel.messages import notify
|
from controlpanel.messages import notify
|
||||||
@@ -229,16 +229,7 @@ class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
|||||||
|
|
||||||
season = current_season(self.request.club)
|
season = current_season(self.request.club)
|
||||||
if season is not None:
|
if season is not None:
|
||||||
memberships = (
|
dues_rows = open_dues_rows(self.request.club, people, season)
|
||||||
ClubMembership.objects.filter(club=self.request.club, member__in=people, season=season)
|
|
||||||
.exclude(fee_status=ClubMembership.FeeStatus.WAIVED)
|
|
||||||
.select_related("dues_invoice", "member")
|
|
||||||
)
|
|
||||||
for membership in memberships:
|
|
||||||
balance = remaining_balance(membership)
|
|
||||||
if balance > 0:
|
|
||||||
dues_rows.append({"membership": membership, "balance": balance, "invoice": getattr(membership, "dues_invoice", None)})
|
|
||||||
|
|
||||||
team_ids = list(TeamMembership.objects.filter(member__in=people, season=season).values_list("team_id", flat=True))
|
team_ids = list(TeamMembership.objects.filter(member__in=people, season=season).values_list("team_id", flat=True))
|
||||||
else:
|
else:
|
||||||
team_ids = []
|
team_ids = []
|
||||||
@@ -538,10 +529,12 @@ class MeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
|||||||
roster), and a settings-ish card linking into M6 (edit_profile) for
|
roster), and a settings-ish card linking into M6 (edit_profile) for
|
||||||
``self.me`` and into M7 (notifications).
|
``self.me`` and into M7 (notifications).
|
||||||
|
|
||||||
The mockup's "Household & contacts" and "Payments & dues" rows, and its
|
The mockup's "Household & contacts" row and its "Coach mode" promo card
|
||||||
"Coach mode" promo card, have nowhere to lead in this build (no dedicated
|
have nowhere to lead in this build (no dedicated screen, no Coach mode
|
||||||
screen, no Coach mode screens at all yet -- see base.html's own comment)
|
screens at all yet -- see base.html's own comment) and are deliberately
|
||||||
and are deliberately omitted rather than built as dead or inert links.
|
omitted rather than built as dead or inert links. "Payments & dues" does
|
||||||
|
lead somewhere -- PaymentsView below -- with its "N OPEN" pill only
|
||||||
|
rendered once there's actually a balance owed.
|
||||||
|
|
||||||
There's no license/eligibility field on Member or ClubMembership to power
|
There's no license/eligibility field on Member or ClubMembership to power
|
||||||
the mockup's "licence OK" text, so each managed person's meta line is
|
the mockup's "licence OK" text, so each managed person's meta line is
|
||||||
@@ -591,14 +584,36 @@ class MeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
|||||||
}
|
}
|
||||||
people_rows = [{"person": person, "membership": memberships_by_member.get(person.pk)} for person in self.managed_people]
|
people_rows = [{"person": person, "membership": memberships_by_member.get(person.pk)} for person in self.managed_people]
|
||||||
|
|
||||||
|
open_dues_count = len(open_dues_rows(club, self.managed_people, season))
|
||||||
|
|
||||||
return super().get_context_data(
|
return super().get_context_data(
|
||||||
member_since=member_since,
|
member_since=member_since,
|
||||||
team_manager_label=team_manager_label,
|
team_manager_label=team_manager_label,
|
||||||
people_rows=people_rows,
|
people_rows=people_rows,
|
||||||
|
open_dues_count=open_dues_count,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentsView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
||||||
|
"""M5's "Payments & dues" row -- every open season-dues balance across
|
||||||
|
``self.managed_people``, one card per person owed, in the same €-badge /
|
||||||
|
amount+due-date / Pay layout as Home's dues card (club.services.fees.
|
||||||
|
open_dues_rows is the shared source for both). No online payment gateway
|
||||||
|
exists yet, so "Pay" is the same non-functional stub as Home's -- this
|
||||||
|
screen's job is visibility ("what do I owe, and when"), not collection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
template_name = "mobile/payments.html"
|
||||||
|
screen_title = _("Payments & dues")
|
||||||
|
active_tab = "me"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
season = current_season(self.request.club)
|
||||||
|
dues_rows = open_dues_rows(self.request.club, self.managed_people, season)
|
||||||
|
return super().get_context_data(dues_rows=dues_rows, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class EditProfileView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
class EditProfileView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
||||||
"""M6 -- design_handoff_rosterchief_platform/README.md's M6 section,
|
"""M6 -- design_handoff_rosterchief_platform/README.md's M6 section,
|
||||||
"Edit personal info". The design mock also shows a "National register
|
"Edit personal info". The design mock also shows a "National register
|
||||||
|
|||||||
Reference in New Issue
Block a user