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 <noreply@anthropic.com>
This commit is contained in:
@@ -32,7 +32,8 @@ class EventSeriesAdmin(admin.ModelAdmin):
|
|||||||
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", "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"]}],
|
[_("Audience"), {"fields": ["teams", "invited_members", "excluded_members"]}],
|
||||||
[_("Where"), {"fields": ["location", "opponent"]}],
|
[_("Where"), {"fields": ["location", "opponent"]}],
|
||||||
]
|
]
|
||||||
|
|||||||
23
events/migrations/0006_event_series_offsets.py
Normal file
23
events/migrations/0006_event_series_offsets.py
Normal file
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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."))
|
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"))
|
||||||
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."))
|
||||||
|
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)."))
|
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."))
|
generated_until = models.DateTimeField(_("generated until"), null=True, blank=True, help_text=_("Occurrences have been materialised up to this point."))
|
||||||
|
|
||||||
|
|||||||
@@ -45,8 +45,9 @@ def apply_template(series, event):
|
|||||||
event.title = series.title
|
event.title = series.title
|
||||||
event.location = series.location
|
event.location = series.location
|
||||||
event.opponent = series.opponent
|
event.opponent = series.opponent
|
||||||
if series.duration is not None:
|
event.end = event.start + series.duration if series.duration is not None else None
|
||||||
event.end = event.start + series.duration
|
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.save()
|
||||||
|
|
||||||
event.teams.set(series.teams.all())
|
event.teams.set(series.teams.all())
|
||||||
|
|||||||
@@ -245,6 +245,24 @@ class GenerateOccurrencesTests(RecurrenceTestBase):
|
|||||||
|
|
||||||
self.assertEqual(series.occurrences.count(), 4)
|
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):
|
class SingleOccurrenceTests(RecurrenceTestBase):
|
||||||
def test_cancel_deletes_and_prevents_regeneration(self):
|
def test_cancel_deletes_and_prevents_regeneration(self):
|
||||||
@@ -298,6 +316,17 @@ class SingleOccurrenceTests(RecurrenceTestBase):
|
|||||||
event = series.occurrences.order_by("start").first()
|
event = series.occurrences.order_by("start").first()
|
||||||
self.assertIn(carol.id, set(event.attendances.values_list("member_id", flat=True)))
|
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):
|
class ExtendSeriesCommandTests(RecurrenceTestBase):
|
||||||
def test_command_generates_occurrences(self):
|
def test_command_generates_occurrences(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user