Files
RosterChief/events/services/recurrence.py
Bernard Siebens 2653dd6d08 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>
2026-07-12 19:22:48 +02:00

113 lines
3.6 KiB
Python

"""Materialise concrete Event rows from a recurring EventSeries.
Occurrences are real Event rows (so attendance attaches directly). The series
holds the RRULE, an anchor ``dtstart``, and a set of ``excluded_dates``
(EXDATEs). Rows are generated up to a rolling horizon; a single occurrence can
be cancelled (adds an EXDATE + removes the row) or detached (edited
independently, so series-wide updates skip it).
"""
from datetime import timedelta
from dateutil.rrule import rrulestr
from django.utils import timezone
from events.models import Event
HORIZON_DAYS = 90
def horizon():
return timezone.now() + timedelta(days=HORIZON_DAYS)
def occurrence_datetimes(series, until):
"""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)
result = []
for occurrence in rule:
if occurrence > until:
break
if occurrence.isoformat() in excluded:
continue
result.append(occurrence)
return result
def apply_template(series, event):
"""Copy the series template (and audience) onto ``event`` and save it.
Setting the audience M2M fires the sync signal, so attendance follows.
"""
event.kind = series.kind
event.title = series.title
event.location = series.location
event.opponent = series.opponent
event.end = event.start + series.duration if series.duration is not None else None
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.teams.set(series.teams.all())
event.invited_members.set(series.invited_members.all())
event.excluded_members.set(series.excluded_members.all())
def generate_occurrences(series, until=None):
"""Materialise any missing occurrences up to ``until`` (default: horizon)."""
until = until or horizon()
existing = set(series.occurrences.values_list("start", flat=True))
created = []
for start in occurrence_datetimes(series, until):
if start in existing:
continue
event = Event(club=series.club, series=series, start=start)
apply_template(series, event)
created.append(event)
series.generated_until = until
series.save(update_fields=["generated_until"])
return created
def cancel_occurrence(event, *, hard_delete=True):
"""Drop a single occurrence and record an EXDATE so it isn't regenerated."""
series = event.series
if series is not None:
iso = event.start.isoformat()
if iso not in series.excluded_dates:
series.excluded_dates = [*series.excluded_dates, iso]
series.save(update_fields=["excluded_dates"])
if hard_delete:
event.delete()
else:
event.cancelled = True
event.save(update_fields=["cancelled"])
def detach_occurrence(event):
"""Mark an occurrence as edited independently of the series."""
event.detached = True
event.save(update_fields=["detached"])
def propagate_series(series):
"""Re-apply the series template to its non-detached future occurrences."""
now = timezone.now()
updated = []
for event in series.occurrences.filter(detached=False, start__gte=now):
apply_template(series, event)
updated.append(event)
return updated