feat(teams): add teams app with roster and staff assignments

Introduce the teams app: Team and Position (both club-scoped, with
per-club unique names), TeamMembership (season-scoped roster with jersey
number + captain flags, unique member and jersey per team/season), and
StaffAssignment (coaching/staff, filtered to staff positions). All
uniqueness expressed as UniqueConstraints.

Register every model in the admin (with roster + staff inlines on Team)
and cover the models with tests. Fix two model bugs surfaced by the
system check: StaffAssignment.position reused TeamMembership's reverse
accessor (Position.team_memberships) and duplicated its constraint name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 16:47:52 +02:00
parent dd2e4b2169
commit 076a9cacbc
9 changed files with 356 additions and 0 deletions

View File

@@ -47,6 +47,7 @@ INSTALLED_APPS = [
"club.apps.ClubConfig",
"authentication.apps.AuthenticationConfig",
"members.apps.MembersConfig",
"teams.apps.TeamsConfig",
]
MIDDLEWARE = [

0
teams/__init__.py Normal file
View File

56
teams/admin.py Normal file
View File

@@ -0,0 +1,56 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from .models import Position, StaffAssignment, Team, TeamMembership
class TeamMembershipInline(admin.TabularInline):
"""Roster shown on the Team page."""
model = TeamMembership
extra = 0
raw_id_fields = ("member",)
class StaffAssignmentInline(admin.TabularInline):
"""Coaching / staff shown on the Team page."""
model = StaffAssignment
extra = 0
raw_id_fields = ("member",)
@admin.register(Team)
class TeamAdmin(admin.ModelAdmin):
list_display = ["name", "short_name", "club"]
list_filter = ["club"]
search_fields = ["name", "short_name"]
inlines = [TeamMembershipInline, StaffAssignmentInline]
@admin.register(Position)
class PositionAdmin(admin.ModelAdmin):
list_display = ["name", "short_name", "club", "staff_position", "ordering"]
list_filter = ["club", "staff_position"]
search_fields = ["name", "short_name"]
ordering = ["ordering", "name"]
@admin.register(TeamMembership)
class TeamMembershipAdmin(admin.ModelAdmin):
list_display = ["team", "member", "season", "position", "jersey_number", "is_captain", "is_alternate_captain"]
list_filter = ["team", "season", "position", "is_captain"]
search_fields = ["team__name", "member__first_name", "member__last_name"]
raw_id_fields = ["member"]
fieldsets = [
[None, {"fields": ["team", "season", "member", "position"]}],
[_("Squad"), {"fields": ["jersey_number", "is_captain", "is_alternate_captain"]}],
]
@admin.register(StaffAssignment)
class StaffAssignmentAdmin(admin.ModelAdmin):
list_display = ["team", "member", "season", "position"]
list_filter = ["team", "season", "position"]
search_fields = ["team__name", "member__first_name", "member__last_name"]
raw_id_fields = ["member"]

5
teams/apps.py Normal file
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class TeamsConfig(AppConfig):
name = "teams"

View File

@@ -0,0 +1,100 @@
# Generated by Django 6.0.6 on 2026-07-12 14:43
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('club', '0008_alter_clubmembership_unique_together_and_more'),
('members', '0002_alter_familymembership_unique_together_and_more'),
]
operations = [
migrations.CreateModel(
name='Position',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=255, verbose_name='name')),
('short_name', models.CharField(max_length=255, verbose_name='short name')),
('ordering', models.PositiveSmallIntegerField(default=0, verbose_name='ordering')),
('staff_position', models.BooleanField(default=False, verbose_name='staff position')),
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
],
options={
'verbose_name': 'position',
'verbose_name_plural': 'positions',
'ordering': ['ordering', 'name'],
},
),
migrations.CreateModel(
name='Team',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=255, verbose_name='name')),
('short_name', models.CharField(max_length=255, verbose_name='short name')),
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
],
options={
'verbose_name': 'team',
'verbose_name_plural': 'teams',
},
),
migrations.CreateModel(
name='StaffAssignment',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='staff_assignments', to='members.member', verbose_name='member')),
('position', models.ForeignKey(limit_choices_to={'staff_position': True}, on_delete=django.db.models.deletion.PROTECT, related_name='staff_assignments', to='teams.position', verbose_name='position')),
('season', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='staff_assignments', to='club.season', verbose_name='season')),
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='staff_assignments', to='teams.team', verbose_name='team')),
],
options={
'verbose_name': 'staff assignment',
'verbose_name_plural': 'staff assignments',
'ordering': ['team', 'member__last_name', 'member__first_name'],
},
),
migrations.CreateModel(
name='TeamMembership',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('jersey_number', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='jersey number')),
('is_captain', models.BooleanField(default=False, verbose_name='is captain')),
('is_alternate_captain', models.BooleanField(default=False, verbose_name='is alternate captain')),
('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='team_memberships', to='members.member', verbose_name='member')),
('position', models.ForeignKey(limit_choices_to={'staff_position': False}, on_delete=django.db.models.deletion.PROTECT, related_name='team_memberships', to='teams.position', verbose_name='position')),
('season', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='team_memberships', to='club.season', verbose_name='season')),
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='roster', to='teams.team', verbose_name='team')),
],
options={
'verbose_name': 'team membership',
'verbose_name_plural': 'team memberships',
'ordering': ['team', 'member__last_name', 'member__first_name'],
},
),
migrations.AddConstraint(
model_name='position',
constraint=models.UniqueConstraint(fields=('club', 'name'), name='unique_position_name_per_club'),
),
migrations.AddConstraint(
model_name='team',
constraint=models.UniqueConstraint(fields=('club', 'name'), name='unique_team_name_per_club'),
),
migrations.AddConstraint(
model_name='staffassignment',
constraint=models.UniqueConstraint(fields=('team', 'season', 'member'), name='unique_staff_member_per_team_per_season'),
),
migrations.AddConstraint(
model_name='teammembership',
constraint=models.UniqueConstraint(fields=('team', 'season', 'member'), name='unique_member_per_team_per_season'),
),
migrations.AddConstraint(
model_name='teammembership',
constraint=models.UniqueConstraint(fields=('team', 'season', 'jersey_number'), name='unique_jersey_number_per_team_per_season'),
),
]

View File

81
teams/models.py Normal file
View File

@@ -0,0 +1,81 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from club.models import Season
from clubmanager.base import ClubScopedModel, UUIDModel
from members.models import Member
class Team(ClubScopedModel):
name = models.CharField(_("name"), max_length=255)
short_name = models.CharField(_("short name"), max_length=255)
class Meta:
verbose_name = _("team")
verbose_name_plural = _("teams")
constraints = [
models.UniqueConstraint(fields=["club", "name"], name="unique_team_name_per_club"),
]
def __str__(self):
return self.name
class Position(ClubScopedModel):
name = models.CharField(_("name"), max_length=255)
short_name = models.CharField(_("short name"), max_length=255)
ordering = models.PositiveSmallIntegerField(_("ordering"), default=0)
staff_position = models.BooleanField(_("staff position"), default=False)
class Meta:
verbose_name = _("position")
verbose_name_plural = _("positions")
constraints = [
models.UniqueConstraint(fields=["club", "name"], name="unique_position_name_per_club"),
]
ordering = ["ordering", "name"]
def __str__(self):
return self.name
class TeamMembership(UUIDModel):
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name="roster", verbose_name=_("team"))
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="team_memberships", verbose_name=_("member"))
season = models.ForeignKey(Season, on_delete=models.PROTECT, related_name="team_memberships", verbose_name=_("season"))
position = models.ForeignKey(Position, on_delete=models.PROTECT, related_name="team_memberships", verbose_name=_("position"), limit_choices_to={"staff_position": False})
jersey_number = models.PositiveSmallIntegerField(_("jersey number"), blank=True, null=True)
is_captain = models.BooleanField(_("is captain"), default=False)
is_alternate_captain = models.BooleanField(_("is alternate captain"), default=False)
class Meta:
verbose_name = _("team membership")
verbose_name_plural = _("team memberships")
ordering = ["team", "member__last_name", "member__first_name"]
constraints = [
models.UniqueConstraint(fields=["team", "season", "member"], name="unique_member_per_team_per_season"),
models.UniqueConstraint(fields=["team", "season", "jersey_number"], name="unique_jersey_number_per_team_per_season"),
]
def __str__(self):
return f"{self.team} - {self.member}"
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"))
season = models.ForeignKey(Season, on_delete=models.PROTECT, related_name="staff_assignments", verbose_name=_("season"))
position = models.ForeignKey(Position, on_delete=models.PROTECT, related_name="staff_assignments", verbose_name=_("position"), limit_choices_to={"staff_position": True})
class Meta:
verbose_name = _("staff assignment")
verbose_name_plural = _("staff assignments")
ordering = ["team", "member__last_name", "member__first_name"]
constraints = [
models.UniqueConstraint(fields=["team", "season", "member"], name="unique_staff_member_per_team_per_season"),
]
def __str__(self):
return f"{self.team} - {self.member}"

112
teams/tests.py Normal file
View File

@@ -0,0 +1,112 @@
import datetime
from django.db import IntegrityError
from django.db.models import ProtectedError
from django.test import TestCase
from club.models import Club, Season
from members.models import Member
from .models import Position, StaffAssignment, Team, TeamMembership
class TeamsTestCase(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
self.season = Season.objects.create(
club=self.club,
start_date=datetime.date(2026, 8, 1),
end_date=datetime.date(2027, 5, 31),
)
self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st")
self.forward = Position.objects.create(club=self.club, name="Forward", short_name="FW")
self.coach = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True)
self.member = Member.objects.create(first_name="Jane", last_name="Doe")
class TeamModelTests(TeamsTestCase):
def test_str_returns_name(self):
self.assertEqual(str(self.team), "First Team")
def test_team_name_is_unique_per_club(self):
with self.assertRaises(IntegrityError):
Team.objects.create(club=self.club, name="First Team", short_name="dup")
def test_same_team_name_allowed_in_other_club(self):
other = Club.objects.create(name="Rival FC", slug="rival-fc")
Team.objects.create(club=other, name="First Team", short_name="1st")
self.assertEqual(Team.objects.filter(name="First Team").count(), 2)
class PositionModelTests(TeamsTestCase):
def test_str_returns_name(self):
self.assertEqual(str(self.forward), "Forward")
def test_defaults(self):
self.assertFalse(self.forward.staff_position)
self.assertEqual(self.forward.ordering, 0)
def test_positions_ordered_by_ordering_then_name(self):
keeper = Position.objects.create(club=self.club, name="Keeper", short_name="GK", ordering=1)
ordered = list(Position.objects.filter(club=self.club).values_list("name", flat=True))
# ordering=0 entries first (alphabetical), then ordering=1.
self.assertEqual(ordered, ["Forward", "Head Coach", "Keeper"])
self.assertEqual(keeper.ordering, 1)
def test_position_name_is_unique_per_club(self):
with self.assertRaises(IntegrityError):
Position.objects.create(club=self.club, name="Forward", short_name="dup")
class TeamMembershipModelTests(TeamsTestCase):
def test_can_create_roster_entry(self):
entry = TeamMembership.objects.create(team=self.team, member=self.member, season=self.season, position=self.forward, jersey_number=9)
self.assertEqual(entry.position, self.forward)
self.assertFalse(entry.is_captain)
self.assertEqual(str(entry), "First Team - Jane Doe")
self.assertEqual(list(self.team.roster.all()), [entry])
def test_member_is_unique_per_team_and_season(self):
TeamMembership.objects.create(team=self.team, member=self.member, season=self.season, position=self.forward, jersey_number=9)
with self.assertRaises(IntegrityError):
TeamMembership.objects.create(team=self.team, member=self.member, season=self.season, position=self.forward, jersey_number=10)
def test_jersey_number_is_unique_per_team_and_season(self):
TeamMembership.objects.create(team=self.team, member=self.member, season=self.season, position=self.forward, jersey_number=9)
other = Member.objects.create(first_name="John", last_name="Smith")
with self.assertRaises(IntegrityError):
TeamMembership.objects.create(team=self.team, member=other, season=self.season, position=self.forward, jersey_number=9)
def test_season_is_protected_while_referenced(self):
TeamMembership.objects.create(team=self.team, member=self.member, season=self.season, position=self.forward)
with self.assertRaises(ProtectedError):
self.season.delete()
class StaffAssignmentModelTests(TeamsTestCase):
def test_can_assign_staff(self):
assignment = StaffAssignment.objects.create(team=self.team, member=self.member, season=self.season, position=self.coach)
self.assertEqual(assignment.position, self.coach)
self.assertEqual(str(assignment), "First Team - Jane Doe")
self.assertEqual(list(self.team.staff_assignments.all()), [assignment])
def test_member_is_unique_per_team_and_season(self):
StaffAssignment.objects.create(team=self.team, member=self.member, season=self.season, position=self.coach)
with self.assertRaises(IntegrityError):
StaffAssignment.objects.create(team=self.team, member=self.member, season=self.season, position=self.coach)
def test_roster_and_staff_use_separate_reverse_accessors(self):
TeamMembership.objects.create(team=self.team, member=self.member, season=self.season, position=self.forward)
coach_member = Member.objects.create(first_name="Coach", last_name="Carter")
StaffAssignment.objects.create(team=self.team, member=coach_member, season=self.season, position=self.coach)
self.assertEqual(self.forward.team_memberships.count(), 1)
self.assertEqual(self.coach.staff_assignments.count(), 1)

1
teams/views.py Normal file
View File

@@ -0,0 +1 @@
# Create your views here.