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"):
|
if hasattr(user, "member"):
|
||||||
member = 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)
|
user.set_password(password)
|
||||||
else:
|
else:
|
||||||
member = cls()
|
member = cls()
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ from django.contrib.auth import get_user_model
|
|||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from members.models import Member
|
||||||
|
|
||||||
User = get_user_model()
|
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.member_manager"))
|
||||||
self.assertTrue(self.user_a.has_perm("members.add_member"))
|
self.assertTrue(self.user_a.has_perm("members.add_member"))
|
||||||
self.assertFalse(self.user_a.is_superuser)
|
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
|
import re
|
||||||
|
|
||||||
from constance import config
|
from constance import config
|
||||||
|
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django_extensions.db.fields import AutoSlugField
|
||||||
from rules import is_superuser
|
from rules import is_superuser
|
||||||
from rules.contrib.models import RulesModel
|
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):
|
class Season(RulesModel):
|
||||||
start_date = models.DateField(_("start date"))
|
start_date = models.DateField(_("start date"))
|
||||||
@@ -40,21 +48,17 @@ class Season(RulesModel):
|
|||||||
if current_date is None:
|
if current_date is None:
|
||||||
current_date = timezone.now().date()
|
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:
|
if values_only:
|
||||||
return season.date_range
|
return season.date_range
|
||||||
return season
|
return season
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def generate_default(cls, day: int | None = None, month: int | None = None, duration: str | None = None) -> "Season":
|
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":
|
||||||
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
|
|
||||||
|
|
||||||
current_year = timezone.now().date().year
|
current_year = timezone.now().date().year
|
||||||
start_date = datetime.date(current_year, month, day)
|
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])
|
day = min(date_value.day, calendar.monthrange(year, month)[1])
|
||||||
|
|
||||||
return date_value.replace(year=year, month=month, day=day)
|
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