feat(events): formal end date for recurring series

Add EventSeries.until: an explicit series end that caps occurrence
generation (whichever comes first — it, the generation horizon, or the
rule's own COUNT/UNTIL). Blank means open-ended. Surface it in the admin.
100% coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 19:22:48 +02:00
parent c535e5dc8c
commit 2653dd6d08
5 changed files with 45 additions and 3 deletions

View File

@@ -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"]}],

View File

@@ -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'),
),
]

View File

@@ -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."))

View File

@@ -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)

View File

@@ -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))