diff --git a/management/context_processors.py b/management/context_processors.py
index 2bb6a18..95adadf 100644
--- a/management/context_processors.py
+++ b/management/context_processors.py
@@ -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)),
+ }
diff --git a/management/templates/management/_nav_items.html b/management/templates/management/_nav_items.html
index d79f873..fe50c55 100644
--- a/management/templates/management/_nav_items.html
+++ b/management/templates/management/_nav_items.html
@@ -16,8 +16,14 @@
+
{% if is_club_admin %}
-
+
+
+
{% endif %}
{% if is_club_admin %}
@@ -30,7 +36,12 @@
{% if is_club_admin %}
-
+
+
+
{% endif %}
diff --git a/management/templates/management/_pagination.html b/management/templates/management/_pagination.html
new file mode 100644
index 0000000..4e579fe
--- /dev/null
+++ b/management/templates/management/_pagination.html
@@ -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 %}
+
+
+ {% blocktrans with number=page_obj.number total=paginator.num_pages %}Page {{ number }} of {{ total }}{% endblocktrans %}
+
+
+
+{% endif %}
diff --git a/management/templates/management/event_list.html b/management/templates/management/event_list.html
index 9a338f3..570d61d 100644
--- a/management/templates/management/event_list.html
+++ b/management/templates/management/event_list.html
@@ -80,6 +80,8 @@
+ {% include "management/_pagination.html" %}
+
{% comment %} Dialogs live outside the table: may only contain elements. {% endcomment %}
{% for event in events %}
{% if event.can_manage %}
diff --git a/management/templates/management/family_list.html b/management/templates/management/family_list.html
new file mode 100644
index 0000000..9e74301
--- /dev/null
+++ b/management/templates/management/family_list.html
@@ -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 %}
+ {% lucide "users" size=16 %} {% trans "Add family" %}
+ {% endif %}
+{% endblock actions %}
+
+{% block panel %}
+
+
+
+
+
+
+ | {% trans "Family" %} |
+ {% trans "Parents / guardians" %} |
+ {% trans "Children" %} |
+ |
+
+
+
+ {% for family in families %}
+
+ | {{ family }} |
+
+ {% for guardian in family.guardians_display %}
+ {{ guardian }}{% if not forloop.last %}, {% endif %}
+ {% empty %}
+ -
+ {% endfor %}
+ |
+
+ {% for child in family.children_display %}
+ {{ child }}{% if not forloop.last %}, {% endif %}
+ {% empty %}
+ -
+ {% endfor %}
+ |
+
+ {# 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. #}
+ {% lucide "pencil" size=14 %} {% trans "Edit" %}
+ |
+
+ {% empty %}
+
+ | {% trans "No families yet." %} |
+
+ {% endfor %}
+
+
+
+
+
+
+ {% include "management/_pagination.html" %}
+{% endblock panel %}
diff --git a/management/templates/management/group_list.html b/management/templates/management/group_list.html
index 8af820a..197e671 100644
--- a/management/templates/management/group_list.html
+++ b/management/templates/management/group_list.html
@@ -43,6 +43,8 @@
+ {% include "management/_pagination.html" %}
+
{% trans "Delete group" as delete_group_title %}
{% trans "Delete" as delete_label %}
{% for group in groups %}
diff --git a/management/templates/management/member_list.html b/management/templates/management/member_list.html
index cd1e9f4..4c1296f 100644
--- a/management/templates/management/member_list.html
+++ b/management/templates/management/member_list.html
@@ -13,17 +13,35 @@
{% endblock actions %}
{% block panel %}
-
+ {% 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 %}
+
+
+
{% trans "Guardians are parents linked only through a child -- no fee, not counted as a member." %}
+
+
+ {% include "management/_pagination.html" %}
+
{% if is_club_admin %}
{% trans "Delete member" as delete_member_title %}
{% trans "Delete" as delete_label %}
diff --git a/management/templates/management/news_list.html b/management/templates/management/news_list.html
index 75e8a81..72b68e6 100644
--- a/management/templates/management/news_list.html
+++ b/management/templates/management/news_list.html
@@ -64,6 +64,8 @@
+ {% include "management/_pagination.html" %}
+
{% trans "Delete news item" as delete_news_title %}
{% trans "Delete" as delete_label %}
{% for news_item in news_items %}
diff --git a/management/templates/management/team_list.html b/management/templates/management/team_list.html
index 703d49e..0ae94be 100644
--- a/management/templates/management/team_list.html
+++ b/management/templates/management/team_list.html
@@ -50,6 +50,8 @@
+ {% include "management/_pagination.html" %}
+
{% if is_club_admin %}
{% trans "Delete team" as delete_team_title %}
{% trans "Delete" as delete_label %}
diff --git a/management/tests.py b/management/tests.py
index 7ab22ba..23702a3 100644
--- a/management/tests.py
+++ b/management/tests.py
@@ -3848,7 +3848,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)
@@ -3859,7 +3862,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)
@@ -5743,3 +5746,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&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)
diff --git a/management/urls.py b/management/urls.py
index c841469..4646aa2 100644
--- a/management/urls.py
+++ b/management/urls.py
@@ -24,6 +24,7 @@ urlpatterns = [
path("members//grant-login/", views.MemberGrantLoginView.as_view(), name="member_grant_login"),
path("members//referee-eligibility/", views.MemberRefereeEligibilityUpdateView.as_view(), name="member_referee_eligibility_update"),
path("members//detach-family//", 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//", views.FamilyDetailView.as_view(), name="family_detail"),
path("families//add-child/", views.FamilyAddChildView.as_view(), name="family_add_child"),
diff --git a/management/views.py b/management/views.py
index 255726f..f05da2b 100644
--- a/management/views.py
+++ b/management/views.py
@@ -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."""
@@ -1644,9 +1702,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):
@@ -1772,6 +1834,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")
@@ -1979,6 +2042,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
@@ -2220,6 +2284,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
@@ -2246,13 +2343,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()
diff --git a/rosterchief/settings.py b/rosterchief/settings.py
index d01327a..43bd02d 100644
--- a/rosterchief/settings.py
+++ b/rosterchief/settings.py
@@ -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",
],
},
},