Events can now target members.Group audiences alongside teams, or go club_wide (every ACTIVE ClubMembership member for the event's season) instead of specific teams/groups -- the two are mutually exclusive, enforced in EventForm/EventSeriesForm.clean() since an M2M can't be validated via a DB CheckConstraint or Event.clean() (no PK yet). Attendance sync (events/signals.py) now reacts to GroupMembership and ClubMembership changes the same way it already did for TeamMembership. Authorization: club.services.access.groups_manageable_by mirrors teams_managed_by (all groups for an ADMIN, else only the ones the user belongs to -- Group has no manager/owner concept); a non-admin needs at least one managed team or belonged-to group to create/edit an event, club_wide stays admin-only, and EventManagerRequiredMixin gained a get_groups() hook so a non-admin who creates a group-only event isn't immediately locked out of managing it. Also adds rosterchief.mail.ResendEmailBackend, an HTTP-API-based Django email backend for Resend (resend.com) using the existing `requests` dependency -- no new SDK. Opt in via DJANGO_EMAIL_BACKEND and RESEND_API_KEY; every Django-sent email (allauth's password reset included) follows whichever EMAIL_BACKEND is configured, so this covers all of them for free. Resend's own SMTP relay remains a valid code-free alternative, documented alongside it in .env.production.example. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
115 lines
3.7 KiB
Python
115 lines
3.7 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.club_wide = series.club_wide
|
|
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.groups.set(series.groups.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
|