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:
@@ -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
0
teams/__init__.py
Normal file
56
teams/admin.py
Normal file
56
teams/admin.py
Normal 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
5
teams/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class TeamsConfig(AppConfig):
|
||||
name = "teams"
|
||||
100
teams/migrations/0001_initial.py
Normal file
100
teams/migrations/0001_initial.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
0
teams/migrations/__init__.py
Normal file
0
teams/migrations/__init__.py
Normal file
81
teams/models.py
Normal file
81
teams/models.py
Normal 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
112
teams/tests.py
Normal 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
1
teams/views.py
Normal file
@@ -0,0 +1 @@
|
||||
# Create your views here.
|
||||
Reference in New Issue
Block a user