diff --git a/events/admin.py b/events/admin.py index 1d96b00..1f6bfb5 100644 --- a/events/admin.py +++ b/events/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin from django.utils.translation import gettext_lazy as _ -from .models import Attendance, Event, Location, Opponent +from .models import Attendance, Event, EventSeries, Location, Opponent @admin.register(Opponent) @@ -24,16 +24,31 @@ class AttendanceInline(admin.TabularInline): raw_id_fields = ["member"] +@admin.register(EventSeries) +class EventSeriesAdmin(admin.ModelAdmin): + list_display = ["title", "kind", "rrule", "dtstart", "generated_until", "club"] + list_filter = ["kind", "club"] + search_fields = ["title"] + autocomplete_fields = ["location", "opponent", "teams", "invited_members", "excluded_members"] + fieldsets = [ + [None, {"fields": ["title", "kind"]}], + [_("Recurrence"), {"fields": ["rrule", "dtstart", "duration", "excluded_dates", "generated_until"]}], + [_("Audience"), {"fields": ["teams", "invited_members", "excluded_members"]}], + [_("Where"), {"fields": ["location", "opponent"]}], + ] + + @admin.register(Event) class EventAdmin(admin.ModelAdmin): - list_display = ["title", "kind", "start", "season", "club"] - list_filter = ["kind", "club", "teams"] + list_display = ["title", "kind", "start", "season", "series", "detached", "club"] + list_filter = ["kind", "club", "teams", "detached", "cancelled"] search_fields = ["title"] date_hierarchy = "start" - autocomplete_fields = ["season", "location", "opponent", "teams", "invited_members", "excluded_members"] + autocomplete_fields = ["season", "series", "location", "opponent", "teams", "invited_members", "excluded_members"] inlines = [AttendanceInline] fieldsets = [ [None, {"fields": ["title", "kind", "season"]}], + [_("Series"), {"fields": ["series", "detached", "cancelled"]}], [_("Audience"), {"fields": ["teams", "invited_members", "excluded_members"]}], [_("When"), {"fields": ["start", "end", "gathering", "deadline"]}], [_("Where"), {"fields": ["location", "opponent"]}], diff --git a/events/management/__init__.py b/events/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/events/management/commands/__init__.py b/events/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/events/management/commands/extend_event_series.py b/events/management/commands/extend_event_series.py new file mode 100644 index 0000000..bb33cbc --- /dev/null +++ b/events/management/commands/extend_event_series.py @@ -0,0 +1,20 @@ +from django.core.management.base import BaseCommand + +from events.models import EventSeries +from events.services import generate_occurrences, horizon + + +class Command(BaseCommand): + help = "Materialise recurring event occurrences up to the rolling horizon." + + def handle(self, *args, **options): + until = horizon() + total = 0 + + for series in EventSeries.objects.all(): + created = generate_occurrences(series, until) + total += len(created) + if created: + self.stdout.write(f"{series}: generated {len(created)} occurrence(s).") + + self.stdout.write(self.style.SUCCESS(f"Done. Generated {total} occurrence(s) across {EventSeries.objects.count()} series.")) diff --git a/events/migrations/0005_event_series.py b/events/migrations/0005_event_series.py new file mode 100644 index 0000000..5671fe7 --- /dev/null +++ b/events/migrations/0005_event_series.py @@ -0,0 +1,57 @@ +# Generated by Django 6.0.6 on 2026-07-12 15:47 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('club', '0008_alter_clubmembership_unique_together_and_more'), + ('events', '0004_remove_event_team'), + ('members', '0002_alter_familymembership_unique_together_and_more'), + ('teams', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='cancelled', + field=models.BooleanField(default=False, verbose_name='cancelled'), + ), + migrations.AddField( + model_name='event', + name='detached', + field=models.BooleanField(default=False, help_text='Edited independently; excluded from series-wide updates and regeneration.', verbose_name='detached'), + ), + migrations.CreateModel( + name='EventSeries', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('rrule', models.CharField(help_text='RFC 5545 RRULE, e.g. FREQ=WEEKLY;BYDAY=MO,WE.', max_length=255, verbose_name='recurrence rule')), + ('dtstart', models.DateTimeField(verbose_name='first occurrence')), + ('duration', models.DurationField(blank=True, help_text="Length of each occurrence; sets each event's end.", null=True, verbose_name='duration')), + ('excluded_dates', models.JSONField(blank=True, default=list, help_text='ISO start datetimes of occurrences removed from the series (EXDATEs).', verbose_name='excluded dates')), + ('generated_until', models.DateTimeField(blank=True, help_text='Occurrences have been materialised up to this point.', null=True, verbose_name='generated until')), + ('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')), + ('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')), + ('excluded_members', models.ManyToManyField(blank=True, related_name='excluded_from_event_series', to='members.member', verbose_name='excluded members')), + ('invited_members', models.ManyToManyField(blank=True, related_name='invited_to_event_series', to='members.member', verbose_name='invited members')), + ('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='event_series', to='events.location', verbose_name='location')), + ('opponent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='event_series', to='events.opponent', verbose_name='opponent')), + ('teams', models.ManyToManyField(blank=True, related_name='event_series', to='teams.team', verbose_name='teams')), + ], + options={ + 'verbose_name': 'event series', + 'verbose_name_plural': 'event series', + 'ordering': ['title'], + }, + ), + migrations.AddField( + model_name='event', + name='series', + field=models.ForeignKey(blank=True, help_text='The recurring series this occurrence belongs to; blank for one-off events.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='occurrences', to='events.eventseries', verbose_name='series'), + ), + ] diff --git a/events/models.py b/events/models.py index b0038a2..bce13bb 100644 --- a/events/models.py +++ b/events/models.py @@ -45,6 +45,10 @@ class Event(ClubScopedModel): SOCIAL = "social", _("social") OTHER = "other", _("other") + series = models.ForeignKey("EventSeries", on_delete=models.CASCADE, related_name="occurrences", null=True, blank=True, verbose_name=_("series"), help_text=_("The recurring series this occurrence belongs to; blank for one-off events.")) + detached = models.BooleanField(_("detached"), default=False, help_text=_("Edited independently; excluded from series-wide updates and regeneration.")) + cancelled = models.BooleanField(_("cancelled"), default=False) + 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")) @@ -70,6 +74,33 @@ class Event(ClubScopedModel): return self.title +class EventSeries(ClubScopedModel): + """A recurring event definition that materialises concrete Event rows.""" + + rrule = models.CharField(_("recurrence rule"), max_length=255, help_text=_("RFC 5545 RRULE, e.g. FREQ=WEEKLY;BYDAY=MO,WE.")) + dtstart = models.DateTimeField(_("first occurrence")) + duration = models.DurationField(_("duration"), null=True, blank=True, help_text=_("Length of each occurrence; sets each event's end.")) + excluded_dates = models.JSONField(_("excluded dates"), default=list, blank=True, help_text=_("ISO start datetimes of occurrences removed from the series (EXDATEs).")) + generated_until = models.DateTimeField(_("generated until"), null=True, blank=True, help_text=_("Occurrences have been materialised up to this point.")) + + # Template copied onto each generated occurrence. + kind = models.CharField(_("kind"), max_length=10, choices=Event.EventKind.choices, default=Event.EventKind.OTHER) + title = models.CharField(_("title"), max_length=255) + location = models.ForeignKey(Location, on_delete=models.SET_NULL, related_name="event_series", null=True, blank=True, verbose_name=_("location")) + opponent = models.ForeignKey(Opponent, on_delete=models.SET_NULL, related_name="event_series", null=True, blank=True, verbose_name=_("opponent")) + teams = models.ManyToManyField(Team, related_name="event_series", blank=True, verbose_name=_("teams")) + invited_members = models.ManyToManyField(Member, related_name="invited_to_event_series", blank=True, verbose_name=_("invited members")) + excluded_members = models.ManyToManyField(Member, related_name="excluded_from_event_series", blank=True, verbose_name=_("excluded members")) + + class Meta: + verbose_name = _("event series") + verbose_name_plural = _("event series") + ordering = ["title"] + + def __str__(self): + return self.title + + class Attendance(UUIDModel): class AttendanceStatus(models.TextChoices): PRESENT = "present", _("present") diff --git a/events/services/__init__.py b/events/services/__init__.py index fa6cb40..8a07cde 100644 --- a/events/services/__init__.py +++ b/events/services/__init__.py @@ -1,3 +1,22 @@ from .attendance import effective_members, sync_event_attendances +from .recurrence import ( + apply_template, + cancel_occurrence, + detach_occurrence, + generate_occurrences, + horizon, + occurrence_datetimes, + propagate_series, +) -__all__ = ["effective_members", "sync_event_attendances"] +__all__ = [ + "apply_template", + "cancel_occurrence", + "detach_occurrence", + "effective_members", + "generate_occurrences", + "horizon", + "occurrence_datetimes", + "propagate_series", + "sync_event_attendances", +] diff --git a/events/services/recurrence.py b/events/services/recurrence.py new file mode 100644 index 0000000..4efc50e --- /dev/null +++ b/events/services/recurrence.py @@ -0,0 +1,104 @@ +"""Materialise concrete Event rows from a recurring EventSeries. + +Occurrences are real Event rows (so attendance attaches directly). The series +holds the RRULE, an anchor ``dtstart``, and a set of ``excluded_dates`` +(EXDATEs). Rows are generated up to a rolling horizon; a single occurrence can +be cancelled (adds an EXDATE + removes the row) or detached (edited +independently, so series-wide updates skip it). +""" + +from datetime import timedelta + +from dateutil.rrule import rrulestr +from django.utils import timezone + +from events.models import Event + +HORIZON_DAYS = 90 + + +def horizon(): + return timezone.now() + timedelta(days=HORIZON_DAYS) + + +def occurrence_datetimes(series, until): + """Expand the series' RRULE from its anchor up to ``until``, minus EXDATEs.""" + rule = rrulestr(series.rrule, dtstart=series.dtstart) + excluded = set(series.excluded_dates) + + result = [] + for occurrence in rule: + if occurrence > until: + break + if occurrence.isoformat() in excluded: + continue + result.append(occurrence) + return result + + +def apply_template(series, event): + """Copy the series template (and audience) onto ``event`` and save it. + + Setting the audience M2M fires the sync signal, so attendance follows. + """ + event.kind = series.kind + event.title = series.title + event.location = series.location + event.opponent = series.opponent + if series.duration is not None: + event.end = event.start + series.duration + event.save() + + event.teams.set(series.teams.all()) + event.invited_members.set(series.invited_members.all()) + event.excluded_members.set(series.excluded_members.all()) + + +def generate_occurrences(series, until=None): + """Materialise any missing occurrences up to ``until`` (default: horizon).""" + until = until or horizon() + existing = set(series.occurrences.values_list("start", flat=True)) + + created = [] + for start in occurrence_datetimes(series, until): + if start in existing: + continue + event = Event(club=series.club, series=series, start=start) + apply_template(series, event) + created.append(event) + + series.generated_until = until + series.save(update_fields=["generated_until"]) + return created + + +def cancel_occurrence(event, *, hard_delete=True): + """Drop a single occurrence and record an EXDATE so it isn't regenerated.""" + series = event.series + if series is not None: + iso = event.start.isoformat() + if iso not in series.excluded_dates: + series.excluded_dates = [*series.excluded_dates, iso] + series.save(update_fields=["excluded_dates"]) + + if hard_delete: + event.delete() + else: + event.cancelled = True + event.save(update_fields=["cancelled"]) + + +def detach_occurrence(event): + """Mark an occurrence as edited independently of the series.""" + event.detached = True + event.save(update_fields=["detached"]) + + +def propagate_series(series): + """Re-apply the series template to its non-detached future occurrences.""" + now = timezone.now() + updated = [] + for event in series.occurrences.filter(detached=False, start__gte=now): + apply_template(series, event) + updated.append(event) + return updated diff --git a/events/tests.py b/events/tests.py index 7f36a1d..f06071f 100644 --- a/events/tests.py +++ b/events/tests.py @@ -1,5 +1,7 @@ from datetime import timedelta +from io import StringIO +from django.core.management import call_command from django.db import IntegrityError from django.test import TestCase from django.utils import timezone @@ -8,8 +10,15 @@ 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 +from .models import Attendance, Event, EventSeries, Location, Opponent +from .services import ( + cancel_occurrence, + detach_occurrence, + effective_members, + generate_occurrences, + occurrence_datetimes, + propagate_series, +) class EventsTestBase(TestCase): @@ -159,3 +168,143 @@ class RosterChangeSyncTests(EventsTestBase): TeamMembership.objects.get(team=self.team, member=self.alice).delete() self.assertTrue(past.attendances.filter(member=self.alice).exists()) + + +class RecurrenceTestBase(EventsTestBase): + def setUp(self): + super().setUp() + self.anchor = (timezone.now() + timedelta(days=1)).replace(microsecond=0) + + def make_series(self, **kwargs): + kwargs.setdefault("club", self.club) + kwargs.setdefault("title", "Weekly Training") + kwargs.setdefault("kind", Event.EventKind.TRAINING) + kwargs.setdefault("rrule", "FREQ=WEEKLY;COUNT=4") + kwargs.setdefault("dtstart", self.anchor) + kwargs.setdefault("duration", timedelta(hours=2)) + series = EventSeries.objects.create(**kwargs) + series.teams.set([self.team]) + return series + + +class OccurrenceExpansionTests(RecurrenceTestBase): + def test_str(self): + self.assertEqual(str(self.make_series()), "Weekly Training") + + def test_weekly_expansion(self): + series = self.make_series() + + dts = occurrence_datetimes(series, self.anchor + timedelta(days=30)) + + self.assertEqual(dts[0], self.anchor) + self.assertEqual(dts[1], self.anchor + timedelta(weeks=1)) + self.assertEqual(len(dts), 4) + + def test_until_bounds_expansion(self): + series = self.make_series() + + dts = occurrence_datetimes(series, self.anchor + timedelta(days=10)) + + self.assertEqual(len(dts), 2) + + def test_excluded_dates_are_skipped(self): + series = self.make_series() + skipped = self.anchor + timedelta(weeks=1) + series.excluded_dates = [skipped.isoformat()] + series.save() + + dts = occurrence_datetimes(series, self.anchor + timedelta(days=30)) + + self.assertNotIn(skipped, dts) + self.assertEqual(len(dts), 3) + + +class GenerateOccurrencesTests(RecurrenceTestBase): + def test_materialises_occurrences_with_template_and_attendance(self): + series = self.make_series() + + created = generate_occurrences(series, self.anchor + timedelta(days=30)) + + self.assertEqual(len(created), 4) + first = series.occurrences.order_by("start").first() + self.assertEqual(first.start, self.anchor) + self.assertEqual(first.end, self.anchor + timedelta(hours=2)) + self.assertEqual(first.title, "Weekly Training") + self.assertEqual(first.kind, Event.EventKind.TRAINING) + # Audience copied from the series, so attendance follows the roster. + self.assertEqual(set(first.attendances.values_list("member_id", flat=True)), {self.alice.id, self.bob.id}) + series.refresh_from_db() + self.assertIsNotNone(series.generated_until) + + def test_generation_is_idempotent(self): + series = self.make_series() + until = self.anchor + timedelta(days=30) + + generate_occurrences(series, until) + generate_occurrences(series, until) + + self.assertEqual(series.occurrences.count(), 4) + + +class SingleOccurrenceTests(RecurrenceTestBase): + def test_cancel_deletes_and_prevents_regeneration(self): + series = self.make_series() + until = self.anchor + timedelta(days=30) + generate_occurrences(series, until) + target = series.occurrences.order_by("start")[1] + target_start = target.start + + cancel_occurrence(target) + self.assertFalse(series.occurrences.filter(start=target_start).exists()) + + generate_occurrences(series, until) + self.assertFalse(series.occurrences.filter(start=target_start).exists()) + self.assertEqual(series.occurrences.count(), 3) + + def test_cancel_soft_marks_cancelled(self): + series = self.make_series() + generate_occurrences(series, self.anchor + timedelta(days=30)) + target = series.occurrences.order_by("start").first() + + cancel_occurrence(target, hard_delete=False) + + target.refresh_from_db() + self.assertTrue(target.cancelled) + self.assertIn(target.start.isoformat(), series.excluded_dates) + + def test_detached_occurrence_is_left_untouched_by_propagation(self): + series = self.make_series() + generate_occurrences(series, self.anchor + timedelta(days=30)) + detached = series.occurrences.order_by("start").first() + detach_occurrence(detached) + + series.title = "Renamed" + series.save() + propagate_series(series) + + detached.refresh_from_db() + self.assertEqual(detached.title, "Weekly Training") + other = series.occurrences.exclude(pk=detached.pk).order_by("start").first() + self.assertEqual(other.title, "Renamed") + + def test_propagation_updates_audience_and_attendance(self): + series = self.make_series() + generate_occurrences(series, self.anchor + timedelta(days=30)) + carol = Member.objects.create(first_name="Carol", last_name="Cedar") + series.invited_members.set([carol]) + + propagate_series(series) + + event = series.occurrences.order_by("start").first() + self.assertIn(carol.id, set(event.attendances.values_list("member_id", flat=True))) + + +class ExtendSeriesCommandTests(RecurrenceTestBase): + def test_command_generates_occurrences(self): + series = self.make_series() + out = StringIO() + + call_command("extend_event_series", stdout=out) + + self.assertEqual(series.occurrences.count(), 4) + self.assertIn("Done.", out.getvalue()) diff --git a/pyproject.toml b/pyproject.toml index 535339d..7d8cdf5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ dependencies = [ "django>=6.0.6", "django-phonenumber-field[phonenumbers]>=8.4.0", "pillow>=12.3.0", + "python-dateutil>=2.9.0.post0", "python-decouple>=3.8", ] diff --git a/uv.lock b/uv.lock index 684173e..cb93482 100644 --- a/uv.lock +++ b/uv.lock @@ -20,6 +20,7 @@ dependencies = [ { name = "django" }, { name = "django-phonenumber-field", extra = ["phonenumbers"] }, { name = "pillow" }, + { name = "python-dateutil" }, { name = "python-decouple" }, ] @@ -35,6 +36,7 @@ requires-dist = [ { name = "django", specifier = ">=6.0.6" }, { name = "django-phonenumber-field", extras = ["phonenumbers"], specifier = ">=8.4.0" }, { name = "pillow", specifier = ">=12.3.0" }, + { name = "python-dateutil", specifier = ">=2.9.0.post0" }, { name = "python-decouple", specifier = ">=3.8" }, ] @@ -185,6 +187,18 @@ wheels = [ { 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-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-decouple" version = "3.8" @@ -219,6 +233,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "sqlparse" version = "0.5.5"