diff --git a/club/models.py b/club/models.py index dca3e0e..8b02009 100644 --- a/club/models.py +++ b/club/models.py @@ -74,6 +74,11 @@ class Season(ClubScopedModel): return cls.objects.current_club().filter(start_date__lte=date, end_date__gte=date).first() + @classmethod + def covering(cls, club, date: datetime.date): + """Return ``club``'s season covering ``date`` (no tenant context needed).""" + return cls.objects.filter(club=club, start_date__lte=date, end_date__gte=date).first() + class ClubMembership(ClubScopedModel): class StatusChoices(models.TextChoices): diff --git a/club/tests.py b/club/tests.py index 3f64bcf..6c699e4 100644 --- a/club/tests.py +++ b/club/tests.py @@ -455,7 +455,7 @@ class AdminRegistrationSmokeTests(TestCase): registered = set(django_admin.site._registry) # Concrete, non-auto-created models in these apps should all be registered. - project_apps = {"authentication", "club", "members", "teams"} + project_apps = {"authentication", "club", "members", "teams", "events"} for model in apps.get_models(): if model._meta.app_label not in project_apps or model._meta.auto_created: continue diff --git a/clubmanager/settings.py b/clubmanager/settings.py index b345015..a547daa 100644 --- a/clubmanager/settings.py +++ b/clubmanager/settings.py @@ -48,6 +48,7 @@ INSTALLED_APPS = [ "authentication.apps.AuthenticationConfig", "members.apps.MembersConfig", "teams.apps.TeamsConfig", + "events.apps.EventsConfig", ] MIDDLEWARE = [ diff --git a/events/__init__.py b/events/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/events/admin.py b/events/admin.py new file mode 100644 index 0000000..1d96b00 --- /dev/null +++ b/events/admin.py @@ -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"] diff --git a/events/apps.py b/events/apps.py new file mode 100644 index 0000000..01eac98 --- /dev/null +++ b/events/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class EventsConfig(AppConfig): + name = "events" + + def ready(self): + from . import signals # noqa: F401 diff --git a/events/migrations/0001_initial.py b/events/migrations/0001_initial.py new file mode 100644 index 0000000..5cf0a2f --- /dev/null +++ b/events/migrations/0001_initial.py @@ -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')], + }, + ), + ] diff --git a/events/migrations/0002_add_event_audience.py b/events/migrations/0002_add_event_audience.py new file mode 100644 index 0000000..af3f9f4 --- /dev/null +++ b/events/migrations/0002_add_event_audience.py @@ -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'), + ), + ] diff --git a/events/migrations/0003_copy_team_to_teams.py b/events/migrations/0003_copy_team_to_teams.py new file mode 100644 index 0000000..0513bec --- /dev/null +++ b/events/migrations/0003_copy_team_to_teams.py @@ -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), + ] diff --git a/events/migrations/0004_remove_event_team.py b/events/migrations/0004_remove_event_team.py new file mode 100644 index 0000000..402fa3a --- /dev/null +++ b/events/migrations/0004_remove_event_team.py @@ -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', + ), + ] diff --git a/events/migrations/__init__.py b/events/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/events/models.py b/events/models.py new file mode 100644 index 0000000..b0038a2 --- /dev/null +++ b/events/models.py @@ -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}" diff --git a/events/services/__init__.py b/events/services/__init__.py new file mode 100644 index 0000000..fa6cb40 --- /dev/null +++ b/events/services/__init__.py @@ -0,0 +1,3 @@ +from .attendance import effective_members, sync_event_attendances + +__all__ = ["effective_members", "sync_event_attendances"] diff --git a/events/services/attendance.py b/events/services/attendance.py new file mode 100644 index 0000000..004b461 --- /dev/null +++ b/events/services/attendance.py @@ -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() diff --git a/events/signals.py b/events/signals.py new file mode 100644 index 0000000..86a3293 --- /dev/null +++ b/events/signals.py @@ -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) diff --git a/events/tests.py b/events/tests.py new file mode 100644 index 0000000..7f36a1d --- /dev/null +++ b/events/tests.py @@ -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()) diff --git a/events/views.py b/events/views.py new file mode 100644 index 0000000..60f00ef --- /dev/null +++ b/events/views.py @@ -0,0 +1 @@ +# Create your views here. diff --git a/pyproject.toml b/pyproject.toml index d65565b..535339d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ dependencies = [ "dj-database-url>=3.1.2", "django>=6.0.6", "django-phonenumber-field[phonenumbers]>=8.4.0", + "pillow>=12.3.0", "python-decouple>=3.8", ] diff --git a/uv.lock b/uv.lock index f174501..684173e 100644 --- a/uv.lock +++ b/uv.lock @@ -19,6 +19,7 @@ dependencies = [ { name = "dj-database-url" }, { name = "django" }, { name = "django-phonenumber-field", extra = ["phonenumbers"] }, + { name = "pillow" }, { name = "python-decouple" }, ] @@ -33,6 +34,7 @@ requires-dist = [ { name = "dj-database-url", specifier = ">=3.1.2" }, { name = "django", specifier = ">=6.0.6" }, { name = "django-phonenumber-field", extras = ["phonenumbers"], specifier = ">=8.4.0" }, + { name = "pillow", specifier = ">=12.3.0" }, { name = "python-decouple", specifier = ">=3.8" }, ] @@ -133,6 +135,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/29/f7e30e3dbd3c7e3d9c4a55006112c04ee62b4765a31f21bcc28c253ac3f1/phonenumbers-9.0.33-py2.py3-none-any.whl", hash = "sha256:ba1d0da52711d5fdda6b2b673b2621fe80774fc5d1b2e5a6ef783396b0343186", size = 2595422, upload-time = "2026-06-22T10:23:29.925Z" }, ] +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, +] + [[package]] name = "python-decouple" version = "3.8"