Files
TeamForge/teams/models.py
Bernard Siebens 6c0115d4a2 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>
2026-06-05 08:53:05 +02:00

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}"