feat(events): recurring event series with occurrence sync

Add EventSeries (club-scoped): an RFC-5545 rrule + dtstart + duration and a
template (kind/title/location/opponent + audience M2M). Concrete Event rows
are materialised occurrences carrying a series FK plus detached/cancelled
flags; the series tracks excluded_dates (EXDATEs) and a generated_until
horizon watermark.

Recurrence service:
- occurrence_datetimes expands the rrule (via python-dateutil) up to a
  horizon, minus EXDATEs.
- generate_occurrences materialises missing rows, copies the template +
  audience (so attendance syncs through the existing signals), and is
  idempotent.
- cancel_occurrence adds an EXDATE and deletes (or soft-cancels) one
  occurrence so it isn't regenerated; detach_occurrence marks an occurrence
  as independently edited; propagate_series re-applies the template to
  non-detached future occurrences.
- extend_event_series management command rolls the horizon forward.

Register EventSeries in the admin and surface series/detached/cancelled on
the Event admin. Add python-dateutil. Full suite at 100% coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 17:51:34 +02:00
parent 866716d196
commit ca34d26a8e
11 changed files with 426 additions and 7 deletions

View File

@@ -1,7 +1,7 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from .models import Attendance, Event, Location, Opponent
from .models import Attendance, Event, EventSeries, Location, Opponent
@admin.register(Opponent)
@@ -24,16 +24,31 @@ class AttendanceInline(admin.TabularInline):
raw_id_fields = ["member"]
@admin.register(EventSeries)
class EventSeriesAdmin(admin.ModelAdmin):
list_display = ["title", "kind", "rrule", "dtstart", "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", "duration", "excluded_dates", "generated_until"]}],
[_("Audience"), {"fields": ["teams", "invited_members", "excluded_members"]}],
[_("Where"), {"fields": ["location", "opponent"]}],
]
@admin.register(Event)
class EventAdmin(admin.ModelAdmin):
list_display = ["title", "kind", "start", "season", "club"]
list_filter = ["kind", "club", "teams"]
list_display = ["title", "kind", "start", "season", "series", "detached", "club"]
list_filter = ["kind", "club", "teams", "detached", "cancelled"]
search_fields = ["title"]
date_hierarchy = "start"
autocomplete_fields = ["season", "location", "opponent", "teams", "invited_members", "excluded_members"]
autocomplete_fields = ["season", "series", "location", "opponent", "teams", "invited_members", "excluded_members"]
inlines = [AttendanceInline]
fieldsets = [
[None, {"fields": ["title", "kind", "season"]}],
[_("Series"), {"fields": ["series", "detached", "cancelled"]}],
[_("Audience"), {"fields": ["teams", "invited_members", "excluded_members"]}],
[_("When"), {"fields": ["start", "end", "gathering", "deadline"]}],
[_("Where"), {"fields": ["location", "opponent"]}],