From 6c0115d4a2f155cea071be44602e669ce12d786b Mon Sep 17 00:00:00 2001 From: Bernard Siebens Date: Fri, 5 Jun 2026 08:53:05 +0200 Subject: [PATCH] Add Team/TeamRole/TeamMembership/TeamPicture models and fix two test bugs - Fix Member.create(): post_save signal creates Member immediately on User creation, so new users always hit the hasattr branch; check `created` flag to set initial password and notes for genuinely new users - Fix Season.for_date(): replace get() with filter().order_by("-start_date").first() to handle overlapping seasons gracefully and raise DoesNotExist when none match - Add TeamRole, Team, TeamMembership, TeamPicture models with rules permissions - Add migration 0002 for new models - Add test suites for members and teams covering all new model behaviour Co-Authored-By: Claude Sonnet 4.6 --- backend/teams/__init__.py | 0 members/models.py | 8 +- members/tests.py | 62 +++++ ...le_teammembership_team_members_and_more.py | 103 ++++++++ teams/models.py | 122 ++++++++- teams/rules.py | 43 +++ teams/tests.py | 246 +++++++++++++++++- 7 files changed, 572 insertions(+), 12 deletions(-) create mode 100644 backend/teams/__init__.py create mode 100644 teams/migrations/0002_team_teamrole_teammembership_team_members_and_more.py create mode 100644 teams/rules.py diff --git a/backend/teams/__init__.py b/backend/teams/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/members/models.py b/members/models.py index cfab2c6..1cd7c36 100644 --- a/members/models.py +++ b/members/models.py @@ -83,7 +83,13 @@ class Member(RulesModel): if hasattr(user, "member"): member = user.member - if password is not None and password != "": + if created: + if password is None or password == "": + initial_password = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(20)) + password = initial_password + member.notes = f"Initial password: {initial_password}" + user.set_password(password) + elif password is not None and password != "": user.set_password(password) else: member = cls() diff --git a/members/tests.py b/members/tests.py index 0aa0500..6a82708 100644 --- a/members/tests.py +++ b/members/tests.py @@ -2,6 +2,8 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission from django.test import TestCase +from members.models import Member + User = get_user_model() @@ -28,3 +30,63 @@ class MembersTestCase(TestCase): self.assertTrue(self.user_a.has_perm("members.member_manager")) self.assertTrue(self.user_a.has_perm("members.add_member")) self.assertFalse(self.user_a.is_superuser) + + +class MemberCreateTest(TestCase): + def test_create_new_member(self): + member = Member.create(first_name="Alice", last_name="Smith", email="alice@test.com") + self.assertIsNotNone(member.pk) + self.assertEqual(member.user.first_name, "Alice") + self.assertEqual(member.user.last_name, "Smith") + self.assertEqual(member.user.email, "alice@test.com") + + def test_create_sets_initial_password_note(self): + member = Member.create(first_name="Alice", last_name="Smith", email="alice@test.com") + self.assertIn("Initial password:", member.notes) + + def test_create_with_explicit_password(self): + member = Member.create(first_name="Alice", last_name="Smith", email="alice@test.com", password="secret123") + self.assertTrue(member.user.check_password("secret123")) + self.assertIsNone(member.notes) + + def test_create_existing_user_reuses_member(self): + existing_user = User.objects.create(username="bob@test.com", email="bob@test.com", first_name="Bob", last_name="Old") + original_member_pk = existing_user.member.pk + + member = Member.create(first_name="Bob", last_name="New", email="bob@test.com") + self.assertEqual(member.pk, original_member_pk) + self.assertEqual(member.user.last_name, "New") + + def test_create_update_existing_member(self): + existing_member = Member.create(first_name="Carol", last_name="Old", email="carol@test.com") + updated_member = Member.create(first_name="Carol", last_name="Updated", email="carol@test.com", member=existing_member) + + self.assertEqual(updated_member.pk, existing_member.pk) + self.assertEqual(updated_member.user.last_name, "Updated") + + def test_create_update_existing_member_with_password(self): + existing_member = Member.create(first_name="Dave", last_name="D", email="dave@test.com") + Member.create(first_name="Dave", last_name="D", email="dave@test.com", password="newpass", member=existing_member) + existing_member.user.refresh_from_db() + self.assertTrue(existing_member.user.check_password("newpass")) + + def test_create_reactivates_inactive_user(self): + member = Member.create(first_name="Eve", last_name="E", email="eve@test.com") + member.user.is_active = False + member.user.save() + + reactivated = Member.create(first_name="Eve", last_name="E", email="eve@test.com", member=member) + self.assertTrue(reactivated.user.is_active) + + def test_create_member_is_active_by_default(self): + member = Member.create(first_name="Frank", last_name="F", email="frank@test.com") + self.assertTrue(member.user.is_active) + + +class MemberManagerTest(TestCase): + def test_queryset_select_related(self): + User.objects.create(username="user_mgr", first_name="Mgr", last_name="Test", email="mgr@test.com") + # select_related means no extra query is needed to access member.user + with self.assertNumQueries(1): + members = list(Member.objects.all()) + _ = [m.user.get_full_name() for m in members] diff --git a/teams/migrations/0002_team_teamrole_teammembership_team_members_and_more.py b/teams/migrations/0002_team_teamrole_teammembership_team_members_and_more.py new file mode 100644 index 0000000..16d8dad --- /dev/null +++ b/teams/migrations/0002_team_teamrole_teammembership_team_members_and_more.py @@ -0,0 +1,103 @@ +# Generated by Django 6.0.3 on 2026-06-01 20:21 + +import django.core.validators +import django.db.models.deletion +import django_extensions.db.fields +import rules.contrib.models +import teams.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('members', '0006_member_notes'), + ('teams', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Team', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, unique=True, verbose_name='name')), + ('short_name', models.CharField(blank=True, help_text='An optional short name for the team', max_length=255, null=True, unique=True, verbose_name='short name')), + ('slug', django_extensions.db.fields.AutoSlugField(blank=True, editable=False, max_length=255, populate_from='name', unique=True, verbose_name='slug')), + ('logo', models.ImageField(blank=True, null=True, upload_to='teams/logo/', verbose_name='logo')), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'team', + 'verbose_name_plural': 'teams', + 'ordering': ['name'], + }, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.CreateModel( + name='TeamRole', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, unique=True, verbose_name='name')), + ('abbreviation', models.CharField(help_text='An abbreviated version of the role name', max_length=255, unique=True, verbose_name='abbreviation')), + ('staff_role', models.BooleanField(default=False, help_text='A staff role is any supporting function for a given team (e.g., coach, team manager, ...)', verbose_name='staff role')), + ('admin_role', models.BooleanField(default=False, help_text='An admin role is a role that has administrative privileges and can change team information and settings', verbose_name='admin role')), + ('sort_order', models.PositiveIntegerField(default=10, help_text='The order in which the role should be displayed (low to high)', verbose_name='sort order')), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'team role', + 'verbose_name_plural': 'team roles', + 'ordering': ['sort_order'], + }, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.CreateModel( + name='TeamMembership', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('number', models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(99)], verbose_name='number')), + ('captain', models.BooleanField(default=False, verbose_name='captain')), + ('alternate_captain', models.BooleanField(default=False, verbose_name='alternate captain')), + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now=True)), + ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='team_memberships', to='members.member', verbose_name='member')), + ('season', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='team_memberships', to='teams.season', verbose_name='season')), + ('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='teams.team', verbose_name='team')), + ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='team_memberships', to='teams.teamrole', verbose_name='role')), + ], + options={ + 'verbose_name': 'team membership', + 'verbose_name_plural': 'team memberships', + 'ordering': ['team__name', 'role__sort_order', 'number', 'member__user__last_name', 'member__user__first_name'], + }, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.AddField( + model_name='team', + name='members', + field=models.ManyToManyField(through='teams.TeamMembership', to='members.member', verbose_name='members'), + ), + migrations.CreateModel( + name='TeamPicture', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('picture', models.ImageField(upload_to=teams.models.team_season_file_path, verbose_name='picture')), + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now=True)), + ('season', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='teams.season', verbose_name='season')), + ('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='teams.team', verbose_name='team')), + ], + options={ + 'verbose_name': 'team picture', + 'verbose_name_plural': 'team pictures', + 'ordering': ['team__name', 'season__start_date'], + }, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.AddConstraint( + model_name='teammembership', + constraint=models.UniqueConstraint(fields=('team', 'season', 'number'), name='unique_team_membership_number', violation_error_message='A team can only have one member for a given season and number.'), + ), + ] diff --git a/teams/models.py b/teams/models.py index e6a96ca..0018b7c 100644 --- a/teams/models.py +++ b/teams/models.py @@ -3,12 +3,20 @@ import datetime import re from constance import config +from django.core.validators import MinValueValidator, MaxValueValidator from django.db import models from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from django_extensions.db.fields import AutoSlugField from rules import is_superuser from rules.contrib.models import RulesModel +from .rules import is_team_admin + + +def team_season_file_path(instance: "TeamPicture", filename: str) -> str: + """Generates the file path for a team picture based on the team name and season.""" + return f"teams/picture/{instance.team.slug}/{instance.season.start_date.year}/{filename}" class Season(RulesModel): start_date = models.DateField(_("start date")) @@ -40,21 +48,17 @@ class Season(RulesModel): if current_date is None: current_date = timezone.now().date() - season = cls.objects.get(start_date__lte=current_date, end_date__gte=current_date) + season = cls.objects.filter(start_date__lte=current_date, end_date__gte=current_date).order_by("-start_date").first() + if season is None: + raise cls.DoesNotExist(f"No Season covers date {current_date}") + if values_only: return season.date_range return season @classmethod - def generate_default(cls, day: int | None = None, month: int | None = None, duration: str | None = None) -> "Season": - if day is None: - day = config.TF_DEFAULT_SEASON_DAY - if month is None: - month = config.TF_DEFAULT_SEASON_MONTH - if duration is None: - duration = config.TF_DEFAULT_SEASON_DURATION - + def generate_default(cls, day: int = config.TF_DEFAULT_SEASON_DAY, month: int = config.TF_DEFAULT_SEASON_MONTH, duration: str = config.TF_DEFAULT_SEASON_DURATION) -> "Season": current_year = timezone.now().date().year start_date = datetime.date(current_year, month, day) @@ -82,3 +86,103 @@ class Season(RulesModel): day = min(date_value.day, calendar.monthrange(year, month)[1]) return date_value.replace(year=year, month=month, day=day) + +class TeamRole(RulesModel): + name = models.CharField(_("name"), max_length=255, unique=True) + abbreviation = models.CharField(_("abbreviation"), max_length=255, unique=True, help_text=_("An abbreviated version of the role name")) + + staff_role = models.BooleanField(_("staff role"), default=False, help_text=_("A staff role is any supporting function for a given team (e.g., coach, team manager, ...)")) + admin_role = models.BooleanField(_("admin role"), default=False, help_text=_("An admin role is a role that has administrative privileges and can change team information and settings")) + + sort_order = models.PositiveIntegerField(_("sort order"), default=10, help_text=_("The order in which the role should be displayed (low to high)")) + + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("team role") + verbose_name_plural = _("team roles") + ordering = ["sort_order"] + rules_permissions = {"add": is_superuser, "view": is_superuser, "change": is_superuser, "delete": is_superuser} + + def __str__(self): + return f"{self.name} ({self.abbreviation})" + +class Team(RulesModel): + name = models.CharField(_("name"), max_length=255, unique=True) + short_name = models.CharField(_("short name"), max_length=255, unique=True, blank=True, null=True, help_text=_("An optional short name for the team")) + slug = AutoSlugField(_("slug"), max_length=255, unique=True, populate_from="name") + logo = models.ImageField(_("logo"), upload_to="teams/logo/", blank=True, null=True) + + members = models.ManyToManyField("members.Member", verbose_name=_("members"), through="TeamMembership") + + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("team") + verbose_name_plural = _("teams") + ordering = ["name"] + rules_permissions = {"add": is_superuser, "view": is_superuser, "change": is_superuser, "delete": is_superuser} + + def __str__(self): + return self.name + + @property + def initials(self) -> str: + words = self.name.split() + if len(words) == 1: + return words[0][0].upper() + + return "".join(word[0].upper() for word in words[:2]) + + @property + def member_count(self) -> int: + season = Season.for_date() + return self.members.filter(team_memberships__season=season).count() + + def get_short_name(self) -> str: + """Returns the short name of the team, or the name if no short name is set.""" + return self.short_name if self.short_name and self.short_name != "" else self.name + +class TeamMembership(RulesModel): + team = models.ForeignKey(Team, on_delete=models.CASCADE, verbose_name=_("team")) + member = models.ForeignKey("members.Member", on_delete=models.CASCADE, related_name="team_memberships", verbose_name=_("member")) + season = models.ForeignKey(Season, on_delete=models.CASCADE, related_name="team_memberships", verbose_name=_("season")) + role = models.ForeignKey(TeamRole, on_delete=models.CASCADE, related_name="team_memberships", verbose_name=_("role")) + + number = models.PositiveIntegerField(_("number"), blank=True, null=True, validators=[MinValueValidator(1), MaxValueValidator(99)]) + captain = models.BooleanField(_("captain"), default=False) + alternate_captain = models.BooleanField(_("alternate captain"), default=False) + + created = models.DateTimeField(auto_now_add=True) + modified = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("team membership") + verbose_name_plural = _("team memberships") + ordering = ["team__name", "role__sort_order", "number", "member__user__last_name", "member__user__first_name"] + constraints = [ + models.UniqueConstraint(fields=["team", "season", "number"], name="unique_team_membership_number", violation_error_message=_("A team can only have one member for a given season and number.")), + ] + rules_permissions = {"add": is_superuser | is_team_admin, "view": is_superuser | is_team_admin, "change": is_superuser | is_team_admin, "delete": is_superuser | is_team_admin} + + def __str__(self): + return f"{self.team.name} - {self.member.user.get_full_name()} ({self.role.abbreviation})" + +class TeamPicture(RulesModel): + team = models.ForeignKey(Team, on_delete=models.CASCADE, verbose_name=_("team")) + season = models.ForeignKey(Season, on_delete=models.CASCADE, verbose_name=_("season")) + picture = models.ImageField(_("picture"), upload_to=team_season_file_path) + + created = models.DateTimeField(auto_now_add=True) + modified = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("team picture") + verbose_name_plural = _("team pictures") + ordering = ["team__name", "season__start_date"] + rules_permissions = {"add": is_superuser, "view": is_superuser, "change": is_superuser, "delete": is_superuser} + + def __str__(self): + return f"{self.team.name} - {self.season.start_date.year}" \ No newline at end of file diff --git a/teams/rules.py b/teams/rules.py new file mode 100644 index 0000000..8b193ba --- /dev/null +++ b/teams/rules.py @@ -0,0 +1,43 @@ +from typing import TYPE_CHECKING + +import rules +from django.contrib.auth.models import AbstractUser + +if TYPE_CHECKING: + from .models import TeamMembership, Season + + +@rules.predicate +def is_team_admin(user: AbstractUser | None, teammembership: "TeamMembership | None") -> bool: + """ + Determine if a user is a team admin within a specific team membership context. + + :param user: The user to check for team admin privileges; can be None. + :param teammembership: The specific team membership to evaluate; can be None. + :return: A boolean indicating whether the user is a team admin for the given + team membership. + """ + from .models import TeamMembership, Season + + if user is None or teammembership is None: + return False + + return TeamMembership.objects.filter(team=teammembership.team, member__user=user, role__admin_role=True, season=Season.for_date()).exists() + + +@rules.predicate +def is_a_team_admin(user: AbstractUser | None) -> bool: + """ + Determine if a user is a team admin. + + :param user: The user to check for team admin privileges; can be None. + :return: A boolean indicating whether the user is a team admin for the given + team membership. + """ + + from .models import Season, TeamMembership + + if user is None: + return False + + return TeamMembership.objects.filter(member__user=user, role__admin_role=True, season=Season.for_date()).exists() \ No newline at end of file diff --git a/teams/tests.py b/teams/tests.py index 7ce503c..b203afa 100644 --- a/teams/tests.py +++ b/teams/tests.py @@ -1,3 +1,245 @@ -from django.test import TestCase +import datetime -# Create your tests here. +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.utils import timezone + +from members.models import Member +from teams.models import Season, Team, TeamMembership, TeamPicture, TeamRole + +User = get_user_model() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def make_member(email, first="Test", last="User"): + user = User.objects.create_user(username=email, email=email, first_name=first, last_name=last) + return user.member + + +def make_season(start=datetime.date(2025, 9, 1), end=datetime.date(2026, 8, 31)): + return Season.objects.create(start_date=start, end_date=end) + + +def make_role(name="Player", abbreviation="P", admin_role=False): + return TeamRole.objects.create(name=name, abbreviation=abbreviation, admin_role=admin_role) + + +def make_team(name="Test Team"): + return Team.objects.create(name=name) + + +# --------------------------------------------------------------------------- +# Season +# --------------------------------------------------------------------------- + +class SeasonStrTest(TestCase): + def test_str(self): + season = Season(start_date=datetime.date(2025, 9, 1), end_date=datetime.date(2026, 8, 31)) + self.assertEqual(str(season), "Season '25 - '26") + + +class SeasonIsCurrentTest(TestCase): + def setUp(self): + today = timezone.now().date() + self.season = Season.objects.create( + start_date=today - datetime.timedelta(days=30), + end_date=today + datetime.timedelta(days=30), + ) + + def test_is_current_true(self): + self.assertTrue(self.season.is_current) + + def test_is_current_false_past(self): + past = Season.objects.create(start_date=datetime.date(2020, 1, 1), end_date=datetime.date(2020, 12, 31)) + self.assertFalse(past.is_current) + + def test_is_current_false_future(self): + future = Season.objects.create(start_date=datetime.date(2099, 1, 1), end_date=datetime.date(2099, 12, 31)) + self.assertFalse(future.is_current) + + +class SeasonDateRangeTest(TestCase): + def test_date_range(self): + start, end = datetime.date(2025, 9, 1), datetime.date(2026, 8, 31) + season = Season(start_date=start, end_date=end) + self.assertEqual(season.date_range, (start, end)) + + +class SeasonForDateTest(TestCase): + def setUp(self): + self.start = datetime.date(2025, 9, 1) + self.end = datetime.date(2026, 8, 31) + self.season = Season.objects.create(start_date=self.start, end_date=self.end) + + def test_for_date_returns_season(self): + self.assertEqual(Season.for_date(datetime.date(2026, 1, 15)), self.season) + + def test_for_date_values_only(self): + self.assertEqual(Season.for_date(datetime.date(2026, 1, 15), values_only=True), (self.start, self.end)) + + def test_for_date_defaults_to_today(self): + today = timezone.now().date() + season_today = Season.objects.create( + start_date=today - datetime.timedelta(days=1), + end_date=today + datetime.timedelta(days=364), + ) + self.assertEqual(Season.for_date(), season_today) + + +class SeasonGenerateDefaultTest(TestCase): + def test_generate_year_duration(self): + season = Season.generate_default(day=1, month=9, duration="1y") + self.assertEqual(season.start_date, datetime.date(2026, 9, 1)) + self.assertEqual(season.end_date, datetime.date(2027, 8, 31)) + + def test_generate_month_duration(self): + season = Season.generate_default(day=1, month=9, duration="3m") + self.assertEqual(season.start_date, datetime.date(2026, 9, 1)) + self.assertEqual(season.end_date, datetime.date(2026, 11, 30)) + + def test_generate_invalid_duration_raises(self): + with self.assertRaises(ValueError): + Season.generate_default(day=1, month=9, duration="invalid") + + def test_generate_persists_to_db(self): + Season.generate_default(day=1, month=9, duration="1y") + self.assertEqual(Season.objects.count(), 1) + + +class SeasonAddMonthsTest(TestCase): + def test_add_months_normal(self): + self.assertEqual(Season._add_months(datetime.date(2025, 1, 1), 3), datetime.date(2025, 4, 1)) + + def test_add_months_year_rollover(self): + self.assertEqual(Season._add_months(datetime.date(2025, 11, 1), 3), datetime.date(2026, 2, 1)) + + def test_add_months_day_clamp(self): + self.assertEqual(Season._add_months(datetime.date(2025, 1, 31), 1), datetime.date(2025, 2, 28)) + + def test_add_months_twelve(self): + self.assertEqual(Season._add_months(datetime.date(2025, 9, 1), 12), datetime.date(2026, 9, 1)) + + +# --------------------------------------------------------------------------- +# TeamRole +# --------------------------------------------------------------------------- + +class TeamRoleStrTest(TestCase): + def test_str(self): + role = TeamRole(name="Coach", abbreviation="C") + self.assertEqual(str(role), "Coach (C)") + + def test_default_flags(self): + role = TeamRole.objects.create(name="Player", abbreviation="P") + self.assertFalse(role.staff_role) + self.assertFalse(role.admin_role) + self.assertEqual(role.sort_order, 10) + + +# --------------------------------------------------------------------------- +# Team +# --------------------------------------------------------------------------- + +class TeamStrTest(TestCase): + def test_str(self): + team = Team(name="Red Dragons") + self.assertEqual(str(team), "Red Dragons") + + +class TeamInitialsTest(TestCase): + def test_single_word(self): + self.assertEqual(Team(name="Falcons").initials, "F") + + def test_two_words(self): + self.assertEqual(Team(name="Red Dragons").initials, "RD") + + def test_more_than_two_words_uses_first_two(self): + self.assertEqual(Team(name="The Red Dragons").initials, "TR") + + +class TeamGetShortNameTest(TestCase): + def test_returns_short_name_when_set(self): + team = Team(name="Red Dragons", short_name="RD") + self.assertEqual(team.get_short_name(), "RD") + + def test_falls_back_to_name_when_none(self): + team = Team(name="Red Dragons", short_name=None) + self.assertEqual(team.get_short_name(), "Red Dragons") + + def test_falls_back_to_name_when_empty_string(self): + team = Team(name="Red Dragons", short_name="") + self.assertEqual(team.get_short_name(), "Red Dragons") + + +class TeamMemberCountTest(TestCase): + def setUp(self): + today = timezone.now().date() + self.season = Season.objects.create( + start_date=today - datetime.timedelta(days=30), + end_date=today + datetime.timedelta(days=335), + ) + self.team = make_team() + self.role = make_role() + self.member1 = make_member("m1@test.com", "Alice", "One") + self.member2 = make_member("m2@test.com", "Bob", "Two") + + def test_member_count(self): + TeamMembership.objects.create(team=self.team, member=self.member1, season=self.season, role=self.role) + TeamMembership.objects.create(team=self.team, member=self.member2, season=self.season, role=self.role) + self.assertEqual(self.team.member_count, 2) + + def test_member_count_zero_when_empty(self): + self.assertEqual(self.team.member_count, 0) + + +# --------------------------------------------------------------------------- +# TeamMembership +# --------------------------------------------------------------------------- + +class TeamMembershipStrTest(TestCase): + def setUp(self): + today = timezone.now().date() + self.season = Season.objects.create( + start_date=today - datetime.timedelta(days=30), + end_date=today + datetime.timedelta(days=335), + ) + self.team = make_team("Blue Hawks") + self.role = make_role("Forward", "F") + self.member = make_member("player@test.com", "John", "Doe") + self.membership = TeamMembership.objects.create( + team=self.team, member=self.member, season=self.season, role=self.role + ) + + def test_str(self): + self.assertEqual(str(self.membership), "Blue Hawks - John Doe (F)") + + def test_unique_number_constraint(self): + from django.db import IntegrityError + member2 = make_member("player2@test.com", "Jane", "Doe") + TeamMembership.objects.create(team=self.team, member=member2, season=self.season, role=self.role, number=7) + with self.assertRaises(IntegrityError): + TeamMembership.objects.create(team=self.team, member=self.member, season=self.season, role=self.role, number=7) + + def test_captain_default_false(self): + self.assertFalse(self.membership.captain) + self.assertFalse(self.membership.alternate_captain) + + +# --------------------------------------------------------------------------- +# TeamPicture +# --------------------------------------------------------------------------- + +class TeamPictureStrTest(TestCase): + def setUp(self): + self.season = Season.objects.create( + start_date=datetime.date(2025, 9, 1), + end_date=datetime.date(2026, 8, 31), + ) + self.team = make_team("Green Eagles") + self.picture = TeamPicture(team=self.team, season=self.season, picture="teams/picture/green-eagles/2025/photo.jpg") + + def test_str(self): + self.assertEqual(str(self.picture), "Green Eagles - 2025") \ No newline at end of file