Add referee assignment/eligibility system, team bulk-add, member Groups, and referee management dashboard with PDF export

Builds the referee workflow end to end: club-defined RefereeLevel/RefereeProfile
eligibility tied to teams, EventReferee assignment (member or external, with
fee/km payment tracking), an admin dashboard with KPI tiles, date-grouped game
tiles and range filters, and a downloadable payment form PDF modeled on the
club's existing paper document (using Club.legal_name when set). Also lands
team roster bulk-add, member mass-upload with family linking, and the
members.Group model, developed alongside this work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 21:39:19 +02:00
parent 86e28c317f
commit 309bd4d83e
54 changed files with 4574 additions and 88 deletions

View File

@@ -1,7 +1,7 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from .models import Position, StaffAssignment, Team, TeamMembership, TeamPhoto
from .models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership, TeamPhoto
class TeamMembershipInline(admin.TabularInline):
@@ -29,8 +29,8 @@ class TeamPhotoInline(admin.TabularInline):
@admin.register(Team)
class TeamAdmin(admin.ModelAdmin):
list_display = ["name", "short_name", "club"]
list_filter = ["club"]
list_display = ["name", "short_name", "club", "referee_management"]
list_filter = ["club", "referee_management"]
search_fields = ["name", "short_name"]
inlines = [TeamMembershipInline, StaffAssignmentInline, TeamPhotoInline]
@@ -68,3 +68,28 @@ class StaffAssignmentAdmin(admin.ModelAdmin):
list_filter = ["team", "season", "position"]
search_fields = ["team__name", "member__first_name", "member__last_name"]
raw_id_fields = ["member"]
@admin.register(RefereeLevel)
class RefereeLevelAdmin(admin.ModelAdmin):
list_display = ["name", "club", "ordering", "team_list"]
list_filter = ["club"]
search_fields = ["name"]
autocomplete_fields = ["teams"]
ordering = ["club", "ordering", "name"]
@admin.display(description=_("teams"))
def team_list(self, obj):
return ", ".join(team.name for team in obj.teams.all())
@admin.register(RefereeProfile)
class RefereeProfileAdmin(admin.ModelAdmin):
list_display = ["member", "level", "valid_until", "is_eligible"]
list_filter = ["level"]
search_fields = ["member__first_name", "member__last_name"]
autocomplete_fields = ["member"]
@admin.display(description=_("eligible"), boolean=True)
def is_eligible(self, obj):
return obj.is_eligible

View File

@@ -0,0 +1,54 @@
# Generated by Django 6.0.6 on 2026-08-09 09:25
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0020_sponsor_logo_height_sponsor_logo_width'),
('members', '0004_group_groupmembership_and_more'),
('teams', '0007_teamphoto'),
]
operations = [
migrations.CreateModel(
name='RefereeLevel',
fields=[
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=255, verbose_name='name')),
('ordering', models.PositiveSmallIntegerField(default=0, help_text='Lower numbers are listed first. Levels with the same number are ordered by name.', verbose_name='ordering')),
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
('teams', models.ManyToManyField(blank=True, help_text="Members holding this level can be assigned to referee these teams' home games.", related_name='referee_levels', to='teams.team', verbose_name='qualifies for')),
],
options={
'verbose_name': 'referee level',
'verbose_name_plural': 'referee levels',
'ordering': ['ordering', 'name'],
},
),
migrations.CreateModel(
name='RefereeProfile',
fields=[
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('valid_until', models.DateField(blank=True, help_text="Once this date has passed, the referee is not eligible for assignment until it's extended.", null=True, verbose_name='valid until')),
('level', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='referees', to='teams.refereelevel', verbose_name='level')),
('member', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='referee_profile', to='members.member', verbose_name='member')),
],
options={
'verbose_name': 'referee profile',
'verbose_name_plural': 'referee profiles',
'ordering': ['member__last_name', 'member__first_name'],
},
),
migrations.AddConstraint(
model_name='refereelevel',
constraint=models.UniqueConstraint(fields=('club', 'name'), name='unique_referee_level_name_per_club'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.6 on 2026-08-09 10:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('teams', '0008_refereelevel_refereeprofile_and_more'),
]
operations = [
migrations.AddField(
model_name='team',
name='referee_management',
field=models.CharField(choices=[('club', 'Club'), ('federation', 'Federation')], default='club', help_text="Who arranges referees for this team's home games. Federation-managed teams are left out of the referee tools entirely -- no eligibility, no assignment, nothing to configure.", max_length=20, verbose_name='referee management'),
),
]

View File

@@ -1,5 +1,6 @@
from django.db import models
from django.db.models import Q
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from club.models import Season
@@ -8,8 +9,19 @@ from rosterchief.base import ClubScopedModel, UUIDModel, validate_club_scope
class Team(ClubScopedModel):
class RefereeManagement(models.TextChoices):
CLUB = "club", _("Club")
FEDERATION = "federation", _("Federation")
name = models.CharField(_("name"), max_length=255)
short_name = models.CharField(_("short name"), max_length=255)
referee_management = models.CharField(
_("referee management"),
max_length=20,
choices=RefereeManagement.choices,
default=RefereeManagement.CLUB,
help_text=_("Who arranges referees for this team's home games. Federation-managed teams are left out of the referee tools entirely -- no eligibility, no assignment, nothing to configure."),
)
class Meta:
verbose_name = _("team")
@@ -96,6 +108,78 @@ class TeamMembership(UUIDModel):
validate_club_scope(self, club_id, same_club_fields=("season", "position"))
class RefereeLevel(ClubScopedModel):
"""A club-defined referee qualification tier (e.g. "Regional", "National")
-- admin-managed, like Position, so a club can name and reorder its own
levels rather than picking from a fixed list. Owns which teams it
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."""
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."))
class Meta:
verbose_name = _("referee level")
verbose_name_plural = _("referee levels")
ordering = ["ordering", "name"]
constraints = [
models.UniqueConstraint(fields=["club", "name"], name="unique_referee_level_name_per_club"),
]
def __str__(self):
return self.name
class RefereeProfile(UUIDModel):
"""Marks a member as a club referee: their level (which determines which
teams they're eligible for, via RefereeLevel.teams) and how long that
qualification is valid. A member-level fact, not a group-level one --
managed from the member's own page, unrelated to members.Group (which
stays a plain, opaque collection of people with no referee-specific
knowledge).
No level, or an expired/unset validity, both mean "not currently
eligible" -- see `is_currently_valid`/`eligible_teams` below, the single
definitions every consumer (the event assign panel, the team page, the
referees list) reads through, so "eligible" never drifts out of sync.
"""
member = models.OneToOneField(Member, on_delete=models.CASCADE, related_name="referee_profile", verbose_name=_("member"))
level = models.ForeignKey(RefereeLevel, on_delete=models.PROTECT, null=True, blank=True, related_name="referees", verbose_name=_("level"))
valid_until = models.DateField(_("valid until"), null=True, blank=True, help_text=_("Once this date has passed, the referee is not eligible for assignment until it's extended."))
class Meta:
verbose_name = _("referee profile")
verbose_name_plural = _("referee profiles")
ordering = ["member__last_name", "member__first_name"]
def __str__(self):
return f"{self.member} (referee)"
@property
def is_currently_valid(self) -> bool:
"""Whether the validity date itself hasn't passed -- independent of
whether a level is even set. Use `is_eligible` for the full gate."""
return self.valid_until is not None and self.valid_until >= timezone.localdate()
@property
def is_eligible(self) -> bool:
"""The full gate consumed everywhere eligibility actually matters: a
level is set, and its validity hasn't passed."""
return self.level_id is not None and self.is_currently_valid
@property
def eligible_teams(self):
"""Teams this profile currently qualifies for -- empty whenever it
isn't currently eligible, regardless of what level is set."""
if not self.is_eligible:
return Team.objects.none()
return self.level.teams.all()
class StaffAssignment(UUIDModel):
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name="staff_assignments", verbose_name=_("team"))
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="staff_assignments", verbose_name=_("member"))

17
teams/services.py Normal file
View File

@@ -0,0 +1,17 @@
from django.utils import timezone
from club.models import ClubMembership, Season
from members.models import Member
def eligible_roster_members(club):
"""Members eligible to be added to a team's roster or staff: active (paid) for
the club this season or next, regardless of which season's roster/staff list is
currently being edited (the team page's season switcher can point at either)."""
today = timezone.localdate()
eligible_seasons = [season for season in (Season.covering(club, today), Season.next_after(club, today)) if season is not None]
return Member.objects.filter(
member_of__club=club,
member_of__season__in=eligible_seasons,
member_of__status=ClubMembership.StatusChoices.ACTIVE,
).distinct()

View File

@@ -4,11 +4,12 @@ from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.db.models import ProtectedError
from django.test import TestCase
from django.utils import timezone
from club.models import Club, Season
from members.models import Member
from .models import Position, StaffAssignment, Team, TeamMembership, TeamPhoto
from .models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership, TeamPhoto
class TeamsTestCase(TestCase):
@@ -39,6 +40,9 @@ class TeamModelTests(TeamsTestCase):
self.assertEqual(Team.objects.filter(name="First Team").count(), 2)
def test_referee_management_defaults_to_club(self):
self.assertEqual(self.team.referee_management, Team.RefereeManagement.CLUB)
class PositionModelTests(TeamsTestCase):
def test_str_returns_name(self):
@@ -188,3 +192,98 @@ class TeamPhotoModelTests(TeamsTestCase):
self.team.delete()
self.assertEqual(TeamPhoto.objects.count(), 0)
class RefereeLevelModelTests(TeamsTestCase):
def test_str(self):
level = RefereeLevel.objects.create(club=self.club, name="Regional")
self.assertEqual(str(level), "Regional")
def test_name_is_unique_per_club(self):
RefereeLevel.objects.create(club=self.club, name="Regional")
with self.assertRaises(IntegrityError):
RefereeLevel.objects.create(club=self.club, name="Regional")
def test_same_name_allowed_in_another_club(self):
other = Club.objects.create(name="Rival FC", slug="rival-fc")
RefereeLevel.objects.create(club=self.club, name="Regional")
RefereeLevel.objects.create(club=other, name="Regional")
self.assertEqual(RefereeLevel.objects.filter(name="Regional").count(), 2)
def test_teams_starts_empty(self):
level = RefereeLevel.objects.create(club=self.club, name="Regional")
self.assertEqual(list(level.teams.all()), [])
def test_can_qualify_for_multiple_teams(self):
other_team = Team.objects.create(club=self.club, name="Second Team", short_name="2nd")
level = RefereeLevel.objects.create(club=self.club, name="Regional")
level.teams.set([self.team, other_team])
self.assertEqual(set(level.teams.all()), {self.team, other_team})
def test_team_reverse_accessor(self):
level = RefereeLevel.objects.create(club=self.club, name="Regional")
level.teams.add(self.team)
self.assertEqual(list(self.team.referee_levels.all()), [level])
class RefereeProfileModelTests(TeamsTestCase):
def setUp(self):
super().setUp()
self.level = RefereeLevel.objects.create(club=self.club, name="Regional")
self.level.teams.add(self.team)
def test_str(self):
profile = RefereeProfile.objects.create(member=self.member)
self.assertEqual(str(profile), "Jane Doe (referee)")
def test_member_is_one_to_one(self):
RefereeProfile.objects.create(member=self.member)
with self.assertRaises(IntegrityError):
RefereeProfile.objects.create(member=self.member)
def test_no_level_is_never_eligible_even_with_a_future_validity(self):
profile = RefereeProfile.objects.create(member=self.member, valid_until=datetime.date(2099, 1, 1))
self.assertTrue(profile.is_currently_valid) # the date itself is fine...
self.assertFalse(profile.is_eligible) # ...but there's no level, so not eligible
self.assertEqual(list(profile.eligible_teams), [])
def test_no_validity_set_is_not_currently_valid_or_eligible(self):
profile = RefereeProfile.objects.create(member=self.member, level=self.level)
self.assertFalse(profile.is_currently_valid)
self.assertFalse(profile.is_eligible)
self.assertEqual(list(profile.eligible_teams), [])
def test_valid_until_today_is_currently_valid_and_eligible(self):
profile = RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=timezone.localdate())
self.assertTrue(profile.is_currently_valid)
self.assertTrue(profile.is_eligible)
def test_valid_until_in_the_past_is_not_currently_valid_or_eligible(self):
profile = RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=timezone.localdate() - datetime.timedelta(days=1))
self.assertFalse(profile.is_currently_valid)
self.assertFalse(profile.is_eligible)
self.assertEqual(list(profile.eligible_teams), [])
def test_eligible_teams_come_from_the_level_when_eligible(self):
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_deleting_a_referenced_level_is_protected(self):
RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=timezone.localdate())
with self.assertRaises(ProtectedError):
self.level.delete()
def test_deleting_the_member_deletes_the_profile(self):
profile = RefereeProfile.objects.create(member=self.member)
self.member.delete()
self.assertFalse(RefereeProfile.objects.filter(pk=profile.pk).exists())