Add families list, member kind filter, sidebar counters, pagination

New Families list page (parents/children columns, edit goes to the
detail view). Members list gets a member/guardian/both filter so
guardians aren't just invisible. Sidebar now shows live counts for
pending parent claims and games missing a referee, always numeric.
Member, event, team, group, news and family lists are paginated with
a shared pager partial that preserves the query string.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 23:53:00 +02:00
parent b2657bb15d
commit 4be10a57e0
13 changed files with 550 additions and 17 deletions

View File

@@ -11,6 +11,7 @@ from waffle import flag_is_active
from billing.services.notices import club_billing_notice
from club.services.access import can_add_news, has_management_access, is_club_admin, is_coach_manager
from members.models import ParentClaim
#: Every management URL name, mapped to the nav item it should light up --
#: management/templates/management/_nav_items.html compares against this.
@@ -31,6 +32,7 @@ _NAV_SECTIONS = {
"member_grant_login": "member_list",
"member_referee_eligibility_update": "member_list",
"member_detach_family": "member_list",
"family_list": "family_list",
"family_create": "member_list",
"family_detail": "member_list",
"family_add_child": "member_list",
@@ -210,3 +212,30 @@ def news_permissions(request):
return {"can_add_news": False}
return {"can_add_news": can_add_news(request.user, club)}
def sidebar_counters(request):
"""Small always-visible counts next to two nav links that flag a queue
waiting on an admin: pending parent claims, and upcoming club-managed games
nobody's down to referee yet (management.views.games_missing_referees_count,
the same shape RefereeManagementDashboardView's own kpi_no_referee uses for
its default "next 10" range).
Admin-only, matching how _nav_items.html itself gates both links (`{% if
is_club_admin %}`) -- a coach never sees either link, so there's no reason
to run either query for them. Always an int when shown, never hidden at 0:
"the queue is empty" and "nobody checked" have to read differently.
"""
club = getattr(request, "club", None)
if club is None or not request.user.is_authenticated or not is_club_admin(request.user, club):
return {"pending_parent_claims_count": None, "games_missing_referees_count": None}
# Imported here rather than at module level to keep this module's own import
# graph small -- management.views pulls in most of the app's models/services,
# none of which any other context processor here needs.
from management.views import RefereeManagementDashboardView, games_missing_referees_count
return {
"pending_parent_claims_count": ParentClaim.objects.filter(club=club, status=ParentClaim.Status.PENDING).count(),
"games_missing_referees_count": games_missing_referees_count(club, limit=int(RefereeManagementDashboardView.DEFAULT_RANGE)),
}

View File

@@ -16,8 +16,14 @@
<li class="menu-title">{% trans "People" %}</li>
<li><a class="{% if nav == 'member_list' %}menu-active{% endif %}" href="{% url 'management:member_list' %}">{% lucide "users" size=16 %} {% trans "Members" %}</a></li>
<li><a class="{% if nav == 'family_list' %}menu-active{% endif %}" href="{% url 'management:family_list' %}">{% lucide "home" size=16 %} {% trans "Families" %}</a></li>
{% if is_club_admin %}
<li><a class="{% if nav == 'parent_claim_list' %}menu-active{% endif %}" href="{% url 'management:parent_claim_list' %}">{% lucide "inbox" size=16 %} {% trans "Parent claims" %}</a></li>
<li>
<a class="{% if nav == 'parent_claim_list' %}menu-active{% endif %}" href="{% url 'management:parent_claim_list' %}">
{% lucide "inbox" size=16 %} {% trans "Parent claims" %}
<span class="badge badge-sm ml-auto {% if pending_parent_claims_count %}badge-error{% else %}badge-neutral{% endif %}">{{ pending_parent_claims_count }}</span>
</a>
</li>
{% endif %}
{% if is_club_admin %}
<li><a class="{% if nav == 'membership_list' %}menu-active{% endif %}" href="{% url 'management:membership_list' %}">{% lucide "wallet" size=16 %} {% trans "Memberships" %}</a></li>
@@ -30,7 +36,12 @@
<li><a class="{% if nav == 'position_list' %}menu-active{% endif %}" href="{% url 'management:position_list' %}">{% lucide "tags" size=16 %} {% trans "Positions" %}</a></li>
{% if is_club_admin %}
<li><a class="{% if nav == 'referee_level_list' %}menu-active{% endif %}" href="{% url 'management:referee_level_list' %}">{% lucide "badge-check" size=16 %} {% trans "Referee levels" %}</a></li>
<li><a class="{% if nav == 'referee_management' %}menu-active{% endif %}" href="{% url 'management:referee_management' %}">{% lucide "calendar-check" size=16 %} {% trans "Referee management" %}</a></li>
<li>
<a class="{% if nav == 'referee_management' %}menu-active{% endif %}" href="{% url 'management:referee_management' %}">
{% lucide "calendar-check" size=16 %} {% trans "Referee management" %}
<span class="badge badge-sm ml-auto {% if games_missing_referees_count %}badge-error{% else %}badge-neutral{% endif %}">{{ games_missing_referees_count }}</span>
</a>
</li>
{% endif %}
<li><a class="{% if nav == 'referee_list' %}menu-active{% endif %}" href="{% url 'management:referee_list' %}">{% lucide "flag" size=16 %} {% trans "Referees" %}</a></li>

View File

@@ -0,0 +1,32 @@
{% load i18n lucide %}
{% comment %}
Shared pager for every paginated management list (see paginate_by on
MemberListView/EventListView/TeamListView/GroupListView/NewsListView/
FamilyListView) -- {% include %} this once rather than duplicating prev/next
markup per template. Expects the standard ListView pagination context
(page_obj/paginator/is_paginated); renders nothing on an unpaginated page.
{% querystring %} (built-in since Django 5.1) rebuilds the current query
string with just `page` overridden, so an active ?q=/?kind=/?season= filter
survives a page link instead of being dropped.
{% endcomment %}
{% if is_paginated %}
<div class="mt-4 flex flex-wrap items-center justify-between gap-2">
<span class="text-sm opacity-60">
{% blocktrans with number=page_obj.number total=paginator.num_pages %}Page {{ number }} of {{ total }}{% endblocktrans %}
</span>
<div class="join">
{% if page_obj.has_previous %}
<a href="{% querystring page=page_obj.previous_page_number %}" class="btn btn-sm join-item">{% lucide "chevron-left" size=14 %} {% trans "Previous" %}</a>
{% else %}
<span class="btn btn-sm join-item btn-disabled">{% lucide "chevron-left" size=14 %} {% trans "Previous" %}</span>
{% endif %}
{% if page_obj.has_next %}
<a href="{% querystring page=page_obj.next_page_number %}" class="btn btn-sm join-item">{% trans "Next" %} {% lucide "chevron-right" size=14 %}</a>
{% else %}
<span class="btn btn-sm join-item btn-disabled">{% trans "Next" %} {% lucide "chevron-right" size=14 %}</span>
{% endif %}
</div>
</div>
{% endif %}

View File

@@ -80,6 +80,8 @@
</div>
</div>
{% include "management/_pagination.html" %}
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
{% for event in events %}
{% if event.can_manage %}

View File

@@ -0,0 +1,61 @@
{% extends "management/base.html" %}
{% load i18n lucide %}
{% block heading %}{% trans "Families" %}{% endblock heading %}
{% block subheading %}{% trans "Every household on file -- who their parents/guardians and children are." %}{% endblock subheading %}
{% block actions %}
{% if is_club_admin %}
<a class="btn btn-outline gap-2" href="{% url 'management:family_create' %}">{% lucide "users" size=16 %} {% trans "Add family" %}</a>
{% endif %}
{% endblock actions %}
{% block panel %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "Family" %}</th>
<th>{% trans "Parents / guardians" %}</th>
<th>{% trans "Children" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for family in families %}
<tr>
<td><a class="link link-hover font-semibold" href="{% url 'management:family_detail' family.pk %}">{{ family }}</a></td>
<td>
{% for guardian in family.guardians_display %}
<a class="link link-hover" href="{% url 'management:member_detail' guardian.pk %}">{{ guardian }}</a>{% if not forloop.last %}, {% endif %}
{% empty %}
<span class="opacity-40">-</span>
{% endfor %}
</td>
<td>
{% for child in family.children_display %}
<a class="link link-hover" href="{% url 'management:member_detail' child.pk %}">{{ child }}</a>{% if not forloop.last %}, {% endif %}
{% empty %}
<span class="opacity-40">-</span>
{% endfor %}
</td>
<td class="text-right">
{# Same convention as Groups: "Edit" lands on the overview page, not a standalone rename form -- family_detail is where every family action (add parent/child, change role, remove) actually lives. #}
<a class="btn btn-outline btn-sm" href="{% url 'management:family_detail' family.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="4" class="text-center opacity-60">{% trans "No families yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% include "management/_pagination.html" %}
{% endblock panel %}

View File

@@ -43,6 +43,8 @@
</div>
</div>
{% include "management/_pagination.html" %}
{% trans "Delete group" as delete_group_title %}
{% trans "Delete" as delete_label %}
{% for group in groups %}

View File

@@ -13,17 +13,35 @@
{% endblock actions %}
{% block panel %}
<form method="get" class="mb-4 flex gap-2">
<form method="get" class="mb-2 flex flex-wrap items-center gap-2">
<label class="input">
<span class="opacity-50">{% lucide "search" size=16 %}</span>
<input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Search members ...' %}" class="input input-bordered w-full max-w-xs">
</label>
{% if selected_kind != "member" %}<input type="hidden" name="kind" value="{{ selected_kind }}">{% endif %}
<button class="btn btn-outline gap-2" type="submit">{% lucide "search" size=16 %} {% trans "Search" %}</button>
{% if search %}
<a class="btn gap-2" href="{% url "management:member_list" %}">{% lucide "x" size=16 %} {% trans "Clear filter" %}</a>
{% endif %}
</form>
{% comment %}
Guardians -- parents attached to the club only through a child -- have no
fee and aren't counted as members, so members_visible_to() leaves them out
of the default list entirely. Without this filter that's silent: a parent
just registered via "Add family"/"Add parent" simply doesn't appear here,
with nothing on the page explaining why. ?kind= makes the exclusion an
explicit, reversible choice instead.
{% endcomment %}
<div class="mb-4 flex flex-wrap items-center gap-2">
<div class="join">
<a href="{% querystring kind=None page=None %}" class="btn btn-sm join-item {% if selected_kind == "member" %}btn-primary{% endif %}">{% trans "Members" %}</a>
<a href="{% querystring kind="guardian" page=None %}" class="btn btn-sm join-item {% if selected_kind == "guardian" %}btn-primary{% endif %}">{% trans "Guardians" %}</a>
<a href="{% querystring kind="both" page=None %}" class="btn btn-sm join-item {% if selected_kind == "both" %}btn-primary{% endif %}">{% trans "Both" %}</a>
</div>
<span class="text-xs opacity-60">{% trans "Guardians are parents linked only through a child -- no fee, not counted as a member." %}</span>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="overflow-x-auto">
@@ -90,6 +108,8 @@
</div>
</div>
{% include "management/_pagination.html" %}
{% if is_club_admin %}
{% trans "Delete member" as delete_member_title %}
{% trans "Delete" as delete_label %}

View File

@@ -64,6 +64,8 @@
</div>
</div>
{% include "management/_pagination.html" %}
{% trans "Delete news item" as delete_news_title %}
{% trans "Delete" as delete_label %}
{% for news_item in news_items %}

View File

@@ -50,6 +50,8 @@
</div>
</div>
{% include "management/_pagination.html" %}
{% if is_club_admin %}
{% trans "Delete team" as delete_team_title %}
{% trans "Delete" as delete_label %}

View File

@@ -3703,7 +3703,10 @@ class TeamListCountsTests(ManagementTestBase):
response = self.club_get("team_list")
team = response.context["teams"].get(pk=self.team.pk)
# Not .get(pk=...): the team list is now paginated, and a Page's
# object_list is a sliced queryset -- Django refuses to .filter()/.get()
# a queryset once it's been sliced.
team = next(t for t in response.context["teams"] if t.pk == self.team.pk)
self.assertEqual(team.player_count, 2)
self.assertEqual(team.staff_count, 1)
@@ -3714,7 +3717,7 @@ class TeamListCountsTests(ManagementTestBase):
response = self.club_get("team_list")
team = response.context["teams"].get(pk=self.team.pk)
team = next(t for t in response.context["teams"] if t.pk == self.team.pk)
self.assertEqual(team.player_count, 0)
@@ -5598,3 +5601,279 @@ class RBIHFImportViewTests(ManagementTestBase):
self.assertContains(response, "already up to date")
self.assertEqual(Event.objects.filter(club=self.club, external_game_id="5002").count(), 1)
class MemberListKindFilterTests(ManagementTestBase):
"""?kind= on the member list -- see MemberListView.get_queryset. "member"
(the default) matches the page's original guardian-excluding behaviour;
"guardian" and "both" turn that exclusion into an explicit, reversible
choice instead of a parent silently disappearing after being registered."""
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.family = Family.objects.create()
cls.parent = Member.objects.create(first_name="Pat", last_name="Guardian")
cls.child = Member.objects.create(first_name="Cody", last_name="Kid")
FamilyMembership.objects.create(family=cls.family, member=cls.parent, role=FamilyMembership.FamilyRole.PARENT)
FamilyMembership.objects.create(family=cls.family, member=cls.child, role=FamilyMembership.FamilyRole.CHILD)
ClubMembership.objects.create(club=cls.club, member=cls.parent, season=cls.season, kind=ClubMembership.Kind.GUARDIAN, status=ClubMembership.StatusChoices.ACTIVE)
ClubMembership.objects.create(club=cls.club, member=cls.child, season=cls.season, kind=ClubMembership.Kind.MEMBER, status=ClubMembership.StatusChoices.ACTIVE)
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def get(self, kind=None):
url = reverse("management:member_list")
if kind is not None:
url += f"?kind={kind}"
return self.client.get(url, HTTP_HOST="ajax-united.rosterchief.app")
def test_default_excludes_the_guardian(self):
response = self.get()
self.assertContains(response, "Cody Kid")
self.assertNotContains(response, "Pat Guardian")
self.assertEqual(response.context["selected_kind"], "member")
def test_kind_guardian_shows_only_the_guardian(self):
response = self.get("guardian")
self.assertContains(response, "Pat Guardian")
self.assertNotContains(response, "Cody Kid")
def test_kind_both_shows_everyone(self):
response = self.get("both")
self.assertContains(response, "Pat Guardian")
self.assertContains(response, "Cody Kid")
def test_an_unrecognised_kind_falls_back_to_the_default(self):
response = self.get("bogus")
self.assertNotContains(response, "Pat Guardian")
self.assertEqual(response.context["selected_kind"], "member")
def test_a_non_admins_guardian_view_stays_within_their_own_visibility(self):
# _guardians_only(club) is club-wide -- it's intersected with
# members_visible_to() so a non-admin only ever sees guardians of
# someone already visible to them, never every guardian in the club.
coach_user = User.objects.create_user(email="coach-kind@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
team = Team.objects.create(club=self.club, name="U9", short_name="U9")
position = Position.objects.create(club=self.club, name="Coach-kind", short_name="CK", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
self.client.force_login(coach_user)
response = self.get("both")
self.assertNotContains(response, "Pat Guardian")
class FamilyListViewTests(ManagementTestBase):
"""The dedicated Families page -- one row per family, parents/guardians and
children in separate columns, with the family name and a per-row "Edit"
action both landing on family_detail (the same overview-page convention
the Groups list uses for its own Edit button, not a standalone form)."""
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.family = Family.objects.create(name="The Smiths")
cls.parent = Member.objects.create(first_name="Pat", last_name="Smith")
cls.child = Member.objects.create(first_name="Cody", last_name="Smith")
FamilyMembership.objects.create(family=cls.family, member=cls.parent, role=FamilyMembership.FamilyRole.PARENT)
FamilyMembership.objects.create(family=cls.family, member=cls.child, role=FamilyMembership.FamilyRole.CHILD)
ClubMembership.objects.create(club=cls.club, member=cls.parent, season=cls.season, kind=ClubMembership.Kind.GUARDIAN, status=ClubMembership.StatusChoices.ACTIVE)
ClubMembership.objects.create(club=cls.club, member=cls.child, season=cls.season, kind=ClubMembership.Kind.MEMBER, status=ClubMembership.StatusChoices.ACTIVE)
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def test_lists_the_family_with_parents_and_children_columns(self):
response = self.club_get("family_list")
self.assertContains(response, "The Smiths")
self.assertContains(response, "Pat Smith")
self.assertContains(response, "Cody Smith")
def test_the_family_name_and_edit_action_both_link_to_family_detail(self):
response = self.club_get("family_list")
detail_url = reverse("management:family_detail", args=[self.family.pk])
self.assertContains(response, f'href="{detail_url}"', count=2)
def test_a_family_from_another_club_is_excluded(self):
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
other_season = make_season(other_club)
other_family = Family.objects.create()
other_member = Member.objects.create(first_name="Other", last_name="Kid")
ClubMembership.objects.create(club=other_club, member=other_member, season=other_season, status=ClubMembership.StatusChoices.ACTIVE)
FamilyMembership.objects.create(family=other_family, member=other_member, role=FamilyMembership.FamilyRole.CHILD)
response = self.club_get("family_list")
self.assertNotContains(response, "Other Kid")
def test_the_families_nav_link_is_reachable(self):
response = self.club_get("home")
self.assertContains(response, reverse("management:family_list"))
def test_a_non_admin_only_sees_families_they_have_a_reason_to(self):
# Same visibility rule as FamilyDetailView: a coach with no tie to this
# family shouldn't learn it exists just by opening the Families list.
coach_user = User.objects.create_user(email="coach-fam@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
team = Team.objects.create(club=self.club, name="U11", short_name="U11")
position = Position.objects.create(club=self.club, name="Coach-fam", short_name="CF", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
self.client.force_login(coach_user)
response = self.club_get("family_list")
self.assertNotContains(response, "The Smiths")
class SidebarCounterTests(ManagementTestBase):
"""The nav's two admin-only badges -- pending parent claims, and upcoming
club-managed games nobody's down to referee yet. See
management.context_processors.sidebar_counters."""
def make_pending_claim(self):
return ParentClaim.objects.create(
club=self.club,
parent_first_name="Pat",
parent_last_name="Parent",
parent_email="pat-claim@example.com",
child_first_name="Cody",
child_last_name="Child",
child_date_of_birth=datetime.date(2015, 1, 1),
)
def make_home_game(self, referee=None):
team = Team.objects.create(club=self.club, name="First Team", short_name="1st")
home_ground = Location.objects.create(club=self.club, name="Home Ground", address="1 St", city="Town", zip_code="1000", country="BE", is_home=True)
event = Event.objects.create(club=self.club, title="Cup game", kind=Event.EventKind.GAME, location=home_ground, start=timezone.now() + datetime.timedelta(days=1))
event.teams.add(team)
if referee is not None:
EventReferee.objects.create(event=event, member=referee, assigned_by=self.admin_member)
return event
def test_zero_is_shown_explicitly_when_nothing_is_pending(self):
self.client.force_login(self.admin_user)
response = self.club_get("home")
self.assertEqual(response.context["pending_parent_claims_count"], 0)
self.assertEqual(response.context["games_missing_referees_count"], 0)
def test_counts_reflect_a_pending_claim_and_an_unrefereed_game(self):
self.make_pending_claim()
self.make_home_game()
self.client.force_login(self.admin_user)
response = self.club_get("home")
self.assertEqual(response.context["pending_parent_claims_count"], 1)
self.assertEqual(response.context["games_missing_referees_count"], 1)
def test_a_refereed_game_does_not_count(self):
referee = Member.objects.create(first_name="Ref", last_name="Eree")
ClubMembership.objects.create(club=self.club, member=referee, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
self.make_home_game(referee=referee)
self.client.force_login(self.admin_user)
response = self.club_get("home")
self.assertEqual(response.context["games_missing_referees_count"], 0)
def test_an_already_reviewed_claim_does_not_count(self):
claim = self.make_pending_claim()
claim.status = ParentClaim.Status.APPROVED
claim.save(update_fields=["status"])
self.client.force_login(self.admin_user)
response = self.club_get("home")
self.assertEqual(response.context["pending_parent_claims_count"], 0)
def test_a_non_admin_gets_no_counters_and_no_badge_links(self):
coach_user = User.objects.create_user(email="coach-badge@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
team = Team.objects.create(club=self.club, name="U10", short_name="U10")
position = Position.objects.create(club=self.club, name="Coach-badge", short_name="CB", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
self.client.force_login(coach_user)
response = self.club_get("home")
self.assertIsNone(response.context["pending_parent_claims_count"])
self.assertIsNone(response.context["games_missing_referees_count"])
self.assertNotContains(response, reverse("management:parent_claim_list"))
self.assertNotContains(response, reverse("management:referee_management"))
class ManagementListPaginationTests(ManagementTestBase):
"""paginate_by on the six paginated lists (MemberListView/EventListView/
TeamListView/GroupListView/NewsListView/FamilyListView) and the shared
management/_pagination.html pager. Exercised with a patched-down page size
rather than dozens of fixture rows."""
def setUp(self):
super().setUp()
self.client.force_login(self.admin_user)
def make_members(self, count, last_name="Match"):
members = []
for i in range(count):
member = Member.objects.create(first_name=f"Search{i}", last_name=last_name)
ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
members.append(member)
return members
def test_page_2_shows_different_rows_than_page_1(self):
self.make_members(5)
with mock.patch("management.views.MemberListView.paginate_by", 2):
page1 = self.client.get(reverse("management:member_list") + "?q=Match", HTTP_HOST="ajax-united.rosterchief.app")
page2 = self.client.get(reverse("management:member_list") + "?q=Match&page=2", HTTP_HOST="ajax-united.rosterchief.app")
page1_ids = {member.pk for member in page1.context["members"]}
page2_ids = {member.pk for member in page2.context["members"]}
self.assertEqual(len(page1_ids), 2)
self.assertEqual(len(page2_ids), 2)
self.assertEqual(page1_ids & page2_ids, set())
def test_a_pagination_link_preserves_the_search_query_string(self):
self.make_members(5)
with mock.patch("management.views.MemberListView.paginate_by", 2):
response = self.client.get(reverse("management:member_list") + "?q=Match", HTTP_HOST="ajax-united.rosterchief.app")
self.assertContains(response, "q=Match&amp;page=2")
def test_family_list_pagination_is_wired_and_shows_a_page_count(self):
for i in range(3):
family = Family.objects.create()
member = Member.objects.create(first_name=f"Fam{i}", last_name="Ily")
FamilyMembership.objects.create(family=family, member=member, role=FamilyMembership.FamilyRole.CHILD)
ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
with mock.patch("management.views.FamilyListView.paginate_by", 2):
response = self.club_get("family_list")
self.assertTrue(response.context["is_paginated"])
self.assertContains(response, "Page 1 of 2")
def test_event_team_group_news_lists_are_all_wired_for_pagination(self):
# A lighter "is it wired" check for the remaining four -- MemberListView
# and FamilyListView above already cover the actual pager mechanics
# (page split + query-string preservation), which is shared markup
# (_pagination.html) and shared ListView machinery (paginate_by), not
# something that differs meaningfully per view.
for name in ["event_list", "team_list", "group_list", "news_list"]:
response = self.club_get(name)
self.assertIsNotNone(response.context["paginator"], name)

View File

@@ -24,6 +24,7 @@ urlpatterns = [
path("members/<uuid:pk>/grant-login/", views.MemberGrantLoginView.as_view(), name="member_grant_login"),
path("members/<uuid:pk>/referee-eligibility/", views.MemberRefereeEligibilityUpdateView.as_view(), name="member_referee_eligibility_update"),
path("members/<uuid:pk>/detach-family/<uuid:family_pk>/", views.MemberDetachFromFamilyView.as_view(), name="member_detach_family"),
path("families/", views.FamilyListView.as_view(), name="family_list"),
path("families/new/", views.FamilyCreateView.as_view(), name="family_create"),
path("families/<uuid:pk>/", views.FamilyDetailView.as_view(), name="family_detail"),
path("families/<uuid:pk>/add-child/", views.FamilyAddChildView.as_view(), name="family_add_child"),

View File

@@ -25,7 +25,7 @@ from club.mixins import (
TeamManagerRequiredMixin,
)
from club.models import ClubMembership, ClubRole, Season, Sponsor
from club.services.access import can_edit_news, can_publish_news, current_season, groups_manageable_by, is_club_admin, members_visible_to, teams_managed_by, teams_staffed_by
from club.services.access import _guardians_only, can_edit_news, can_publish_news, current_season, groups_manageable_by, is_club_admin, members_visible_to, teams_managed_by, teams_staffed_by
from club.services.fees import mark_as_paid, record_payment, remaining_balance
from controlpanel.messages import notify
from controlpanel.mixins import RedirectOnInvalidMixin
@@ -183,16 +183,36 @@ class MemberListView(ClubStaffRequiredMixin, ListView):
template_name = "management/member_list.html"
context_object_name = "members"
paginate_by = 25
def get_queryset(self):
members = members_visible_to(self.request.user, self.request.club)
# A guardian -- a parent linked to the club only through a child, no fee,
# not counted anywhere (see ClubMembership.Kind) -- simply doesn't show up
# here by default, with nothing on the page explaining why they seem to
# have vanished right after being added via "Add family"/"Add parent".
# ?kind= turns that silent exclusion into an explicit, visible choice:
# "member" (default) matches the page's original behaviour exactly,
# "guardian" flips it to show only the excluded guardians, "both" shows
# everyone. _guardians_only is intersected with members_visible_to (not
# queried club-wide on its own) so a non-admin still only ever sees
# guardians of someone already visible to them.
kind = self.request.GET.get("kind", "member")
if kind == "guardian":
members = members_visible_to(self.request.user, self.request.club, include_guardians=True).filter(pk__in=_guardians_only(self.request.club))
elif kind == "both":
members = members_visible_to(self.request.user, self.request.club, include_guardians=True)
else:
kind = "member"
members = members_visible_to(self.request.user, self.request.club)
self.selected_kind = kind
search = self.request.GET.get("q", "").strip()
if search:
members = members.filter(first_name__icontains=search) | members.filter(last_name__icontains=search) | members.filter(email__icontains=search) | members.filter(user__email__icontains=search)
return members.distinct()
def get_context_data(self, **kwargs):
context = super().get_context_data(search=self.request.GET.get("q", ""), **kwargs)
context = super().get_context_data(search=self.request.GET.get("q", ""), selected_kind=self.selected_kind, **kwargs)
members = list(context["members"])
memberships = FamilyMembership.objects.filter(member__in=members).select_related("family")
@@ -794,6 +814,7 @@ class TeamListView(ClubStaffRequiredMixin, ListView):
template_name = "management/team_list.html"
context_object_name = "teams"
paginate_by = 25
def get_queryset(self):
club = self.request.club
@@ -803,10 +824,14 @@ class TeamListView(ClubStaffRequiredMixin, ListView):
teams = teams.filter(name__icontains=search)
season = current_season(club)
# Explicit, not just Team.Meta's default -- an annotate() that aggregates
# (the two Counts below) forces a GROUP BY, and Django doesn't apply a
# model's default ordering to a grouped query (QuerySet.ordered), which
# would otherwise make pagination's page split nondeterministic.
return teams.annotate(
player_count=Count("roster", filter=Q(roster__season=season), distinct=True),
staff_count=Count("staff_assignments", filter=Q(staff_assignments__season=season), distinct=True),
)
).order_by("name")
def get_context_data(self, **kwargs):
return super().get_context_data(search=self.request.GET.get("q", ""), **kwargs)
@@ -1335,6 +1360,39 @@ def families_of_club(club):
return Family.objects.filter(memberships__member__member_of__club=club).distinct()
class FamilyListView(ClubStaffRequiredMixin, ListView):
"""One row per family -- parents/guardians in one column, children in
another -- for browsing what's on file rather than reaching a family only
by clicking through a member. Same visibility rule as FamilyDetailView's
own guardians/children (group_by_family over members_visible_to), so a
non-admin never sees a family, or a family-mate within one, they couldn't
already reach some other way; the family list itself is narrowed to only
families with at least one such visible member, rather than showing empty
rows for the rest."""
template_name = "management/family_list.html"
context_object_name = "families"
paginate_by = 25
def get_queryset(self):
visible = members_visible_to(self.request.user, self.request.club, include_guardians=True)
return families_of_club(self.request.club).filter(memberships__member__in=visible).distinct()
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
families = list(context["families"])
visible = members_visible_to(self.request.user, self.request.club, include_guardians=True)
groups, _ungrouped = group_by_family(visible.filter(family_memberships__family__in=families))
groups_by_family = {group["family"]: group for group in groups}
for family in families:
group = groups_by_family.get(family, {"guardians": [], "children": []})
family.guardians_display = group["guardians"]
family.children_display = group["children"]
return context | {"families": families}
class FamilyCreateView(ClubAdminRequiredMixin, FormView):
"""One new family in one go: a parent (who gets a login) and a child (who
doesn't) -- see members.services.family.register_family."""
@@ -1636,9 +1694,13 @@ class RefereeListView(ClubStaffRequiredMixin, ListView):
class GroupListView(ClubAdminRequiredMixin, ListView):
template_name = "management/group_list.html"
context_object_name = "groups"
paginate_by = 25
def get_queryset(self):
return Group.objects.filter(club=self.request.club).annotate(member_count=Count("memberships", distinct=True))
# order_by explicit for the same reason as TeamListView: the Count()
# annotation forces a GROUP BY, which Django doesn't apply the model's
# default ordering to -- pagination needs a real order to split on.
return Group.objects.filter(club=self.request.club).annotate(member_count=Count("memberships", distinct=True)).order_by("name")
class GroupCreateView(ClubAdminRequiredMixin, CreateView):
@@ -1764,6 +1826,7 @@ class GroupMemberRemoveView(ClubAdminRequiredMixin, View):
class NewsListView(ClubStaffRequiredMixin, ListView):
template_name = "management/news_list.html"
context_object_name = "news_items"
paginate_by = 25
def get_queryset(self):
return News.objects.filter(club=self.request.club).prefetch_related("teams")
@@ -1971,6 +2034,7 @@ class EventListView(ClubStaffRequiredMixin, ListView):
template_name = "management/event_list.html"
context_object_name = "events"
paginate_by = 25
def get_queryset(self):
club = self.request.club
@@ -2212,6 +2276,39 @@ class EventRefereeFormPdfView(ClubAdminRequiredMixin, View):
return response
def upcoming_games_needing_referee_management(club):
"""Upcoming home games a club-arranged referee is needed for -- federation-
managed teams never appear here, see events.services.referees.needs_referee_management.
The base query behind RefereeManagementDashboardView's own list, factored out
so the nav's Referee management badge (games_missing_referees_count below,
used by management.context_processors.sidebar_counters) counts from exactly
the same set of games rather than a second, potentially-drifting definition
of "needs a referee"."""
return (
Event.objects.filter(
club=club,
kind=Event.EventKind.GAME,
cancelled=False,
location__is_home=True,
start__gte=timezone.now(),
teams__referee_management=Team.RefereeManagement.CLUB,
)
.distinct()
.order_by("start")
)
def games_missing_referees_count(club, limit=10):
"""How many of the next `limit` upcoming club-managed home games have nobody
assigned yet -- the same games RefereeManagementDashboardView's own
kpi_no_referee counts for its default "next 10" range, but via one annotated
query rather than the per-game referee_rows/eligible_referees loop the
dashboard builds for rendering (which a nav badge has no use for)."""
games = upcoming_games_needing_referee_management(club).annotate(referee_count=Count("referees", distinct=True))[:limit]
return sum(1 for game in games if game.referee_count == 0)
class RefereeManagementDashboardView(ClubAdminRequiredMixin, TemplateView):
"""One-stop admin view of every upcoming home game that needs a
club-arranged referee (federation-managed teams never appear here, see
@@ -2238,13 +2335,7 @@ class RefereeManagementDashboardView(ClubAdminRequiredMixin, TemplateView):
club = self.request.club
range_choice = self.get_range()
queryset = (
Event.objects.filter(club=club, kind=Event.EventKind.GAME, cancelled=False, location__is_home=True, start__gte=timezone.now(), teams__referee_management=Team.RefereeManagement.CLUB)
.distinct()
.select_related("location", "opponent")
.prefetch_related("teams", "referees__member", "referees__assigned_by")
.order_by("start")
)
queryset = upcoming_games_needing_referee_management(club).select_related("location", "opponent").prefetch_related("teams", "referees__member", "referees__assigned_by")
if range_choice in ("week", "two_weeks"):
today = timezone.localdate()

View File

@@ -200,6 +200,7 @@ TEMPLATES = [
"management.context_processors.active_nav_section",
"management.context_processors.news_permissions",
"management.context_processors.feature_sections",
"management.context_processors.sidebar_counters",
],
},
},