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 Family, FamilyMembership, Member
from .models import Family, FamilyMembership, Group, GroupMembership, Member
# Register your models here.
@@ -60,3 +60,30 @@ class FamilyMembershipAdmin(admin.ModelAdmin):
list_filter = ("role",)
autocomplete_fields = ("family", "member")
search_fields = ("family__name", "member__first_name", "member__last_name")
class GroupMemberInline(admin.TabularInline):
"""Members shown on the Group page."""
model = GroupMembership
extra = 1
autocomplete_fields = ("member",)
@admin.register(Group)
class GroupAdmin(admin.ModelAdmin):
list_display = ("name", "club", "member_count")
list_filter = ("club",)
search_fields = ("name",)
inlines = [GroupMemberInline]
@admin.display(description=_("members"))
def member_count(self, obj):
return obj.memberships.count()
@admin.register(GroupMembership)
class GroupMembershipAdmin(admin.ModelAdmin):
list_display = ("group", "member")
autocomplete_fields = ("group", "member")
search_fields = ("group__name", "member__first_name", "member__last_name")

View File

@@ -0,0 +1,54 @@
# Generated by Django 6.0.6 on 2026-08-08 21:53
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', '0003_family_created_family_modified_member_created_and_more'),
]
operations = [
migrations.CreateModel(
name='Group',
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')),
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
],
options={
'verbose_name': 'group',
'verbose_name_plural': 'groups',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='GroupMembership',
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)),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='members.group', verbose_name='group')),
('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_memberships', to='members.member', verbose_name='member')),
],
options={
'verbose_name': 'group membership',
'verbose_name_plural': 'group memberships',
'ordering': ['group', 'member__last_name', 'member__first_name'],
},
),
migrations.AddConstraint(
model_name='group',
constraint=models.UniqueConstraint(fields=('club', 'name'), name='unique_group_name_per_club'),
),
migrations.AddConstraint(
model_name='groupmembership',
constraint=models.UniqueConstraint(fields=('group', 'member'), name='unique_member_per_group'),
),
]

View File

@@ -3,7 +3,7 @@ from django.db import models
from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField
from rosterchief.base import UUIDModel
from rosterchief.base import ClubScopedModel, UUIDModel
class Family(UUIDModel):
@@ -99,3 +99,42 @@ class FamilyMembership(models.Model):
def __str__(self):
return f"{self.family} - {self.member} ({self.get_role_display()})"
class Group(ClubScopedModel):
"""An arbitrary named collection of members -- deliberately generic, not
team-shaped and not aware of any specific use: "all coaches", "all team
managers", an ad-hoc committee. A Team's roster is a separate, more
specific concept (teams.TeamMembership); nothing here assumes team
semantics. Referee eligibility (who can ref which team) is a member-level
fact (teams.RefereeProfile), not a Group concern -- Group carries no
referee-specific knowledge at all."""
name = models.CharField(_("name"), max_length=255)
class Meta:
verbose_name = _("group")
verbose_name_plural = _("groups")
ordering = ["name"]
constraints = [
models.UniqueConstraint(fields=["club", "name"], name="unique_group_name_per_club"),
]
def __str__(self):
return self.name
class GroupMembership(UUIDModel):
group = models.ForeignKey(Group, on_delete=models.CASCADE, related_name="memberships", verbose_name=_("group"))
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="group_memberships", verbose_name=_("member"))
class Meta:
verbose_name = _("group membership")
verbose_name_plural = _("group memberships")
ordering = ["group", "member__last_name", "member__first_name"]
constraints = [
models.UniqueConstraint(fields=["group", "member"], name="unique_member_per_group"),
]
def __str__(self):
return f"{self.group} - {self.member}"

View File

@@ -18,17 +18,22 @@ def find_member_by_email(email):
return Member.objects.filter(user__email__iexact=email).first()
def get_or_create_login_member(email, first_name="", last_name=""):
"""Find or create the Member behind ``email``, creating a User (no usable
password -- they set one via the reset-link flow) the first time we see them.
Mirrors controlpanel.services.admins.grant_club_admin.
"""
def get_or_create_login_user(email):
"""Find or create the User behind ``email`` -- no usable password, they set
one via the reset-link flow the first time we see them. Mirrors
controlpanel.services.admins.grant_club_admin."""
email = email.lower()
user, user_created = User.objects.get_or_create(email=email, defaults={"is_active": True})
if user_created:
user.set_unusable_password()
user.save(update_fields=["password"])
return user, user_created
def get_or_create_login_member(email, first_name="", last_name=""):
"""Find or create the Member behind ``email``, creating a User the first time
we see them."""
user, _ = get_or_create_login_user(email)
member, _ = Member.objects.get_or_create(user=user, defaults={"first_name": first_name, "last_name": last_name})
return member

View File

@@ -15,7 +15,7 @@ from django.utils import timezone
from authentication.models import User
from club.models import Club, ClubMembership, Season
from members.admin import FamilyAdmin
from members.models import Family, FamilyMembership, Member
from members.models import Family, FamilyMembership, Group, GroupMembership, Member
from members.services import MemberImportResult
@@ -209,6 +209,62 @@ class FamilyMembershipModelTests(TestCase):
self.assertTrue(Member.objects.filter(pk=member.pk).exists())
class GroupModelTests(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
def test_str_returns_name(self):
group = Group.objects.create(club=self.club, name="Coaches")
self.assertEqual(str(group), "Coaches")
def test_name_is_unique_per_club(self):
Group.objects.create(club=self.club, name="Coaches")
with self.assertRaises(IntegrityError):
Group.objects.create(club=self.club, name="Coaches")
def test_same_name_allowed_in_another_club(self):
other = Club.objects.create(name="Rival FC", slug="rival-fc")
Group.objects.create(club=self.club, name="Coaches")
Group.objects.create(club=other, name="Coaches")
self.assertEqual(Group.objects.filter(name="Coaches").count(), 2)
class GroupMembershipModelTests(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
self.group = Group.objects.create(club=self.club, name="Referees")
self.member = Member.objects.create(first_name="Ref", last_name="Eree")
def test_str(self):
membership = GroupMembership.objects.create(group=self.group, member=self.member)
self.assertEqual(str(membership), "Referees - Ref Eree")
def test_member_unique_per_group(self):
GroupMembership.objects.create(group=self.group, member=self.member)
with self.assertRaises(IntegrityError):
GroupMembership.objects.create(group=self.group, member=self.member)
def test_same_member_can_join_multiple_groups(self):
other_group = Group.objects.create(club=self.club, name="Coaches")
GroupMembership.objects.create(group=self.group, member=self.member)
GroupMembership.objects.create(group=other_group, member=self.member)
self.assertEqual(self.member.group_memberships.count(), 2)
def test_deleting_group_cascades_to_memberships(self):
GroupMembership.objects.create(group=self.group, member=self.member)
self.group.delete()
self.assertFalse(GroupMembership.objects.exists())
self.assertTrue(Member.objects.filter(pk=self.member.pk).exists())
class FamilyAdminTests(TestCase):
def test_member_count_reflects_memberships(self):
family = Family.objects.create(name="The Smiths")
@@ -241,6 +297,8 @@ class AdminSmokeTests(TestCase):
("members", "member"),
("members", "family"),
("members", "familymembership"),
("members", "group"),
("members", "groupmembership"),
):
with self.subTest(model=model):
response = self.client.get(f"/admin/{app_label}/{model}/")