diff --git a/events/admin.py b/events/admin.py index 22979de..824cbcc 100644 --- a/events/admin.py +++ b/events/admin.py @@ -26,13 +26,13 @@ class AttendanceInline(admin.TabularInline): @admin.register(EventSeries) class EventSeriesAdmin(admin.ModelAdmin): - list_display = ["title", "kind", "rrule", "dtstart", "generated_until", "club"] + list_display = ["title", "kind", "rrule", "dtstart", "until", "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", "excluded_dates", "generated_until"]}], + [_("Recurrence"), {"fields": ["rrule", "dtstart", "until", "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/0007_event_series_until.py b/events/migrations/0007_event_series_until.py new file mode 100644 index 0000000..299b554 --- /dev/null +++ b/events/migrations/0007_event_series_until.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.6 on 2026-07-12 17:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0006_event_series_offsets'), + ] + + operations = [ + migrations.AddField( + model_name='eventseries', + name='until', + field=models.DateTimeField(blank=True, help_text="Series end: no occurrences are generated after this. Leave blank for open-ended (bounded by the rule's own COUNT/UNTIL, if any).", null=True, verbose_name='until'), + ), + ] diff --git a/events/models.py b/events/models.py index 2174ba7..69e0c68 100644 --- a/events/models.py +++ b/events/models.py @@ -79,6 +79,7 @@ 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")) + until = models.DateTimeField(_("until"), null=True, blank=True, help_text=_("Series end: no occurrences are generated after this. Leave blank for open-ended (bounded by the rule's own COUNT/UNTIL, if any).")) 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.")) diff --git a/events/services/recurrence.py b/events/services/recurrence.py index ed05294..e54973e 100644 --- a/events/services/recurrence.py +++ b/events/services/recurrence.py @@ -22,7 +22,14 @@ def horizon(): def occurrence_datetimes(series, until): - """Expand the series' RRULE from its anchor up to ``until``, minus EXDATEs.""" + """Expand the series' RRULE from its anchor up to ``until``, minus EXDATEs. + + ``until`` is the generation horizon; the series' own ``until`` (its formal + end) further caps it, whichever comes first. + """ + if series.until is not None and series.until < until: + until = series.until + rule = rrulestr(series.rrule, dtstart=series.dtstart) excluded = set(series.excluded_dates) diff --git a/events/tests.py b/events/tests.py index 5088452..8af833a 100644 --- a/events/tests.py +++ b/events/tests.py @@ -218,6 +218,15 @@ class OccurrenceExpansionTests(RecurrenceTestBase): self.assertNotIn(skipped, dts) self.assertEqual(len(dts), 3) + def test_series_until_caps_expansion(self): + series = self.make_series(rrule="FREQ=WEEKLY", until=self.anchor + timedelta(days=10)) + + dts = occurrence_datetimes(series, self.anchor + timedelta(days=90)) + + # Only the anchor and the first weekly repeat fall on/before `until`. + self.assertEqual(dts, [self.anchor, self.anchor + timedelta(weeks=1)]) + self.assertTrue(all(dt <= series.until for dt in dts)) + class GenerateOccurrencesTests(RecurrenceTestBase): def test_materialises_occurrences_with_template_and_attendance(self): @@ -245,6 +254,13 @@ class GenerateOccurrencesTests(RecurrenceTestBase): self.assertEqual(series.occurrences.count(), 4) + def test_generation_stops_at_series_until(self): + series = self.make_series(rrule="FREQ=WEEKLY", until=self.anchor + timedelta(days=10)) + + generate_occurrences(series) + + self.assertEqual(series.occurrences.count(), 2) + def test_gathering_and_deadline_come_from_offsets(self): series = self.make_series(gathering_offset=timedelta(minutes=30), deadline_offset=timedelta(days=1))