feat(events): team/invited audience with attendance sync
Rework an event's audience: replace the single team FK with a teams M2M plus invited_members and excluded_members, and add a season FK (derived from the start date when blank) so team rosters resolve correctly. A data migration copies existing team -> teams. Add an attendance sync service: the effective audience is the union of the teams' rosters for the event's season plus invited, minus excluded; sync_event_attendances reconciles Attendance rows for future events only, adding NO_RESPONSE rows for new members and hard-deleting rows for members no longer invited. Signals drive it: editing an event or its audience re-syncs that event, and adding/removing a team-roster member re-syncs that team's future events. Register all events models in the admin (with an attendance inline) and add events to the admin registration smoke test. Add Season.covering() and pillow (Opponent.logo ImageField). Full suite at 100% coverage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
97
events/models.py
Normal file
97
events/models.py
Normal file
@@ -0,0 +1,97 @@
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from club.models import Season
|
||||
from clubmanager.base import ClubScopedModel, UUIDModel
|
||||
from members.models import Member
|
||||
from teams.models import Team
|
||||
|
||||
|
||||
class Opponent(ClubScopedModel):
|
||||
name = models.CharField(_("name"), max_length=255)
|
||||
logo = models.ImageField(_("logo"), upload_to="opponents", blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("opponent")
|
||||
verbose_name_plural = _("opponents")
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Location(ClubScopedModel):
|
||||
name = models.CharField(_("name"), max_length=255)
|
||||
address = models.CharField(_("address"), max_length=255)
|
||||
city = models.CharField(_("city"), max_length=255)
|
||||
zip_code = models.CharField(_("zip code"), max_length=255)
|
||||
country = models.CharField(_("country"), max_length=255)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("location")
|
||||
verbose_name_plural = _("locations")
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Event(ClubScopedModel):
|
||||
class EventKind(models.TextChoices):
|
||||
TRAINING = "training", _("training")
|
||||
MATCH = "match", _("match")
|
||||
TOURNAMENT = "tournament", _("tournament")
|
||||
MEETING = "meeting", _("meeting")
|
||||
SOCIAL = "social", _("social")
|
||||
OTHER = "other", _("other")
|
||||
|
||||
teams = models.ManyToManyField(Team, related_name="scheduled_events", blank=True, verbose_name=_("teams"))
|
||||
invited_members = models.ManyToManyField(Member, related_name="invited_to_events", blank=True, verbose_name=_("invited members"))
|
||||
excluded_members = models.ManyToManyField(Member, related_name="excluded_from_events", blank=True, verbose_name=_("excluded members"))
|
||||
season = models.ForeignKey(Season, on_delete=models.SET_NULL, related_name="events", null=True, blank=True, verbose_name=_("season"), help_text=_("Season whose team rosters define the audience; derived from the start date when left blank."))
|
||||
|
||||
kind = models.CharField(_("kind"), max_length=10, choices=EventKind.choices, default=EventKind.OTHER)
|
||||
title = models.CharField(_("title"), max_length=255)
|
||||
|
||||
start = models.DateTimeField(_("start"))
|
||||
end = models.DateTimeField(_("end"), blank=True, null=True)
|
||||
gathering = models.DateTimeField(_("gathering"), blank=True, null=True)
|
||||
deadline = models.DateTimeField(_("deadline"), blank=True, null=True)
|
||||
|
||||
location = models.ForeignKey(Location, on_delete=models.SET_NULL, related_name="events", null=True, blank=True, verbose_name=_("location"))
|
||||
opponent = models.ForeignKey(Opponent, on_delete=models.SET_NULL, related_name="events", null=True, blank=True, verbose_name=_("opponent"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("event")
|
||||
verbose_name_plural = _("events")
|
||||
ordering = ["-start"]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class Attendance(UUIDModel):
|
||||
class AttendanceStatus(models.TextChoices):
|
||||
PRESENT = "present", _("present")
|
||||
ABSENT = "absent", _("absent")
|
||||
EXCUSED = "excused", _("excused")
|
||||
SELECTED = "selected", _("selected")
|
||||
NOT_SELECTED = "not_selected", _("not selected")
|
||||
MAYBE = "maybe", _("maybe")
|
||||
NO_RESPONSE = "no_response", _("no response")
|
||||
|
||||
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="attendances", verbose_name=_("event"))
|
||||
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="attendances", verbose_name=_("member"))
|
||||
status = models.CharField(_("status"), max_length=20, choices=AttendanceStatus.choices, default=AttendanceStatus.NO_RESPONSE)
|
||||
note = models.TextField(_("note"), blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("attendance")
|
||||
verbose_name_plural = _("attendances")
|
||||
ordering = ["event", "member__last_name", "member__first_name"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["event", "member"], name="unique_attendance_per_event_per_member"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.event} - {self.member}"
|
||||
Reference in New Issue
Block a user