- Coach line-up screen: more breathing room above the sheet, brighter event subtitle, and the "Schedule" button now matches the date input's height. - Positions and referee levels can now be deleted from Settings (blocked with a friendly message if still in use on a roster/referee profile/inheritance chain). - The Evaluations nav placeholder is now gated on the formbuilder flag, same as Forms itself, since the design reuses formbuilder underneath. - Control panel: each scheduled platform job can now be paused/resumed individually (features.models.JobToggle), independent of the platform-wide Maintenance lock. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
144 lines
7.0 KiB
Python
144 lines
7.0 KiB
Python
"""Celery tasks for events -- the `extend-event-series` beat schedule entry
|
|
(see rosterchief/settings.CELERY_BEAT_SCHEDULE and features/jobs.py), and
|
|
notifying members when a new event needs their RSVP.
|
|
"""
|
|
|
|
import datetime
|
|
|
|
from celery import shared_task
|
|
from django.utils import timezone
|
|
from django.utils.translation import gettext as _
|
|
|
|
from events.models import Attendance, Event, EventSeries, Lineup
|
|
from events.services import generate_occurrences, horizon
|
|
from events.services.lineup import publish_lineup
|
|
from features.models import JobToggle, Maintenance
|
|
from members.models import Member
|
|
from notifications.services import notify_members
|
|
|
|
#: How long before an event's answer deadline (or, when it has none, its own
|
|
#: start) send_deadline_reminders below nudges whoever still hasn't answered.
|
|
DEADLINE_REMINDER_LEAD_TIME = datetime.timedelta(days=7)
|
|
|
|
|
|
@shared_task(name="events.tasks.extend_event_series")
|
|
def extend_event_series():
|
|
if Maintenance.is_on():
|
|
# Loud, not silent: a job that quietly skips itself while the platform is closed is
|
|
# how a rolling horizon quietly runs dry. Raising here is what turns it into a
|
|
# Failure on the control panel's Jobs tab instead of nothing happening at all.
|
|
raise RuntimeError("Platform is in maintenance mode; this job stood down.")
|
|
if not JobToggle.is_enabled("events.tasks.extend_event_series"):
|
|
raise RuntimeError("This job is disabled in the control panel.")
|
|
|
|
until = horizon()
|
|
total = 0
|
|
for series in EventSeries.objects.all():
|
|
total += len(generate_occurrences(series, until))
|
|
|
|
return f"Generated {total} occurrence(s) across {EventSeries.objects.count()} series."
|
|
|
|
|
|
@shared_task(name="events.tasks.notify_new_event")
|
|
def notify_new_event(event_id):
|
|
"""Scheduled from management.views.EventCreateView.form_valid -- a staff
|
|
member deliberately planning one new event, not every Event row that
|
|
happens to get created (a recurring series' rolling-horizon extension
|
|
via extend_event_series above, or a bulk fixture import, would otherwise
|
|
flood everyone with one push per occurrence; those are intentionally not
|
|
wired to this).
|
|
|
|
Notifies only whoever is still NO_RESPONSE -- right after creation every
|
|
invited member starts there (events.services.attendance.sync_event_attendances
|
|
just ran via the Event post_save signal), so this is exactly "everyone
|
|
who needs to respond", not the full invited list.
|
|
"""
|
|
event = Event.objects.filter(pk=event_id, cancelled=False).select_related("club").first()
|
|
if event is None:
|
|
return "Skipped: event no longer exists or was cancelled."
|
|
|
|
member_ids = Attendance.objects.filter(event=event, status=Attendance.AttendanceStatus.NO_RESPONSE).values_list("member_id", flat=True)
|
|
members = Member.objects.filter(id__in=member_ids)
|
|
if not members:
|
|
return "Skipped: no one to notify."
|
|
|
|
when = timezone.localtime(event.start).strftime("%a %d %b, %H:%M")
|
|
body = _("New %(kind)s: %(when)s. Let us know if you can make it.") % {"kind": event.get_kind_display(), "when": when}
|
|
notifications = notify_members(members, club=event.club, title=event.title, body=body, source=event)
|
|
return f"Notified {len(notifications)} member(s)."
|
|
|
|
|
|
@shared_task(name="events.tasks.send_deadline_reminders")
|
|
def send_deadline_reminders():
|
|
"""One reminder push per event, DEADLINE_REMINDER_LEAD_TIME before whichever
|
|
cutoff matters -- the event's own answer deadline, or its start when no
|
|
deadline is set -- to whoever still hasn't answered.
|
|
|
|
Unlike notify_new_event above (fired once, on-demand, from a staff
|
|
member manually planning a single event), this is the periodic sweep
|
|
that also catches a recurring series' occurrences, which never go
|
|
through that on-creation path at all -- bulk-generating a season's
|
|
worth of practices in one go and notifying everyone about each
|
|
individually would be exactly the flood notify_new_event's own
|
|
docstring says to avoid. Idempotent via Event.deadline_reminder_sent_at:
|
|
safe to run as often as CELERY_BEAT_SCHEDULE likes without
|
|
double-notifying, and if a run is ever missed, the next one still
|
|
catches anything whose window hasn't fully closed yet.
|
|
"""
|
|
if Maintenance.is_on():
|
|
raise RuntimeError("Platform is in maintenance mode; this job stood down.")
|
|
if not JobToggle.is_enabled("events.tasks.send_deadline_reminders"):
|
|
raise RuntimeError("This job is disabled in the control panel.")
|
|
|
|
now = timezone.now()
|
|
events_reminded = 0
|
|
members_notified = 0
|
|
|
|
candidates = Event.objects.filter(cancelled=False, deadline_reminder_sent_at__isnull=True, start__gt=now).select_related("club")
|
|
for event in candidates:
|
|
cutoff = event.deadline or event.start
|
|
reminder_at = cutoff - DEADLINE_REMINDER_LEAD_TIME
|
|
if not (reminder_at <= now < cutoff):
|
|
continue
|
|
|
|
member_ids = Attendance.objects.filter(event=event, status=Attendance.AttendanceStatus.NO_RESPONSE).values_list("member_id", flat=True)
|
|
members = Member.objects.filter(id__in=member_ids)
|
|
if members:
|
|
when = timezone.localtime(event.start).strftime("%a %d %b, %H:%M")
|
|
body = _("Reminder: %(kind)s on %(when)s still needs your answer.") % {"kind": event.get_kind_display(), "when": when}
|
|
notify_members(members, club=event.club, title=event.title, body=body, source=event)
|
|
members_notified += len(members)
|
|
|
|
# Marked processed even when nobody was NO_RESPONSE at the time -- the
|
|
# window only opens once per event, not "keep checking until someone
|
|
# answers" (that would just mean it fires the moment they stop being
|
|
# NO_RESPONSE for an unrelated reason, e.g. answering after the window).
|
|
event.deadline_reminder_sent_at = now
|
|
event.save(update_fields=["deadline_reminder_sent_at", "modified"])
|
|
events_reminded += 1
|
|
|
|
return f"Reminded {members_notified} member(s) across {events_reminded} event(s)."
|
|
|
|
|
|
@shared_task(name="events.tasks.publish_scheduled_lineups")
|
|
def publish_scheduled_lineups():
|
|
"""The periodic sweep behind a coach's "schedule for later" option on the
|
|
Publish action (mobile/coach_views.py's CoachLineupPublishView, events.
|
|
services.lineup.schedule_lineup_publish) -- catches any line-up whose
|
|
scheduled_publish_at has arrived and actually publishes it. Runs
|
|
frequently (see CELERY_BEAT_SCHEDULE), unlike this module's other daily
|
|
jobs, since a schedule set for a specific time should take effect close
|
|
to it, not up to a day late."""
|
|
if Maintenance.is_on():
|
|
raise RuntimeError("Platform is in maintenance mode; this job stood down.")
|
|
if not JobToggle.is_enabled("events.tasks.publish_scheduled_lineups"):
|
|
raise RuntimeError("This job is disabled in the control panel.")
|
|
|
|
due = Lineup.objects.filter(published_at__isnull=True, scheduled_publish_at__isnull=False, scheduled_publish_at__lte=timezone.now())
|
|
count = 0
|
|
for lineup in due:
|
|
publish_lineup(lineup)
|
|
count += 1
|
|
|
|
return f"Published {count} scheduled line-up(s)."
|