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:
2026-08-20 22:42:18 +02:00
parent 5889b3740e
commit 66d7de820f
10 changed files with 235 additions and 16 deletions

View File

@@ -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):

View File

@@ -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>

View File

@@ -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."""

View File

@@ -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,