diff --git a/events/services/referees.py b/events/services/referees.py
index a371d25..659ffd7 100644
--- a/events/services/referees.py
+++ b/events/services/referees.py
@@ -30,7 +30,7 @@ from django.utils.translation import gettext_lazy as _
from events.models import ASSUMED_EVENT_DURATION, Event, EventReferee
from events.services.attendance import effective_members
from members.models import Member
-from teams.models import Team
+from teams.models import RefereeLevel, Team
class RefereeAssignmentError(Exception):
@@ -52,17 +52,24 @@ def needs_referee_management(event) -> bool:
def eligible_referees(event):
"""Members who could referee `event`: their RefereeProfile has a level
- qualifying for one of its club-managed teams, and is currently valid,
- minus whoever is already assigned. Empty unless needs_referee_management."""
+ qualifying for one of its club-managed teams (directly, or via whatever
+ that level inherits from), and is currently valid, minus whoever is
+ already assigned. Empty unless needs_referee_management.
+
+ Levels resolved to ids first (RefereeLevel.eligible_team_ids can't be
+ expressed as a single ORM lookup once inheritance is transitive) rather
+ than the old flat `level__teams__id__in` filter -- a club has a handful
+ of levels, so this stays cheap."""
if not needs_referee_management(event):
return Member.objects.none()
- team_ids = list(event.teams.filter(referee_management=Team.RefereeManagement.CLUB).values_list("id", flat=True))
+ team_ids = set(event.teams.filter(referee_management=Team.RefereeManagement.CLUB).values_list("id", flat=True))
+ qualifying_level_ids = [level.pk for level in RefereeLevel.objects.filter(club=event.club) if level.eligible_team_ids() & team_ids]
assigned_ids = event.referees.values_list("member_id", flat=True)
today = timezone.localdate()
return (
- Member.objects.filter(referee_profile__level__teams__id__in=team_ids, referee_profile__valid_until__gte=today)
+ Member.objects.filter(referee_profile__level_id__in=qualifying_level_ids, referee_profile__valid_until__gte=today)
.exclude(pk__in=assigned_ids)
.distinct()
)
diff --git a/events/tests.py b/events/tests.py
index 22cbbcb..6194332 100644
--- a/events/tests.py
+++ b/events/tests.py
@@ -1205,6 +1205,18 @@ class RefereeServiceTests(EventsTestBase):
self.assertEqual(set(eligible_referees(game)), set())
+ def test_eligible_referees_includes_a_higher_level_via_inheritance(self):
+ # self.level ("Regional") qualifies for self.team; a referee holding a
+ # higher level that inherits from Regional (without self.team added
+ # directly) must still show up -- see RefereeLevel.eligible_team_ids.
+ national = RefereeLevel.objects.create(club=self.club, name="National", inherits_from=self.level)
+ national_referee = Member.objects.create(first_name="Nat", last_name="Ional")
+ self.make_eligible_profile(national_referee, level=national)
+
+ game = self.make_home_game()
+
+ self.assertIn(national_referee, set(eligible_referees(game)))
+
def test_eligible_referees_ignores_an_expired_profile(self):
expired_referee = Member.objects.create(first_name="Ex", last_name="Pired")
RefereeProfile.objects.create(member=expired_referee, level=self.level, valid_until=timezone.localdate() - timedelta(days=1))
diff --git a/management/forms.py b/management/forms.py
index 62930e8..69125cf 100644
--- a/management/forms.py
+++ b/management/forms.py
@@ -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):
diff --git a/management/templates/management/referee_level_list.html b/management/templates/management/referee_level_list.html
index 25222fe..be62dc3 100644
--- a/management/templates/management/referee_level_list.html
+++ b/management/templates/management/referee_level_list.html
@@ -17,6 +17,7 @@
| {% trans "Ordering" %} |
{% trans "Name" %} |
+ {% trans "Inherits from" %} |
{% trans "Qualifies for" %} |
|
@@ -26,12 +27,22 @@
| {{ level.ordering }} |
{{ level.name }} |
+
+ {% if level.inherits_from %}
+ {{ level.inherits_from.name }}
+ {% else %}
+ —
+ {% endif %}
+ |
{% for team in level.teams.all %}
{{ team.name }}
{% empty %}
- —
+ {% if not level.inherits_from %}—{% endif %}
{% endfor %}
+ {% if level.inherits_from %}
+ {% blocktrans with name=level.inherits_from.name %}+ everything “{{ name }}” covers{% endblocktrans %}
+ {% endif %}
|
{% if is_club_admin %}
@@ -41,7 +52,7 @@
|
{% empty %}
- | {% trans "No referee levels yet." %} |
+ {% trans "No referee levels yet." %} |
{% endfor %}
diff --git a/management/tests.py b/management/tests.py
index 2d7be39..9e32661 100644
--- a/management/tests.py
+++ b/management/tests.py
@@ -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."""
diff --git a/management/views.py b/management/views.py
index 253417e..174169b 100644
--- a/management/views.py
+++ b/management/views.py
@@ -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,
diff --git a/teams/admin.py b/teams/admin.py
index aeb86a3..92e041d 100644
--- a/teams/admin.py
+++ b/teams/admin.py
@@ -72,10 +72,10 @@ class StaffAssignmentAdmin(admin.ModelAdmin):
@admin.register(RefereeLevel)
class RefereeLevelAdmin(admin.ModelAdmin):
- list_display = ["name", "club", "ordering", "team_list"]
+ list_display = ["name", "club", "ordering", "inherits_from", "team_list"]
list_filter = ["club"]
search_fields = ["name"]
- autocomplete_fields = ["teams"]
+ autocomplete_fields = ["teams", "inherits_from"]
ordering = ["club", "ordering", "name"]
@admin.display(description=_("teams"))
diff --git a/teams/migrations/0011_refereelevel_inherits_from.py b/teams/migrations/0011_refereelevel_inherits_from.py
new file mode 100644
index 0000000..3e018a7
--- /dev/null
+++ b/teams/migrations/0011_refereelevel_inherits_from.py
@@ -0,0 +1,19 @@
+# Generated by Django 6.0.6 on 2026-08-20 20:39
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('teams', '0010_alter_team_options_alter_teammembership_position'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='refereelevel',
+ name='inherits_from',
+ field=models.ForeignKey(blank=True, help_text='A referee holding this level is also eligible for everything the linked level covers (and, transitively, whatever that one inherits from).', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inherited_by', to='teams.refereelevel', verbose_name='inherits from'),
+ ),
+ ]
diff --git a/teams/models.py b/teams/models.py
index c465d14..4e35f15 100644
--- a/teams/models.py
+++ b/teams/models.py
@@ -1,3 +1,4 @@
+from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Q
from django.utils import timezone
@@ -125,11 +126,27 @@ class RefereeLevel(ClubScopedModel):
qualifies a referee for: eligibility is a property of the *level*, not of
the individual referee -- a club typically has a handful of levels, each
unlocking a tier of teams/competitions, rather than hand-picking teams per
- referee."""
+ referee.
+
+ `inherits_from` chains levels together so a higher tier doesn't need every
+ lower tier's team re-added by hand: a "National" referee is automatically
+ eligible for everything "Regional" (its inherits_from) covers, and so on
+ down the chain -- see eligible_team_ids, the single definition every
+ consumer (RefereeProfile.eligible_teams, events.services.referees) reads
+ through."""
name = models.CharField(_("name"), max_length=255)
ordering = models.PositiveSmallIntegerField(_("ordering"), default=0, help_text=_("Lower numbers are listed first. Levels with the same number are ordered by name."))
teams = models.ManyToManyField(Team, related_name="referee_levels", blank=True, verbose_name=_("qualifies for"), help_text=_("Members holding this level can be assigned to referee these teams' home games."))
+ inherits_from = models.ForeignKey(
+ "self",
+ on_delete=models.PROTECT,
+ null=True,
+ blank=True,
+ related_name="inherited_by",
+ verbose_name=_("inherits from"),
+ help_text=_("A referee holding this level is also eligible for everything the linked level covers (and, transitively, whatever that one inherits from)."),
+ )
class Meta:
verbose_name = _("referee level")
@@ -142,6 +159,33 @@ class RefereeLevel(ClubScopedModel):
def __str__(self):
return self.name
+ def clean(self):
+ validate_club_scope(self, self.club_id, same_club_fields=("inherits_from",))
+ current = self.inherits_from
+ seen = set()
+ while current is not None:
+ if current.pk == self.pk:
+ raise ValidationError({"inherits_from": _("This would create a loop -- a level can't inherit from itself, even indirectly.")})
+ if current.pk in seen:
+ break # An already-broken chain elsewhere; not this field's problem to fix.
+ seen.add(current.pk)
+ current = current.inherits_from
+
+ def eligible_team_ids(self):
+ """This level's own qualifying teams, plus (transitively) every level
+ it inherits from -- so a higher tier doesn't need a lower tier's teams
+ duplicated onto it by hand, and stays correct if the lower tier's own
+ teams change later. Cycle-guarded even though clean() already blocks
+ creating one, in case of data written outside that path."""
+ team_ids = set(self.teams.values_list("id", flat=True))
+ seen = {self.pk}
+ current = self.inherits_from
+ while current is not None and current.pk not in seen:
+ team_ids.update(current.teams.values_list("id", flat=True))
+ seen.add(current.pk)
+ current = current.inherits_from
+ return team_ids
+
class RefereeProfile(UUIDModel):
"""Marks a member as a club referee: their level (which determines which
@@ -184,10 +228,12 @@ class RefereeProfile(UUIDModel):
@property
def eligible_teams(self):
"""Teams this profile currently qualifies for -- empty whenever it
- isn't currently eligible, regardless of what level is set."""
+ isn't currently eligible, regardless of what level is set. Includes
+ whatever the level inherits from, transitively -- see
+ RefereeLevel.eligible_team_ids."""
if not self.is_eligible:
return Team.objects.none()
- return self.level.teams.all()
+ return Team.objects.filter(id__in=self.level.eligible_team_ids())
class StaffAssignment(UUIDModel):
diff --git a/teams/tests.py b/teams/tests.py
index 8c1f186..e085f34 100644
--- a/teams/tests.py
+++ b/teams/tests.py
@@ -246,6 +246,65 @@ class RefereeLevelModelTests(TeamsTestCase):
self.assertEqual(list(self.team.referee_levels.all()), [level])
+ def test_eligible_team_ids_with_no_inheritance_is_just_its_own_teams(self):
+ level = RefereeLevel.objects.create(club=self.club, name="Regional")
+ level.teams.add(self.team)
+
+ self.assertEqual(level.eligible_team_ids(), {self.team.pk})
+
+ def test_a_higher_level_inherits_its_lower_levels_teams(self):
+ other_team = Team.objects.create(club=self.club, name="Second Team", short_name="2nd")
+ regional = RefereeLevel.objects.create(club=self.club, name="Regional")
+ regional.teams.add(self.team)
+ national = RefereeLevel.objects.create(club=self.club, name="National", inherits_from=regional)
+ national.teams.add(other_team)
+
+ self.assertEqual(national.eligible_team_ids(), {self.team.pk, other_team.pk})
+ # Inheritance is one-directional -- Regional doesn't gain National's teams.
+ self.assertEqual(regional.eligible_team_ids(), {self.team.pk})
+
+ def test_inheritance_is_transitive_through_a_chain(self):
+ other_team = Team.objects.create(club=self.club, name="Second Team", short_name="2nd")
+ third_team = Team.objects.create(club=self.club, name="Third Team", short_name="3rd")
+ local = RefereeLevel.objects.create(club=self.club, name="Local")
+ local.teams.add(self.team)
+ regional = RefereeLevel.objects.create(club=self.club, name="Regional", inherits_from=local)
+ regional.teams.add(other_team)
+ national = RefereeLevel.objects.create(club=self.club, name="National", inherits_from=regional)
+ national.teams.add(third_team)
+
+ self.assertEqual(national.eligible_team_ids(), {self.team.pk, other_team.pk, third_team.pk})
+
+ def test_a_level_cannot_inherit_from_itself(self):
+ level = RefereeLevel.objects.create(club=self.club, name="Regional")
+ level.inherits_from = level
+
+ with self.assertRaises(ValidationError):
+ level.clean()
+
+ def test_a_level_cannot_indirectly_inherit_from_itself(self):
+ regional = RefereeLevel.objects.create(club=self.club, name="Regional")
+ national = RefereeLevel.objects.create(club=self.club, name="National", inherits_from=regional)
+ regional.inherits_from = national
+
+ with self.assertRaises(ValidationError):
+ regional.clean()
+
+ def test_inherits_from_must_be_the_same_club(self):
+ other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
+ other_level = RefereeLevel.objects.create(club=other_club, name="Regional")
+ level = RefereeLevel.objects.create(club=self.club, name="National", inherits_from=other_level)
+
+ with self.assertRaises(ValidationError):
+ level.clean()
+
+ def test_deleting_an_inherited_from_level_is_protected(self):
+ regional = RefereeLevel.objects.create(club=self.club, name="Regional")
+ RefereeLevel.objects.create(club=self.club, name="National", inherits_from=regional)
+
+ with self.assertRaises(ProtectedError):
+ regional.delete()
+
class RefereeProfileModelTests(TeamsTestCase):
@classmethod
@@ -291,6 +350,14 @@ class RefereeProfileModelTests(TeamsTestCase):
profile = RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=timezone.localdate() + datetime.timedelta(days=1))
self.assertEqual(list(profile.eligible_teams), [self.team])
+ def test_eligible_teams_include_what_the_level_inherits(self):
+ other_team = Team.objects.create(club=self.club, name="Second Team", short_name="2nd")
+ national = RefereeLevel.objects.create(club=self.club, name="National", inherits_from=self.level)
+ national.teams.add(other_team)
+ profile = RefereeProfile.objects.create(member=self.member, level=national, valid_until=timezone.localdate() + datetime.timedelta(days=1))
+
+ self.assertEqual(set(profile.eligible_teams), {self.team, other_team})
+
def test_deleting_a_referenced_level_is_protected(self):
RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=timezone.localdate())