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 <noreply@anthropic.com>
This commit is contained in:
0
backend/teams/__init__.py
Normal file
0
backend/teams/__init__.py
Normal file
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.'),
|
||||
),
|
||||
]
|
||||
122
teams/models.py
122
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}"
|
||||
43
teams/rules.py
Normal file
43
teams/rules.py
Normal file
@@ -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()
|
||||
246
teams/tests.py
246
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")
|
||||
Reference in New Issue
Block a user