From c535e5dc8c293b212ed5d942ce5668af2397512f Mon Sep 17 00:00:00 2001 From: Bernard Siebens Date: Sun, 12 Jul 2026 18:04:14 +0200 Subject: [PATCH] feat(events): series-level gathering and deadline offsets EventSeries gains gathering_offset and deadline_offset (durations before the start). Each generated occurrence derives its gathering/deadline from them (and clears them when unset); propagate_series pushes offset changes to non-detached future occurrences. Surface them in the admin. 100% coverage. Co-Authored-By: Claude Opus 4.8 --- events/admin.py | 3 +- .../migrations/0006_event_series_offsets.py | 23 +++++++++++++++ events/models.py | 2 ++ events/services/recurrence.py | 5 ++-- events/tests.py | 29 +++++++++++++++++++ 5 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 events/migrations/0006_event_series_offsets.py diff --git a/events/admin.py b/events/admin.py index 1f6bfb5..22979de 100644 --- a/events/admin.py +++ b/events/admin.py @@ -32,7 +32,8 @@ class EventSeriesAdmin(admin.ModelAdmin): autocomplete_fields = ["location", "opponent", "teams", "invited_members", "excluded_members"] fieldsets = [ [None, {"fields": ["title", "kind"]}], - [_("Recurrence"), {"fields": ["rrule", "dtstart", "duration", "excluded_dates", "generated_until"]}], + [_("Recurrence"), {"fields": ["rrule", "dtstart", "excluded_dates", "generated_until"]}], + [_("Timing"), {"fields": ["duration", "gathering_offset", "deadline_offset"]}], [_("Audience"), {"fields": ["teams", "invited_members", "excluded_members"]}], [_("Where"), {"fields": ["location", "opponent"]}], ] diff --git a/events/migrations/0006_event_series_offsets.py b/events/migrations/0006_event_series_offsets.py new file mode 100644 index 0000000..3f3c10f --- /dev/null +++ b/events/migrations/0006_event_series_offsets.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.6 on 2026-07-12 16:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0005_event_series'), + ] + + operations = [ + migrations.AddField( + model_name='eventseries', + name='deadline_offset', + field=models.DurationField(blank=True, help_text="How long before the start each occurrence's sign-up deadline is.", null=True, verbose_name='deadline offset'), + ), + migrations.AddField( + model_name='eventseries', + name='gathering_offset', + field=models.DurationField(blank=True, help_text="How long before the start each occurrence's gathering time is.", null=True, verbose_name='gathering offset'), + ), + ] diff --git a/events/models.py b/events/models.py index bce13bb..2174ba7 100644 --- a/events/models.py +++ b/events/models.py @@ -80,6 +80,8 @@ class EventSeries(ClubScopedModel): 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.")) + gathering_offset = models.DurationField(_("gathering offset"), null=True, blank=True, help_text=_("How long before the start each occurrence's gathering time is.")) + deadline_offset = models.DurationField(_("deadline offset"), null=True, blank=True, help_text=_("How long before the start each occurrence's sign-up deadline is.")) 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.")) diff --git a/events/services/recurrence.py b/events/services/recurrence.py index 4efc50e..ed05294 100644 --- a/events/services/recurrence.py +++ b/events/services/recurrence.py @@ -45,8 +45,9 @@ def apply_template(series, event): 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.end = event.start + series.duration if series.duration is not None else None + event.gathering = event.start - series.gathering_offset if series.gathering_offset is not None else None + event.deadline = event.start - series.deadline_offset if series.deadline_offset is not None else None event.save() event.teams.set(series.teams.all()) diff --git a/events/tests.py b/events/tests.py index f06071f..5088452 100644 --- a/events/tests.py +++ b/events/tests.py @@ -245,6 +245,24 @@ class GenerateOccurrencesTests(RecurrenceTestBase): self.assertEqual(series.occurrences.count(), 4) + def test_gathering_and_deadline_come_from_offsets(self): + series = self.make_series(gathering_offset=timedelta(minutes=30), deadline_offset=timedelta(days=1)) + + generate_occurrences(series, self.anchor + timedelta(days=30)) + + first = series.occurrences.order_by("start").first() + self.assertEqual(first.gathering, self.anchor - timedelta(minutes=30)) + self.assertEqual(first.deadline, self.anchor - timedelta(days=1)) + + def test_without_offsets_gathering_and_deadline_are_blank(self): + series = self.make_series() + + generate_occurrences(series, self.anchor + timedelta(days=30)) + + first = series.occurrences.order_by("start").first() + self.assertIsNone(first.gathering) + self.assertIsNone(first.deadline) + class SingleOccurrenceTests(RecurrenceTestBase): def test_cancel_deletes_and_prevents_regeneration(self): @@ -298,6 +316,17 @@ class SingleOccurrenceTests(RecurrenceTestBase): event = series.occurrences.order_by("start").first() self.assertIn(carol.id, set(event.attendances.values_list("member_id", flat=True))) + def test_propagation_updates_timing_offsets(self): + series = self.make_series() + generate_occurrences(series, self.anchor + timedelta(days=30)) + series.gathering_offset = timedelta(minutes=45) + series.save() + + propagate_series(series) + + first = series.occurrences.order_by("start").first() + self.assertEqual(first.gathering, self.anchor - timedelta(minutes=45)) + class ExtendSeriesCommandTests(RecurrenceTestBase): def test_command_generates_occurrences(self):