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 @@
  • {% lucide "users" size=16 %} {% trans "Members" %}
  • +
  • {% lucide "home" size=16 %} {% trans "Families" %}
  • {% if is_club_admin %} -
  • {% lucide "inbox" size=16 %} {% trans "Parent claims" %}
  • +
  • + + {% lucide "inbox" size=16 %} {% trans "Parent claims" %} + {{ pending_parent_claims_count }} + +
  • {% endif %} {% if is_club_admin %}
  • {% lucide "wallet" size=16 %} {% trans "Memberships" %}
  • @@ -30,7 +36,12 @@
  • {% lucide "tags" size=16 %} {% trans "Positions" %}
  • {% if is_club_admin %}
  • {% lucide "badge-check" size=16 %} {% trans "Referee levels" %}
  • -
  • {% lucide "calendar-check" size=16 %} {% trans "Referee management" %}
  • +
  • + + {% lucide "calendar-check" size=16 %} {% trans "Referee management" %} + {{ games_missing_referees_count }} + +
  • {% endif %}
  • {% lucide "flag" size=16 %} {% trans "Referees" %}
  • 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 %} + +
    + {% if page_obj.has_previous %} + {% lucide "chevron-left" size=14 %} {% trans "Previous" %} + {% else %} + {% lucide "chevron-left" size=14 %} {% trans "Previous" %} + {% endif %} + {% if page_obj.has_next %} + {% trans "Next" %} {% lucide "chevron-right" size=14 %} + {% else %} + {% trans "Next" %} {% lucide "chevron-right" size=14 %} + {% endif %} +
    +
    +{% 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 %} +
    +
    +
    + + + + + + + + + + + {% for family in families %} + + + + + + + {% empty %} + + + + {% endfor %} + +
    {% trans "Family" %}{% trans "Parents / guardians" %}{% trans "Children" %}
    {{ 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" %} +
    {% trans "No families yet." %}
    +
    +
    +
    + + {% 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 %} -
    + + {% if selected_kind != "member" %}{% endif %} {% if search %} {% lucide "x" size=16 %} {% trans "Clear filter" %} {% endif %}
    + {% 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 "Members" %} + {% trans "Guardians" %} + {% trans "Both" %} +
    + {% trans "Guardians are parents linked only through a child -- no fee, not counted as a member." %} +
    +
    @@ -90,6 +108,8 @@
    + {% 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 b883c3b..cc4d11a 100644 --- a/management/tests.py +++ b/management/tests.py @@ -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&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 b104ba1..81317d5 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.""" @@ -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() 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", ], }, },