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:
@@ -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)),
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
32
management/templates/management/_pagination.html
Normal file
32
management/templates/management/_pagination.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
61
management/templates/management/family_list.html
Normal file
61
management/templates/management/family_list.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user