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:
@@ -30,7 +30,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from events.models import ASSUMED_EVENT_DURATION, Event, EventReferee
|
from events.models import ASSUMED_EVENT_DURATION, Event, EventReferee
|
||||||
from events.services.attendance import effective_members
|
from events.services.attendance import effective_members
|
||||||
from members.models import Member
|
from members.models import Member
|
||||||
from teams.models import Team
|
from teams.models import RefereeLevel, Team
|
||||||
|
|
||||||
|
|
||||||
class RefereeAssignmentError(Exception):
|
class RefereeAssignmentError(Exception):
|
||||||
@@ -52,17 +52,24 @@ def needs_referee_management(event) -> bool:
|
|||||||
|
|
||||||
def eligible_referees(event):
|
def eligible_referees(event):
|
||||||
"""Members who could referee `event`: their RefereeProfile has a level
|
"""Members who could referee `event`: their RefereeProfile has a level
|
||||||
qualifying for one of its club-managed teams, and is currently valid,
|
qualifying for one of its club-managed teams (directly, or via whatever
|
||||||
minus whoever is already assigned. Empty unless needs_referee_management."""
|
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):
|
if not needs_referee_management(event):
|
||||||
return Member.objects.none()
|
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)
|
assigned_ids = event.referees.values_list("member_id", flat=True)
|
||||||
today = timezone.localdate()
|
today = timezone.localdate()
|
||||||
|
|
||||||
return (
|
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)
|
.exclude(pk__in=assigned_ids)
|
||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1205,6 +1205,18 @@ class RefereeServiceTests(EventsTestBase):
|
|||||||
|
|
||||||
self.assertEqual(set(eligible_referees(game)), set())
|
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):
|
def test_eligible_referees_ignores_an_expired_profile(self):
|
||||||
expired_referee = Member.objects.create(first_name="Ex", last_name="Pired")
|
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))
|
RefereeProfile.objects.create(member=expired_referee, level=self.level, valid_until=timezone.localdate() - timedelta(days=1))
|
||||||
|
|||||||
@@ -56,12 +56,17 @@ class GroupForm(forms.ModelForm):
|
|||||||
class RefereeLevelForm(forms.ModelForm):
|
class RefereeLevelForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = RefereeLevel
|
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...")})}
|
widgets = {"teams": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a team to search...")})}
|
||||||
|
|
||||||
def __init__(self, *args, club=None, **kwargs):
|
def __init__(self, *args, club=None, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields["teams"].queryset = Team.objects.filter(club=club)
|
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):
|
class MemberRefereeEligibilityForm(forms.ModelForm):
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>{% trans "Ordering" %}</th>
|
<th>{% trans "Ordering" %}</th>
|
||||||
<th>{% trans "Name" %}</th>
|
<th>{% trans "Name" %}</th>
|
||||||
|
<th>{% trans "Inherits from" %}</th>
|
||||||
<th>{% trans "Qualifies for" %}</th>
|
<th>{% trans "Qualifies for" %}</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -26,12 +27,22 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="font-mono text-muted">{{ level.ordering }}</td>
|
<td class="font-mono text-muted">{{ level.ordering }}</td>
|
||||||
<td class="font-semibold text-ink">{{ level.name }}</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>
|
<td>
|
||||||
{% for team in level.teams.all %}
|
{% for team in level.teams.all %}
|
||||||
<span class="badge badge-outline badge-sm">{{ team.name }}</span>
|
<span class="badge badge-outline badge-sm">{{ team.name }}</span>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<span class="text-dim">—</span>
|
{% if not level.inherits_from %}<span class="text-dim">—</span>{% endif %}
|
||||||
{% endfor %}
|
{% 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>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
{% if is_club_admin %}
|
{% if is_club_admin %}
|
||||||
@@ -41,7 +52,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1318,6 +1318,46 @@ class RefereeLevelManagementTests(ManagementTestBase):
|
|||||||
|
|
||||||
self.assertNotContains(response, "Rival Level")
|
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):
|
class RefereeListViewTests(ManagementTestBase):
|
||||||
"""The club-wide referee overview -- see management.views.RefereeListView."""
|
"""The club-wide referee overview -- see management.views.RefereeListView."""
|
||||||
|
|||||||
@@ -1133,7 +1133,16 @@ class TeamDetailView(ClubStaffRequiredMixin, DetailView):
|
|||||||
no_shows=no_shows,
|
no_shows=no_shows,
|
||||||
# None (not an empty queryset) signals "federation-managed" to the
|
# None (not an empty queryset) signals "federation-managed" to the
|
||||||
# template, distinct from "club-managed, nobody eligible yet".
|
# 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,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1835,7 +1844,7 @@ class RefereeLevelListView(ClubStaffRequiredMixin, ListView):
|
|||||||
context_object_name = "levels"
|
context_object_name = "levels"
|
||||||
|
|
||||||
def get_queryset(self):
|
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):
|
class RefereeLevelCreateView(MemberAdminRequiredMixin, CreateView):
|
||||||
@@ -1890,8 +1899,11 @@ class RefereeListView(ClubStaffRequiredMixin, ListView):
|
|||||||
context_object_name = "referees"
|
context_object_name = "referees"
|
||||||
|
|
||||||
def get_queryset(self):
|
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)
|
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,
|
# --- Groups: a generic named collection of members (all coaches, all team managers,
|
||||||
|
|||||||
@@ -72,10 +72,10 @@ class StaffAssignmentAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(RefereeLevel)
|
@admin.register(RefereeLevel)
|
||||||
class RefereeLevelAdmin(admin.ModelAdmin):
|
class RefereeLevelAdmin(admin.ModelAdmin):
|
||||||
list_display = ["name", "club", "ordering", "team_list"]
|
list_display = ["name", "club", "ordering", "inherits_from", "team_list"]
|
||||||
list_filter = ["club"]
|
list_filter = ["club"]
|
||||||
search_fields = ["name"]
|
search_fields = ["name"]
|
||||||
autocomplete_fields = ["teams"]
|
autocomplete_fields = ["teams", "inherits_from"]
|
||||||
ordering = ["club", "ordering", "name"]
|
ordering = ["club", "ordering", "name"]
|
||||||
|
|
||||||
@admin.display(description=_("teams"))
|
@admin.display(description=_("teams"))
|
||||||
|
|||||||
19
teams/migrations/0011_refereelevel_inherits_from.py
Normal file
19
teams/migrations/0011_refereelevel_inherits_from.py
Normal file
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils import timezone
|
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
|
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
|
the individual referee -- a club typically has a handful of levels, each
|
||||||
unlocking a tier of teams/competitions, rather than hand-picking teams per
|
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)
|
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."))
|
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."))
|
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:
|
class Meta:
|
||||||
verbose_name = _("referee level")
|
verbose_name = _("referee level")
|
||||||
@@ -142,6 +159,33 @@ class RefereeLevel(ClubScopedModel):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
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):
|
class RefereeProfile(UUIDModel):
|
||||||
"""Marks a member as a club referee: their level (which determines which
|
"""Marks a member as a club referee: their level (which determines which
|
||||||
@@ -184,10 +228,12 @@ class RefereeProfile(UUIDModel):
|
|||||||
@property
|
@property
|
||||||
def eligible_teams(self):
|
def eligible_teams(self):
|
||||||
"""Teams this profile currently qualifies for -- empty whenever it
|
"""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:
|
if not self.is_eligible:
|
||||||
return Team.objects.none()
|
return Team.objects.none()
|
||||||
return self.level.teams.all()
|
return Team.objects.filter(id__in=self.level.eligible_team_ids())
|
||||||
|
|
||||||
|
|
||||||
class StaffAssignment(UUIDModel):
|
class StaffAssignment(UUIDModel):
|
||||||
|
|||||||
@@ -246,6 +246,65 @@ class RefereeLevelModelTests(TeamsTestCase):
|
|||||||
|
|
||||||
self.assertEqual(list(self.team.referee_levels.all()), [level])
|
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):
|
class RefereeProfileModelTests(TeamsTestCase):
|
||||||
@classmethod
|
@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))
|
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])
|
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):
|
def test_deleting_a_referenced_level_is_protected(self):
|
||||||
RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=timezone.localdate())
|
RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=timezone.localdate())
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user