Let referee levels inherit from a lower level instead of relying on ordering alone
RefereeLevel.inherits_from chains levels together so a higher tier is automatically eligible for everything a linked lower tier covers, transitively, without hand-duplicating teams onto every level. Kept the ordering field (still drives display order) but eligibility everywhere (RefereeProfile.eligible_teams, events.services.referees, the team detail page's eligible-referees list) now reads through RefereeLevel.eligible_team_ids, which walks the inherits_from chain. clean() rejects a loop, including an indirect one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
@@ -56,12 +56,17 @@ class GroupForm(forms.ModelForm):
|
||||
class RefereeLevelForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = RefereeLevel
|
||||
fields = ["name", "ordering", "teams"]
|
||||
fields = ["name", "ordering", "teams", "inherits_from"]
|
||||
widgets = {"teams": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a team to search...")})}
|
||||
|
||||
def __init__(self, *args, club=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["teams"].queryset = Team.objects.filter(club=club)
|
||||
# Excludes self from the dropdown -- self.instance always has a pk (UUIDModel
|
||||
# gets its default at construction, not at save()), so this also works for a
|
||||
# brand-new, unsaved level. clean() still catches an indirect loop (A -> B -> A).
|
||||
self.fields["inherits_from"].queryset = RefereeLevel.objects.filter(club=club).exclude(pk=self.instance.pk)
|
||||
self.fields["inherits_from"].empty_label = _("— none —")
|
||||
|
||||
|
||||
class MemberRefereeEligibilityForm(forms.ModelForm):
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<tr>
|
||||
<th>{% trans "Ordering" %}</th>
|
||||
<th>{% trans "Name" %}</th>
|
||||
<th>{% trans "Inherits from" %}</th>
|
||||
<th>{% trans "Qualifies for" %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
@@ -26,12 +27,22 @@
|
||||
<tr>
|
||||
<td class="font-mono text-muted">{{ level.ordering }}</td>
|
||||
<td class="font-semibold text-ink">{{ level.name }}</td>
|
||||
<td>
|
||||
{% if level.inherits_from %}
|
||||
<span class="badge badge-info badge-sm">{{ level.inherits_from.name }}</span>
|
||||
{% else %}
|
||||
<span class="text-dim">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% for team in level.teams.all %}
|
||||
<span class="badge badge-outline badge-sm">{{ team.name }}</span>
|
||||
{% empty %}
|
||||
<span class="text-dim">—</span>
|
||||
{% if not level.inherits_from %}<span class="text-dim">—</span>{% endif %}
|
||||
{% endfor %}
|
||||
{% if level.inherits_from %}
|
||||
<div class="mt-1 text-xs text-muted">{% blocktrans with name=level.inherits_from.name %}+ everything “{{ name }}” covers{% endblocktrans %}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{% if is_club_admin %}
|
||||
@@ -41,7 +52,7 @@
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted">{% trans "No referee levels yet." %}</td>
|
||||
<td colspan="5" class="text-center text-muted">{% trans "No referee levels yet." %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@@ -1318,6 +1318,46 @@ class RefereeLevelManagementTests(ManagementTestBase):
|
||||
|
||||
self.assertNotContains(response, "Rival Level")
|
||||
|
||||
def test_admin_can_link_a_level_to_inherit_from_another(self):
|
||||
regional = RefereeLevel.objects.create(club=self.club, name="Regional")
|
||||
regional.teams.add(self.team)
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
response = self.club_post("referee_level_create", {"name": "National", "ordering": 0, "teams": [str(self.other_team.pk)], "inherits_from": str(regional.pk)})
|
||||
|
||||
national = RefereeLevel.objects.get(club=self.club, name="National")
|
||||
self.assertRedirects(response, reverse("management:referee_level_list"))
|
||||
self.assertEqual(national.inherits_from, regional)
|
||||
self.assertEqual(national.eligible_team_ids(), {self.team.pk, self.other_team.pk})
|
||||
|
||||
def test_a_level_cannot_be_set_to_inherit_from_itself(self):
|
||||
level = RefereeLevel.objects.create(club=self.club, name="Regional")
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
self.club_post("referee_level_update", {"name": "Regional", "ordering": 0, "teams": [], "inherits_from": str(level.pk)}, level.pk)
|
||||
|
||||
level.refresh_from_db()
|
||||
self.assertIsNone(level.inherits_from)
|
||||
|
||||
def test_the_inherits_from_dropdown_only_offers_this_clubs_levels(self):
|
||||
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
|
||||
RefereeLevel.objects.create(club=other_club, name="Rival Level")
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
response = self.club_get("referee_level_create")
|
||||
|
||||
self.assertNotContains(response, "Rival Level")
|
||||
|
||||
def test_the_list_shows_what_a_level_inherits(self):
|
||||
regional = RefereeLevel.objects.create(club=self.club, name="Regional")
|
||||
RefereeLevel.objects.create(club=self.club, name="National", inherits_from=regional)
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
response = self.club_get("referee_level_list")
|
||||
|
||||
self.assertContains(response, "Regional")
|
||||
self.assertContains(response, "everything")
|
||||
|
||||
|
||||
class RefereeListViewTests(ManagementTestBase):
|
||||
"""The club-wide referee overview -- see management.views.RefereeListView."""
|
||||
|
||||
@@ -1133,7 +1133,16 @@ class TeamDetailView(ClubStaffRequiredMixin, DetailView):
|
||||
no_shows=no_shows,
|
||||
# None (not an empty queryset) signals "federation-managed" to the
|
||||
# template, distinct from "club-managed, nobody eligible yet".
|
||||
eligible_referees=(Member.objects.filter(referee_profile__level__teams=team, referee_profile__valid_until__gte=timezone.localdate()).order_by("last_name", "first_name") if team.referee_management == Team.RefereeManagement.CLUB else None),
|
||||
# Levels resolved to ids first, not a flat level__teams=team filter --
|
||||
# same reasoning as events.services.referees.eligible_referees, since
|
||||
# a level may qualify for this team only via what it inherits from.
|
||||
eligible_referees=(
|
||||
Member.objects.filter(
|
||||
referee_profile__level_id__in=[level.pk for level in RefereeLevel.objects.filter(club=club) if team.pk in level.eligible_team_ids()], referee_profile__valid_until__gte=timezone.localdate()
|
||||
).order_by("last_name", "first_name")
|
||||
if team.referee_management == Team.RefereeManagement.CLUB
|
||||
else None
|
||||
),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -1835,7 +1844,7 @@ class RefereeLevelListView(ClubStaffRequiredMixin, ListView):
|
||||
context_object_name = "levels"
|
||||
|
||||
def get_queryset(self):
|
||||
return RefereeLevel.objects.filter(club=self.request.club).prefetch_related("teams")
|
||||
return RefereeLevel.objects.filter(club=self.request.club).select_related("inherits_from").prefetch_related("teams")
|
||||
|
||||
|
||||
class RefereeLevelCreateView(MemberAdminRequiredMixin, CreateView):
|
||||
@@ -1890,8 +1899,11 @@ class RefereeListView(ClubStaffRequiredMixin, ListView):
|
||||
context_object_name = "referees"
|
||||
|
||||
def get_queryset(self):
|
||||
# No prefetch for eligible_teams below select_related's level: it walks the
|
||||
# level's own inherits_from chain (RefereeLevel.eligible_team_ids), which a
|
||||
# single prefetch_related path can't cover anyway.
|
||||
members = members_visible_to(self.request.user, self.request.club, include_guardians=True).filter(referee_profile__isnull=False)
|
||||
return members.select_related("referee_profile", "referee_profile__level").prefetch_related("referee_profile__level__teams").order_by("last_name", "first_name")
|
||||
return members.select_related("referee_profile", "referee_profile__level").order_by("last_name", "first_name")
|
||||
|
||||
|
||||
# --- Groups: a generic named collection of members (all coaches, all team managers,
|
||||
|
||||
Reference in New Issue
Block a user