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:
0
events/__init__.py
Normal file
0
events/__init__.py
Normal file
48
events/admin.py
Normal file
48
events/admin.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .models import Attendance, Event, Location, Opponent
|
||||
|
||||
|
||||
@admin.register(Opponent)
|
||||
class OpponentAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "club"]
|
||||
list_filter = ["club"]
|
||||
search_fields = ["name"]
|
||||
|
||||
|
||||
@admin.register(Location)
|
||||
class LocationAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "city", "club"]
|
||||
list_filter = ["club"]
|
||||
search_fields = ["name", "city"]
|
||||
|
||||
|
||||
class AttendanceInline(admin.TabularInline):
|
||||
model = Attendance
|
||||
extra = 0
|
||||
raw_id_fields = ["member"]
|
||||
|
||||
|
||||
@admin.register(Event)
|
||||
class EventAdmin(admin.ModelAdmin):
|
||||
list_display = ["title", "kind", "start", "season", "club"]
|
||||
list_filter = ["kind", "club", "teams"]
|
||||
search_fields = ["title"]
|
||||
date_hierarchy = "start"
|
||||
autocomplete_fields = ["season", "location", "opponent", "teams", "invited_members", "excluded_members"]
|
||||
inlines = [AttendanceInline]
|
||||
fieldsets = [
|
||||
[None, {"fields": ["title", "kind", "season"]}],
|
||||
[_("Audience"), {"fields": ["teams", "invited_members", "excluded_members"]}],
|
||||
[_("When"), {"fields": ["start", "end", "gathering", "deadline"]}],
|
||||
[_("Where"), {"fields": ["location", "opponent"]}],
|
||||
]
|
||||
|
||||
|
||||
@admin.register(Attendance)
|
||||
class AttendanceAdmin(admin.ModelAdmin):
|
||||
list_display = ["event", "member", "status"]
|
||||
list_filter = ["status", "event__kind"]
|
||||
search_fields = ["event__title", "member__first_name", "member__last_name"]
|
||||
raw_id_fields = ["event", "member"]
|
||||
8
events/apps.py
Normal file
8
events/apps.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class EventsConfig(AppConfig):
|
||||
name = "events"
|
||||
|
||||
def ready(self):
|
||||
from . import signals # noqa: F401
|
||||
87
events/migrations/0001_initial.py
Normal file
87
events/migrations/0001_initial.py
Normal file
@@ -0,0 +1,87 @@
|
||||
# Generated by Django 6.0.6 on 2026-07-12 15:22
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('club', '0008_alter_clubmembership_unique_together_and_more'),
|
||||
('members', '0002_alter_familymembership_unique_together_and_more'),
|
||||
('teams', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Location',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=255, verbose_name='name')),
|
||||
('address', models.CharField(max_length=255, verbose_name='address')),
|
||||
('city', models.CharField(max_length=255, verbose_name='city')),
|
||||
('zip_code', models.CharField(max_length=255, verbose_name='zip code')),
|
||||
('country', models.CharField(max_length=255, verbose_name='country')),
|
||||
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'location',
|
||||
'verbose_name_plural': 'locations',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Opponent',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=255, verbose_name='name')),
|
||||
('logo', models.ImageField(blank=True, upload_to='opponents', verbose_name='logo')),
|
||||
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'opponent',
|
||||
'verbose_name_plural': 'opponents',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Event',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('kind', models.CharField(choices=[('training', 'training'), ('match', 'match'), ('tournament', 'tournament'), ('meeting', 'meeting'), ('social', 'social'), ('other', 'other')], default='other', max_length=10, verbose_name='kind')),
|
||||
('title', models.CharField(max_length=255, verbose_name='title')),
|
||||
('start', models.DateTimeField(verbose_name='start')),
|
||||
('end', models.DateTimeField(blank=True, null=True, verbose_name='end')),
|
||||
('gathering', models.DateTimeField(blank=True, null=True, verbose_name='gathering')),
|
||||
('deadline', models.DateTimeField(blank=True, null=True, verbose_name='deadline')),
|
||||
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
|
||||
('team', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='events', to='teams.team', verbose_name='team')),
|
||||
('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='events', to='events.location', verbose_name='location')),
|
||||
('opponent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='events', to='events.opponent', verbose_name='opponent')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'event',
|
||||
'verbose_name_plural': 'events',
|
||||
'ordering': ['-start'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Attendance',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('status', models.CharField(choices=[('present', 'present'), ('absent', 'absent'), ('excused', 'excused'), ('selected', 'selected'), ('not_selected', 'not selected'), ('maybe', 'maybe'), ('no_response', 'no response')], default='no_response', max_length=20, verbose_name='status')),
|
||||
('note', models.TextField(blank=True, verbose_name='note')),
|
||||
('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attendances', to='members.member', verbose_name='member')),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attendances', to='events.event', verbose_name='event')),
|
||||
],
|
||||
options={
|
||||
'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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
37
events/migrations/0002_add_event_audience.py
Normal file
37
events/migrations/0002_add_event_audience.py
Normal file
@@ -0,0 +1,37 @@
|
||||
# Generated by Django 6.0.6 on 2026-07-12 15:38
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('club', '0008_alter_clubmembership_unique_together_and_more'),
|
||||
('events', '0001_initial'),
|
||||
('members', '0002_alter_familymembership_unique_together_and_more'),
|
||||
('teams', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='excluded_members',
|
||||
field=models.ManyToManyField(blank=True, related_name='excluded_from_events', to='members.member', verbose_name='excluded members'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='invited_members',
|
||||
field=models.ManyToManyField(blank=True, related_name='invited_to_events', to='members.member', verbose_name='invited members'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='season',
|
||||
field=models.ForeignKey(blank=True, help_text='Season whose team rosters define the audience; derived from the start date when left blank.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='events', to='club.season', verbose_name='season'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='teams',
|
||||
field=models.ManyToManyField(blank=True, related_name='scheduled_events', to='teams.team', verbose_name='teams'),
|
||||
),
|
||||
]
|
||||
29
events/migrations/0003_copy_team_to_teams.py
Normal file
29
events/migrations/0003_copy_team_to_teams.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 6.0.6 on 2026-07-12 15:38
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def copy_team_to_teams(apps, schema_editor):
|
||||
Event = apps.get_model("events", "Event")
|
||||
for event in Event.objects.exclude(team__isnull=True).iterator():
|
||||
event.teams.add(event.team_id)
|
||||
|
||||
|
||||
def copy_teams_to_team(apps, schema_editor):
|
||||
Event = apps.get_model("events", "Event")
|
||||
for event in Event.objects.iterator():
|
||||
first_team = event.teams.first()
|
||||
if first_team is not None:
|
||||
event.team = first_team
|
||||
event.save(update_fields=["team"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('events', '0002_add_event_audience'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(copy_team_to_teams, copy_teams_to_team),
|
||||
]
|
||||
17
events/migrations/0004_remove_event_team.py
Normal file
17
events/migrations/0004_remove_event_team.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 6.0.6 on 2026-07-12 15:38
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('events', '0003_copy_team_to_teams'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='event',
|
||||
name='team',
|
||||
),
|
||||
]
|
||||
0
events/migrations/__init__.py
Normal file
0
events/migrations/__init__.py
Normal file
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}"
|
||||
3
events/services/__init__.py
Normal file
3
events/services/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .attendance import effective_members, sync_event_attendances
|
||||
|
||||
__all__ = ["effective_members", "sync_event_attendances"]
|
||||
59
events/services/attendance.py
Normal file
59
events/services/attendance.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Keep an event's attendance rows in sync with its effective audience.
|
||||
|
||||
The audience of an event is the union of the current rosters of its ``teams``
|
||||
(for the event's season) plus any individually ``invited_members``, minus any
|
||||
``excluded_members``. Attendance rows are reconciled against that set, but only
|
||||
for events that are still in the future — history is never rewritten.
|
||||
"""
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from club.models import Season
|
||||
from events.models import Attendance
|
||||
from members.models import Member
|
||||
from teams.models import TeamMembership
|
||||
|
||||
|
||||
def resolve_season(event):
|
||||
"""The season whose rosters define the event's audience."""
|
||||
if event.season_id is not None:
|
||||
return event.season
|
||||
return Season.covering(event.club, event.start.date())
|
||||
|
||||
|
||||
def effective_members(event):
|
||||
"""Return the ``Member`` queryset invited to ``event``."""
|
||||
member_ids: set = set()
|
||||
|
||||
season = resolve_season(event)
|
||||
if season is not None:
|
||||
team_ids = list(event.teams.values_list("id", flat=True))
|
||||
if team_ids:
|
||||
member_ids.update(TeamMembership.objects.filter(team_id__in=team_ids, season=season).values_list("member_id", flat=True))
|
||||
|
||||
member_ids.update(event.invited_members.values_list("id", flat=True))
|
||||
member_ids.difference_update(event.excluded_members.values_list("id", flat=True))
|
||||
|
||||
return Member.objects.filter(id__in=member_ids)
|
||||
|
||||
|
||||
def sync_event_attendances(event):
|
||||
"""Reconcile ``event``'s attendance rows with its effective audience.
|
||||
|
||||
No-op for events that have already started. New members get a
|
||||
``NO_RESPONSE`` row; members no longer invited have their row removed
|
||||
outright (hard reconcile).
|
||||
"""
|
||||
if event.start < timezone.now():
|
||||
return
|
||||
|
||||
desired_ids = set(effective_members(event).values_list("id", flat=True))
|
||||
existing_ids = set(event.attendances.values_list("member_id", flat=True))
|
||||
|
||||
to_add = desired_ids - existing_ids
|
||||
if to_add:
|
||||
Attendance.objects.bulk_create([Attendance(event=event, member_id=member_id) for member_id in to_add])
|
||||
|
||||
to_remove = existing_ids - desired_ids
|
||||
if to_remove:
|
||||
event.attendances.filter(member_id__in=to_remove).delete()
|
||||
47
events/signals.py
Normal file
47
events/signals.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Signal wiring that keeps attendance in sync with the event audience.
|
||||
|
||||
Registered from ``EventsConfig.ready``. Two triggers:
|
||||
|
||||
* editing an event (its ``start``, or its ``teams`` / ``invited_members`` /
|
||||
``excluded_members``) re-syncs that event;
|
||||
* adding or removing a member from a team roster re-syncs that team's future
|
||||
events.
|
||||
"""
|
||||
|
||||
from django.db.models.signals import m2m_changed, post_delete, post_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
|
||||
from events.models import Event
|
||||
from events.services import sync_event_attendances
|
||||
from teams.models import TeamMembership
|
||||
|
||||
M2M_SYNC_ACTIONS = {"post_add", "post_remove", "post_clear"}
|
||||
|
||||
|
||||
@receiver(post_save, sender=Event)
|
||||
def sync_on_event_save(sender, instance, **kwargs):
|
||||
sync_event_attendances(instance)
|
||||
|
||||
|
||||
@receiver(m2m_changed, sender=Event.teams.through)
|
||||
@receiver(m2m_changed, sender=Event.invited_members.through)
|
||||
@receiver(m2m_changed, sender=Event.excluded_members.through)
|
||||
def sync_on_audience_change(sender, instance, action, reverse, model, pk_set, **kwargs):
|
||||
if action not in M2M_SYNC_ACTIONS:
|
||||
return
|
||||
|
||||
if isinstance(instance, Event):
|
||||
sync_event_attendances(instance)
|
||||
else:
|
||||
# Reverse relation edited (e.g. member.invited_to_events.add(event)).
|
||||
for event in Event.objects.filter(pk__in=pk_set or []):
|
||||
sync_event_attendances(event)
|
||||
|
||||
|
||||
@receiver(post_save, sender=TeamMembership)
|
||||
@receiver(post_delete, sender=TeamMembership)
|
||||
def sync_on_roster_change(sender, instance, **kwargs):
|
||||
now = timezone.now()
|
||||
for event in Event.objects.filter(teams=instance.team_id, start__gte=now).distinct():
|
||||
sync_event_attendances(event)
|
||||
161
events/tests.py
Normal file
161
events/tests.py
Normal file
@@ -0,0 +1,161 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db import IntegrityError
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from club.models import Club, Season
|
||||
from members.models import Member
|
||||
from teams.models import Position, Team, TeamMembership
|
||||
|
||||
from .models import Attendance, Event, Location, Opponent
|
||||
from .services import effective_members
|
||||
|
||||
|
||||
class EventsTestBase(TestCase):
|
||||
def setUp(self):
|
||||
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||
today = timezone.localdate()
|
||||
self.season = Season.objects.create(
|
||||
club=self.club,
|
||||
start_date=today - timedelta(days=30),
|
||||
end_date=today + timedelta(days=300),
|
||||
)
|
||||
self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st")
|
||||
self.position = Position.objects.create(club=self.club, name="Forward", short_name="FW")
|
||||
self.alice = Member.objects.create(first_name="Alice", last_name="Ash")
|
||||
self.bob = Member.objects.create(first_name="Bob", last_name="Birch")
|
||||
TeamMembership.objects.create(team=self.team, member=self.alice, season=self.season, position=self.position)
|
||||
TeamMembership.objects.create(team=self.team, member=self.bob, season=self.season, position=self.position)
|
||||
self.future = timezone.now() + timedelta(days=7)
|
||||
|
||||
def make_event(self, **kwargs):
|
||||
kwargs.setdefault("club", self.club)
|
||||
kwargs.setdefault("title", "Training")
|
||||
kwargs.setdefault("start", self.future)
|
||||
if "season" not in kwargs:
|
||||
kwargs["season"] = self.season
|
||||
return Event.objects.create(**kwargs)
|
||||
|
||||
def attendee_ids(self, event):
|
||||
return set(event.attendances.values_list("member_id", flat=True))
|
||||
|
||||
|
||||
class EventModelTests(EventsTestBase):
|
||||
def test_str_methods(self):
|
||||
opponent = Opponent.objects.create(club=self.club, name="Rivals FC")
|
||||
location = Location.objects.create(club=self.club, name="Arena", address="1 St", city="Town", zip_code="1000", country="BE")
|
||||
event = self.make_event(title="Big Match")
|
||||
attendance = Attendance.objects.create(event=event, member=self.alice)
|
||||
|
||||
self.assertEqual(str(opponent), "Rivals FC")
|
||||
self.assertEqual(str(location), "Arena")
|
||||
self.assertEqual(str(event), "Big Match")
|
||||
self.assertEqual(str(attendance), "Big Match - Alice Ash")
|
||||
|
||||
def test_attendance_is_unique_per_event_and_member(self):
|
||||
event = self.make_event()
|
||||
Attendance.objects.create(event=event, member=self.alice)
|
||||
|
||||
with self.assertRaises(IntegrityError):
|
||||
Attendance.objects.create(event=event, member=self.alice)
|
||||
|
||||
|
||||
class EffectiveMembersTests(EventsTestBase):
|
||||
def test_union_of_team_invited_minus_excluded(self):
|
||||
carol = Member.objects.create(first_name="Carol", last_name="Cedar")
|
||||
event = self.make_event()
|
||||
event.teams.set([self.team])
|
||||
event.invited_members.set([carol])
|
||||
event.excluded_members.set([self.bob])
|
||||
|
||||
self.assertEqual(
|
||||
set(effective_members(event).values_list("id", flat=True)),
|
||||
{self.alice.id, carol.id},
|
||||
)
|
||||
|
||||
def test_season_is_derived_from_start_date(self):
|
||||
event = self.make_event(season=None)
|
||||
event.teams.set([self.team])
|
||||
|
||||
self.assertEqual(self.attendee_ids(event), {self.alice.id, self.bob.id})
|
||||
|
||||
def test_no_covering_season_yields_no_team_members(self):
|
||||
event = self.make_event(season=None, start=timezone.now() + timedelta(days=5000))
|
||||
event.teams.set([self.team])
|
||||
|
||||
self.assertEqual(event.attendances.count(), 0)
|
||||
|
||||
|
||||
class AttendanceSyncTests(EventsTestBase):
|
||||
def test_setting_teams_creates_attendance_for_roster(self):
|
||||
event = self.make_event()
|
||||
event.teams.set([self.team])
|
||||
|
||||
self.assertEqual(self.attendee_ids(event), {self.alice.id, self.bob.id})
|
||||
|
||||
def test_invited_member_gets_attendance(self):
|
||||
carol = Member.objects.create(first_name="Carol", last_name="Cedar")
|
||||
event = self.make_event()
|
||||
event.invited_members.set([carol])
|
||||
|
||||
self.assertEqual(self.attendee_ids(event), {carol.id})
|
||||
|
||||
def test_reverse_invited_relation_syncs(self):
|
||||
carol = Member.objects.create(first_name="Carol", last_name="Cedar")
|
||||
event = self.make_event()
|
||||
carol.invited_to_events.add(event)
|
||||
|
||||
self.assertIn(carol.id, self.attendee_ids(event))
|
||||
|
||||
def test_excluding_member_removes_attendance(self):
|
||||
event = self.make_event()
|
||||
event.teams.set([self.team])
|
||||
event.excluded_members.set([self.alice])
|
||||
|
||||
self.assertEqual(self.attendee_ids(event), {self.bob.id})
|
||||
|
||||
def test_hard_reconcile_removes_even_responded_rows(self):
|
||||
event = self.make_event()
|
||||
event.teams.set([self.team])
|
||||
attendance = event.attendances.get(member=self.alice)
|
||||
attendance.status = Attendance.AttendanceStatus.PRESENT
|
||||
attendance.save()
|
||||
|
||||
event.excluded_members.set([self.alice])
|
||||
|
||||
self.assertFalse(event.attendances.filter(member=self.alice).exists())
|
||||
|
||||
def test_past_event_is_not_synced(self):
|
||||
event = self.make_event(start=timezone.now() - timedelta(days=1))
|
||||
event.teams.set([self.team])
|
||||
|
||||
self.assertEqual(event.attendances.count(), 0)
|
||||
|
||||
|
||||
class RosterChangeSyncTests(EventsTestBase):
|
||||
def test_adding_roster_member_syncs_future_events(self):
|
||||
event = self.make_event()
|
||||
event.teams.set([self.team])
|
||||
dave = Member.objects.create(first_name="Dave", last_name="Dogwood")
|
||||
|
||||
TeamMembership.objects.create(team=self.team, member=dave, season=self.season, position=self.position)
|
||||
|
||||
self.assertIn(dave.id, self.attendee_ids(event))
|
||||
|
||||
def test_removing_roster_member_syncs_future_events(self):
|
||||
event = self.make_event()
|
||||
event.teams.set([self.team])
|
||||
|
||||
TeamMembership.objects.get(team=self.team, member=self.alice).delete()
|
||||
|
||||
self.assertNotIn(self.alice.id, self.attendee_ids(event))
|
||||
|
||||
def test_roster_change_leaves_past_events_untouched(self):
|
||||
past = self.make_event(start=timezone.now() - timedelta(days=1))
|
||||
past.teams.add(self.team)
|
||||
Attendance.objects.create(event=past, member=self.alice)
|
||||
|
||||
TeamMembership.objects.get(team=self.team, member=self.alice).delete()
|
||||
|
||||
self.assertTrue(past.attendances.filter(member=self.alice).exists())
|
||||
1
events/views.py
Normal file
1
events/views.py
Normal file
@@ -0,0 +1 @@
|
||||
# Create your views here.
|
||||
Reference in New Issue
Block a user