diff --git a/events/migrations/0023_event_deadline_reminder_sent_at.py b/events/migrations/0023_event_deadline_reminder_sent_at.py new file mode 100644 index 0000000..91e9427 --- /dev/null +++ b/events/migrations/0023_event_deadline_reminder_sent_at.py @@ -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'), + ), + ] diff --git a/events/models.py b/events/models.py index 7cedf5a..aca4c1a 100644 --- a/events/models.py +++ b/events/models.py @@ -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") diff --git a/events/tasks.py b/events/tasks.py index 5441341..9ed4ed4 100644 --- a/events/tasks.py +++ b/events/tasks.py @@ -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)." diff --git a/events/tests.py b/events/tests.py index 19a7f7b..e9f4ece 100644 --- a/events/tests.py +++ b/events/tests.py @@ -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() diff --git a/features/jobs.py b/features/jobs.py index 536d2a9..ccb17a2 100644 --- a/features/jobs.py +++ b/features/jobs.py @@ -15,6 +15,11 @@ JOB_REGISTRY = { "description": _("Materialises recurring event occurrences up to the rolling horizon, so the calendar never runs dry."), "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": { "label": _("Renew subscriptions"), "description": _("Opens the next billing period for clubs whose current one is running out."), diff --git a/rosterchief/settings.py b/rosterchief/settings.py index 9d7f5a0..19af95f 100644 --- a/rosterchief/settings.py +++ b/rosterchief/settings.py @@ -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. CELERY_BEAT_SCHEDULE = { "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)}, "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)},