diff --git a/clubmanager/settings.py b/clubmanager/settings.py index 05bde7f..b345015 100644 --- a/clubmanager/settings.py +++ b/clubmanager/settings.py @@ -47,6 +47,7 @@ INSTALLED_APPS = [ "club.apps.ClubConfig", "authentication.apps.AuthenticationConfig", "members.apps.MembersConfig", + "teams.apps.TeamsConfig", ] MIDDLEWARE = [ diff --git a/teams/__init__.py b/teams/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/teams/admin.py b/teams/admin.py new file mode 100644 index 0000000..c2deb32 --- /dev/null +++ b/teams/admin.py @@ -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"] diff --git a/teams/apps.py b/teams/apps.py new file mode 100644 index 0000000..2cbcff7 --- /dev/null +++ b/teams/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class TeamsConfig(AppConfig): + name = "teams" diff --git a/teams/migrations/0001_initial.py b/teams/migrations/0001_initial.py new file mode 100644 index 0000000..a203854 --- /dev/null +++ b/teams/migrations/0001_initial.py @@ -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'), + ), + ] diff --git a/teams/migrations/__init__.py b/teams/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/teams/models.py b/teams/models.py new file mode 100644 index 0000000..2b41c16 --- /dev/null +++ b/teams/models.py @@ -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}" diff --git a/teams/tests.py b/teams/tests.py new file mode 100644 index 0000000..542163b --- /dev/null +++ b/teams/tests.py @@ -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) diff --git a/teams/views.py b/teams/views.py new file mode 100644 index 0000000..60f00ef --- /dev/null +++ b/teams/views.py @@ -0,0 +1 @@ +# Create your views here.