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:
@@ -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")
|
||||
|
||||
54
members/migrations/0004_group_groupmembership_and_more.py
Normal file
54
members/migrations/0004_group_groupmembership_and_more.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}/")
|
||||
|
||||
Reference in New Issue
Block a user