Add a periodic deadline-reminder job for events, including recurring series

New events.tasks.send_deadline_reminders (daily beat schedule, registered
in features.jobs.JOB_REGISTRY for the control panel's Jobs tab), the gap
notify_new_event's own docstring flagged: a recurring series' occurrences
never go through that on-creation path (they're bulk-generated by
extend_event_series, and notifying per-occurrence there would flood
everyone), so they never got any "you need to answer this" nudge at all.

This sweeps every upcoming event once, one week before whichever cutoff
matters -- the event's own answer deadline, or its start when none is
set -- and notifies whoever's still NO_RESPONSE, using the same
notify_members() -> Notification -> push chain notify_new_event already
uses. New Event.deadline_reminder_sent_at makes it idempotent: each
event's window opens once, is marked processed regardless of whether
anyone needed notifying, and a missed run still catches anything whose
window hasn't fully closed by the time the job next runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
2026-08-21 17:33:33 +02:00
parent 81f8f7f7dd
commit 17b1c0220a
6 changed files with 184 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.6 on 2026-08-21 15:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0022_event_club_wide_event_groups_eventseries_club_wide_and_more'),
]
operations = [
migrations.AddField(
model_name='event',
name='deadline_reminder_sent_at',
field=models.DateTimeField(blank=True, help_text="Set once events.tasks.send_deadline_reminders has nudged whoever still hasn't answered -- keeps that job from reminding the same event twice.", null=True, verbose_name='deadline reminder sent at'),
),
]

View File

@@ -98,6 +98,13 @@ class Event(ClubScopedModel):
max_referees = models.PositiveSmallIntegerField(_("max referees"), default=2, help_text=_("How many referees can be assigned to this game. Only meaningful for home games -- ignored otherwise.")) max_referees = models.PositiveSmallIntegerField(_("max referees"), default=2, help_text=_("How many referees can be assigned to this game. Only meaningful for home games -- ignored otherwise."))
deadline_reminder_sent_at = models.DateTimeField(
_("deadline reminder sent at"),
null=True,
blank=True,
help_text=_("Set once events.tasks.send_deadline_reminders has nudged whoever still hasn't answered -- keeps that job from reminding the same event twice."),
)
class Meta: class Meta:
verbose_name = _("event") verbose_name = _("event")
verbose_name_plural = _("events") verbose_name_plural = _("events")

View File

@@ -3,6 +3,8 @@
notifying members when a new event needs their RSVP. notifying members when a new event needs their RSVP.
""" """
import datetime
from celery import shared_task from celery import shared_task
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@@ -13,6 +15,10 @@ from features.models import Maintenance
from members.models import Member from members.models import Member
from notifications.services import notify_members 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") @shared_task(name="events.tasks.extend_event_series")
def extend_event_series(): def extend_event_series():
@@ -57,3 +63,53 @@ def notify_new_event(event_id):
body = _("New %(kind)s: %(when)s. Let us know if you can make it.") % {"kind": event.get_kind_display(), "when": when} 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) notifications = notify_members(members, club=event.club, title=event.title, body=body, source=event)
return f"Notified {len(notifications)} member(s)." 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.")
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)."

View File

@@ -3,6 +3,7 @@ from decimal import Decimal
from io import StringIO from io import StringIO
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.management import call_command from django.core.management import call_command
from django.db import IntegrityError from django.db import IntegrityError
@@ -12,7 +13,9 @@ from waffle import get_waffle_flag_model
from club.models import Club, ClubMembership, OnboardingRequirement, Season from club.models import Club, ClubMembership, OnboardingRequirement, Season
from club.services.onboarding import mark_bypassed, mark_complete from club.services.onboarding import mark_bypassed, mark_complete
from features.models import Maintenance
from members.models import Group, GroupMembership, Member from members.models import Group, GroupMembership, Member
from notifications.models import Notification
from teams.models import Position, RefereeLevel, RefereeProfile, Team, TeamMembership from teams.models import Position, RefereeLevel, RefereeProfile, Team, TeamMembership
from .admin import EventAdminForm from .admin import EventAdminForm
@@ -33,6 +36,7 @@ from .services import (
from .services.calendar import add_months, month_bounds, month_grid, season_grid, week_bounds, week_grid from .services.calendar import add_months, month_bounds, month_grid, season_grid, week_bounds, week_grid
from .services.rbihf_import import RBIHFImportError, apply_plan, build_plan, extract_team_id, parse_fixtures, suggested_location, suggested_opponent from .services.rbihf_import import RBIHFImportError, apply_plan, build_plan, extract_team_id, parse_fixtures, suggested_location, suggested_opponent
from .services.referees import RefereeAssignmentError, add_external_referee, assign_referee, conflicting_events, eligible_referees, needs_referee_management, remove_referee, set_referee_fee from .services.referees import RefereeAssignmentError, add_external_referee, assign_referee, conflicting_events, eligible_referees, needs_referee_management, remove_referee, set_referee_fee
from .tasks import send_deadline_reminders
class EventsTestBase(TestCase): class EventsTestBase(TestCase):
@@ -1481,3 +1485,96 @@ class CalendarGridTests(EventsTestBase):
cell = next(cell for week in september["weeks"] for cell in week if cell["date"] == date(2026, 9, 5)) cell = next(cell for week in september["weeks"] for cell in week if cell["date"] == date(2026, 9, 5))
self.assertEqual(cell["count"], 1) self.assertEqual(cell["count"], 1)
self.assertNotIn("events", cell) self.assertNotIn("events", cell)
class SendDeadlineRemindersTests(EventsTestBase):
"""events.tasks.send_deadline_reminders -- one reminder push per event,
a week before whichever cutoff matters (its own deadline, or its start
when no deadline is set), to whoever's still NO_RESPONSE. This is the
periodic sweep that also catches a recurring series' occurrences, which
never go through notify_new_event's on-creation path at all."""
def setUp(self):
# Maintenance.save() writes through to the cache (see that model's own
# docstring) -- a DB rollback between tests doesn't clear it, so the
# maintenance-mode test below would otherwise leak into every test that
# happens to run after it.
cache.clear()
self.addCleanup(cache.clear)
def make_event_with_roster(self, **kwargs):
event = self.make_event(**kwargs)
event.teams.add(self.team)
return event
def test_reminds_within_the_window_before_the_deadline(self):
event = self.make_event_with_roster(start=timezone.now() + timedelta(days=10), deadline=timezone.now() + timedelta(days=6))
result = send_deadline_reminders()
self.assertTrue(Notification.objects.filter(member=self.alice, title=event.title).exists())
self.assertTrue(Notification.objects.filter(member=self.bob, title=event.title).exists())
self.assertIn("Reminded 2 member(s) across 1 event(s)", result)
def test_falls_back_to_the_event_start_when_no_deadline_is_set(self):
event = self.make_event_with_roster(start=timezone.now() + timedelta(days=6), deadline=None)
send_deadline_reminders()
self.assertTrue(Notification.objects.filter(member=self.alice, title=event.title).exists())
def test_does_not_remind_before_the_window_opens(self):
self.make_event_with_roster(start=timezone.now() + timedelta(days=30), deadline=timezone.now() + timedelta(days=20))
send_deadline_reminders()
self.assertFalse(Notification.objects.exists())
def test_does_not_remind_after_the_deadline_has_passed(self):
# Deadline already gone, but the event itself hasn't started yet -- the
# window is closed, not "still open and overdue".
self.make_event_with_roster(start=timezone.now() + timedelta(days=2), deadline=timezone.now() - timedelta(hours=1))
send_deadline_reminders()
self.assertFalse(Notification.objects.exists())
def test_only_notifies_members_who_have_not_answered(self):
event = self.make_event_with_roster(start=timezone.now() + timedelta(days=10), deadline=timezone.now() + timedelta(days=6))
Attendance.objects.filter(event=event, member=self.alice).update(status=Attendance.AttendanceStatus.PRESENT)
send_deadline_reminders()
self.assertFalse(Notification.objects.filter(member=self.alice).exists())
self.assertTrue(Notification.objects.filter(member=self.bob).exists())
def test_does_not_notify_the_same_event_twice(self):
self.make_event_with_roster(start=timezone.now() + timedelta(days=10), deadline=timezone.now() + timedelta(days=6))
send_deadline_reminders()
Notification.objects.all().delete()
send_deadline_reminders()
self.assertFalse(Notification.objects.exists())
def test_marks_the_event_processed_even_when_nobody_needed_notifying(self):
event = self.make_event_with_roster(start=timezone.now() + timedelta(days=10), deadline=timezone.now() + timedelta(days=6))
Attendance.objects.filter(event=event).update(status=Attendance.AttendanceStatus.PRESENT)
send_deadline_reminders()
event.refresh_from_db()
self.assertIsNotNone(event.deadline_reminder_sent_at)
def test_skips_a_cancelled_event(self):
self.make_event_with_roster(start=timezone.now() + timedelta(days=10), deadline=timezone.now() + timedelta(days=6), cancelled=True)
send_deadline_reminders()
self.assertFalse(Notification.objects.exists())
def test_raises_during_maintenance_instead_of_silently_skipping(self):
Maintenance.start(user=None)
with self.assertRaises(RuntimeError):
send_deadline_reminders()

View File

@@ -15,6 +15,11 @@ JOB_REGISTRY = {
"description": _("Materialises recurring event occurrences up to the rolling horizon, so the calendar never runs dry."), "description": _("Materialises recurring event occurrences up to the rolling horizon, so the calendar never runs dry."),
"schedule": _("Daily at 03:00"), "schedule": _("Daily at 03:00"),
}, },
"events.tasks.send_deadline_reminders": {
"label": _("Send deadline reminders"),
"description": _("Nudges whoever still hasn't answered an event, one week before its answer deadline (or its start, when no deadline is set)."),
"schedule": _("Daily at 07:00"),
},
"billing.tasks.renew_subscriptions": { "billing.tasks.renew_subscriptions": {
"label": _("Renew subscriptions"), "label": _("Renew subscriptions"),
"description": _("Opens the next billing period for clubs whose current one is running out."), "description": _("Opens the next billing period for clubs whose current one is running out."),

View File

@@ -309,6 +309,7 @@ CELERY_TIMEZONE = TIME_ZONE
#: jobs so a club that renews today isn't chased or archived for a period that just closed. #: jobs so a club that renews today isn't chased or archived for a period that just closed.
CELERY_BEAT_SCHEDULE = { CELERY_BEAT_SCHEDULE = {
"extend-event-series": {"task": "events.tasks.extend_event_series", "schedule": crontab(hour=3, minute=0)}, "extend-event-series": {"task": "events.tasks.extend_event_series", "schedule": crontab(hour=3, minute=0)},
"send-deadline-reminders": {"task": "events.tasks.send_deadline_reminders", "schedule": crontab(hour=7, minute=0)},
"renew-subscriptions": {"task": "billing.tasks.renew_subscriptions", "schedule": crontab(hour=4, minute=0)}, "renew-subscriptions": {"task": "billing.tasks.renew_subscriptions", "schedule": crontab(hour=4, minute=0)},
"send-billing-reminders": {"task": "billing.tasks.send_billing_reminders", "schedule": crontab(hour=5, minute=0)}, "send-billing-reminders": {"task": "billing.tasks.send_billing_reminders", "schedule": crontab(hour=5, minute=0)},
"archive-overdue-clubs": {"task": "billing.tasks.archive_overdue_clubs", "schedule": crontab(hour=6, minute=0)}, "archive-overdue-clubs": {"task": "billing.tasks.archive_overdue_clubs", "schedule": crontab(hour=6, minute=0)},