Admin paid indicator, CSV year filter, and payment email search (#30) (#31)
Unit Tests / test (push) Successful in 5s

## Summary
Closes #30.

- Green check (“Paid this year”) on membership list, person list, and person inline when `Payments.email` matches `MembershipPerson.email` (case-insensitive) for the current calendar year. Also matches via the payment’s person FK.
- CSV downloads for payments, memberships by person, and memberships by address prompt for a year (default current year, or All years). Payments filter by payment date; memberships filter by record created year. Changelists also get a year sidebar filter.
- Payments admin remains searchable by email.

## Test plan
- [ ] `uv run python manage.py test`
- [ ] In admin, open Memberships / Membership people and confirm a green check when a same-year payment email matches, and a red X otherwise
- [ ] Select memberships → Download CSV by member/address → pick a year → confirm only that year is in the file
- [ ] Select payments → Download payments CSV → pick a year → confirm filter
- [ ] On Payments, search by email and filter by year

Reviewed-on: #31
This commit was merged in pull request #31.
This commit is contained in:
2026-09-03 13:51:09 -07:00
parent f37ff3e87b
commit 531d97f3fc
3 changed files with 490 additions and 123 deletions
+185
View File
@@ -6,12 +6,17 @@ from django.urls import reverse
from scha.settings.base import build_csrf_trusted_origins
from django.contrib.auth.models import User
from django.utils import timezone
from .admin import membership_payment_exists, person_payment_exists
from .forms import AddressForm, PeopleForm
from .models import (
AddressModel1,
CalendarEvent,
Membership,
MembershipPerson,
Payments,
SCHAOfficer,
UsefulLinks,
)
@@ -469,3 +474,183 @@ class AuthViewTests(TestCase):
response = self.views.member_posts(request)
self.assertEqual(response.status_code, 302)
self.assertIn("/login", response.url)
def _membership_with_person(email="alex@example.com"):
membership = Membership.objects.create()
AddressModel1.objects.create(
membership=membership,
address_1="123 Main St",
city="Wheaton",
state="IL",
zip_code="60189",
)
person = MembershipPerson.objects.create(
membership=membership,
first_name="Alex",
last_name="Resident",
email=email,
phone_number="+16305559876",
)
return membership, person
class AdminPaidIndicatorTests(TestCase):
def setUp(self):
self.client = Client()
self.admin_user = User.objects.create_superuser(
"admin", "admin@example.com", "pass"
)
self.client.force_login(self.admin_user)
self.this_year = timezone.now().year
self.membership, self.person = _membership_with_person("paid@example.com")
def test_person_marked_paid_when_email_matches_current_year_payment(self):
Payments.objects.create(
email="PAID@example.com",
date=datetime.date(self.this_year, 3, 1),
)
paid = MembershipPerson.objects.annotate(
_has_payment=person_payment_exists()
).get(pk=self.person.pk)
self.assertTrue(paid._has_payment)
unpaid_membership, unpaid_person = _membership_with_person("unpaid@example.com")
unpaid = MembershipPerson.objects.annotate(
_has_payment=person_payment_exists()
).get(pk=unpaid_person.pk)
self.assertFalse(unpaid._has_payment)
membership = Membership.objects.annotate(
_has_payment=membership_payment_exists()
).get(pk=self.membership.pk)
self.assertTrue(membership._has_payment)
other = Membership.objects.annotate(
_has_payment=membership_payment_exists()
).get(pk=unpaid_membership.pk)
self.assertFalse(other._has_payment)
def test_prior_year_payment_does_not_count_as_paid(self):
Payments.objects.create(
email="paid@example.com",
date=datetime.date(self.this_year - 1, 6, 1),
)
person = MembershipPerson.objects.annotate(
_has_payment=person_payment_exists()
).get(pk=self.person.pk)
self.assertFalse(person._has_payment)
def test_person_changelist_shows_paid_icon(self):
Payments.objects.create(
email="paid@example.com",
date=datetime.date(self.this_year, 4, 15),
)
url = reverse("admin:schasite_membershipperson_changelist")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Paid this year")
self.assertContains(response, "icon-yes.svg")
class AdminPaymentSearchTests(TestCase):
def setUp(self):
self.client = Client()
self.admin_user = User.objects.create_superuser(
"admin", "admin@example.com", "pass"
)
self.client.force_login(self.admin_user)
Payments.objects.create(email="dues@example.com", date=datetime.date(2026, 1, 10))
Payments.objects.create(email="other@example.com", date=datetime.date(2026, 2, 10))
def test_payments_admin_search_by_email(self):
url = reverse("admin:schasite_payments_changelist")
response = self.client.get(url, {"q": "dues@example.com"})
self.assertEqual(response.status_code, 200)
self.assertContains(response, "dues@example.com")
self.assertNotContains(response, "other@example.com")
class AdminCsvYearFilterTests(TestCase):
def setUp(self):
self.client = Client()
self.admin_user = User.objects.create_superuser(
"admin", "admin@example.com", "pass"
)
self.client.force_login(self.admin_user)
self.current, self.current_person = _membership_with_person("now@example.com")
self.old, self.old_person = _membership_with_person("old@example.com")
Membership.objects.filter(pk=self.old.pk).update(
created=timezone.make_aware(datetime.datetime(2024, 5, 1, 12, 0, 0))
)
Payments.objects.create(email="now@example.com", date=datetime.date(2026, 3, 1))
Payments.objects.create(email="old@example.com", date=datetime.date(2024, 3, 1))
def test_membership_csv_prompts_for_year(self):
url = reverse("admin:schasite_membership_changelist")
response = self.client.post(
url,
{
"action": "download_csv_by_members",
"_selected_action": [str(self.current.pk), str(self.old.pk)],
"index": "0",
},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Year:")
self.assertContains(response, "Download CSV")
def test_membership_csv_filters_by_created_year(self):
url = reverse("admin:schasite_membership_changelist")
response = self.client.post(
url,
{
"action": "download_csv_by_members",
"_selected_action": [str(self.current.pk), str(self.old.pk)],
"index": "0",
"apply": "Download CSV",
"year": "2024",
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "text/csv")
content = response.content.decode()
self.assertIn("old@example.com", content)
self.assertNotIn("now@example.com", content)
def test_membership_csv_by_address_filters_by_created_year(self):
url = reverse("admin:schasite_membership_changelist")
response = self.client.post(
url,
{
"action": "download_csv_by_address",
"_selected_action": [str(self.current.pk), str(self.old.pk)],
"index": "0",
"apply": "Download CSV",
"year": "2024",
},
)
self.assertEqual(response.status_code, 200)
content = response.content.decode()
self.assertIn("old@example.com", content)
self.assertNotIn("now@example.com", content)
def test_payments_csv_filters_by_payment_year(self):
url = reverse("admin:schasite_payments_changelist")
payments = list(Payments.objects.values_list("pk", flat=True))
response = self.client.post(
url,
{
"action": "download_payments",
"_selected_action": [str(pk) for pk in payments],
"index": "0",
"apply": "Download CSV",
"year": "2024",
},
)
self.assertEqual(response.status_code, 200)
content = response.content.decode()
self.assertIn("old@example.com", content)
self.assertNotIn("now@example.com", content)