- 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>
188 lines
8.9 KiB
Python
188 lines
8.9 KiB
Python
import calendar
|
|
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"))
|
|
end_date = models.DateField(_("end date"))
|
|
|
|
class Meta:
|
|
verbose_name = _("season")
|
|
verbose_name_plural = _("seasons")
|
|
ordering = ["start_date"]
|
|
rules_permissions = {"add": is_superuser, "view": is_superuser, "change": is_superuser, "delete": is_superuser}
|
|
constraints = [
|
|
models.UniqueConstraint(fields=["start_date", "end_date"], name="season_start_date_end_date_unique", violation_error_message=_("Start and end date for a given season must be unique")),
|
|
models.CheckConstraint(condition=models.Q(start_date__lt=models.F("end_date")), name="season_start_date_lt_end_date", violation_error_message=_("Start date must be before end date.")),
|
|
]
|
|
|
|
def __str__(self):
|
|
return _("Season '{start_date} - '{end_date}").format(start_date=self.start_date.strftime("%y"), end_date=self.end_date.strftime("%y"))
|
|
|
|
@property
|
|
def is_current(self) -> bool:
|
|
return self.start_date <= timezone.now().date() <= self.end_date
|
|
|
|
@property
|
|
def date_range(self) -> tuple[datetime.date, datetime.date]:
|
|
return self.start_date, self.end_date
|
|
|
|
@classmethod
|
|
def for_date(cls, current_date: datetime.date | None = None, values_only: bool = False) -> "tuple[datetime.date, datetime.date] | Season":
|
|
if current_date is None:
|
|
current_date = timezone.now().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 = 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)
|
|
|
|
match = re.fullmatch(r"(\d+)([ym])", duration.strip().lower())
|
|
if match is None:
|
|
raise ValueError('Duration must be specified as "<number>y" or "<number>m", for example "1y", "1m", or "3m".')
|
|
|
|
duration_value = int(match.group(1))
|
|
duration_unit = match.group(2)
|
|
|
|
if duration_unit == "y":
|
|
end_date = cls._add_months(start_date, duration_value * 12) - datetime.timedelta(days=1)
|
|
elif duration_unit == "m":
|
|
end_date = cls._add_months(start_date, duration_value) - datetime.timedelta(days=1)
|
|
else:
|
|
raise ValueError('Duration unit must be "y" or "m".')
|
|
|
|
return cls.objects.create(start_date=start_date, end_date=end_date)
|
|
|
|
@staticmethod
|
|
def _add_months(date_value: datetime.date, months: int) -> datetime.date:
|
|
month_index = date_value.month - 1 + months
|
|
year = date_value.year + month_index // 12
|
|
month = month_index % 12 + 1
|
|
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}" |