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:
18
events/migrations/0023_event_deadline_reminder_sent_at.py
Normal file
18
events/migrations/0023_event_deadline_reminder_sent_at.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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."))
|
||||
|
||||
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:
|
||||
verbose_name = _("event")
|
||||
verbose_name_plural = _("events")
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
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 _
|
||||
@@ -13,6 +15,10 @@ from features.models import 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():
|
||||
@@ -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}
|
||||
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.")
|
||||
|
||||
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)."
|
||||
|
||||
@@ -3,6 +3,7 @@ from decimal import Decimal
|
||||
from io import StringIO
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.management import call_command
|
||||
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.services.onboarding import mark_bypassed, mark_complete
|
||||
from features.models import Maintenance
|
||||
from members.models import Group, GroupMembership, Member
|
||||
from notifications.models import Notification
|
||||
from teams.models import Position, RefereeLevel, RefereeProfile, Team, TeamMembership
|
||||
|
||||
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.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 .tasks import send_deadline_reminders
|
||||
|
||||
|
||||
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))
|
||||
self.assertEqual(cell["count"], 1)
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user