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:
@@ -26,13 +26,13 @@ class AttendanceInline(admin.TabularInline):
|
|||||||
|
|
||||||
@admin.register(EventSeries)
|
@admin.register(EventSeries)
|
||||||
class EventSeriesAdmin(admin.ModelAdmin):
|
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"]
|
list_filter = ["kind", "club"]
|
||||||
search_fields = ["title"]
|
search_fields = ["title"]
|
||||||
autocomplete_fields = ["location", "opponent", "teams", "invited_members", "excluded_members"]
|
autocomplete_fields = ["location", "opponent", "teams", "invited_members", "excluded_members"]
|
||||||
fieldsets = [
|
fieldsets = [
|
||||||
[None, {"fields": ["title", "kind"]}],
|
[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"]}],
|
[_("Timing"), {"fields": ["duration", "gathering_offset", "deadline_offset"]}],
|
||||||
[_("Audience"), {"fields": ["teams", "invited_members", "excluded_members"]}],
|
[_("Audience"), {"fields": ["teams", "invited_members", "excluded_members"]}],
|
||||||
[_("Where"), {"fields": ["location", "opponent"]}],
|
[_("Where"), {"fields": ["location", "opponent"]}],
|
||||||
|
|||||||
18
events/migrations/0007_event_series_until.py
Normal file
18
events/migrations/0007_event_series_until.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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."))
|
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"))
|
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."))
|
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."))
|
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."))
|
deadline_offset = models.DurationField(_("deadline offset"), null=True, blank=True, help_text=_("How long before the start each occurrence's sign-up deadline is."))
|
||||||
|
|||||||
@@ -22,7 +22,14 @@ def horizon():
|
|||||||
|
|
||||||
|
|
||||||
def occurrence_datetimes(series, until):
|
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)
|
rule = rrulestr(series.rrule, dtstart=series.dtstart)
|
||||||
excluded = set(series.excluded_dates)
|
excluded = set(series.excluded_dates)
|
||||||
|
|
||||||
|
|||||||
@@ -218,6 +218,15 @@ class OccurrenceExpansionTests(RecurrenceTestBase):
|
|||||||
self.assertNotIn(skipped, dts)
|
self.assertNotIn(skipped, dts)
|
||||||
self.assertEqual(len(dts), 3)
|
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):
|
class GenerateOccurrencesTests(RecurrenceTestBase):
|
||||||
def test_materialises_occurrences_with_template_and_attendance(self):
|
def test_materialises_occurrences_with_template_and_attendance(self):
|
||||||
@@ -245,6 +254,13 @@ class GenerateOccurrencesTests(RecurrenceTestBase):
|
|||||||
|
|
||||||
self.assertEqual(series.occurrences.count(), 4)
|
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):
|
def test_gathering_and_deadline_come_from_offsets(self):
|
||||||
series = self.make_series(gathering_offset=timedelta(minutes=30), deadline_offset=timedelta(days=1))
|
series = self.make_series(gathering_offset=timedelta(minutes=30), deadline_offset=timedelta(days=1))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user