Let a coach schedule a line-up to publish itself later
Publish now still works exactly as before (default action), but a coach can also pick a future date/time -- events.services.lineup. schedule_lineup_publish sets Lineup.scheduled_publish_at, and a new periodic task (events.tasks.publish_scheduled_lineups, every 15 minutes) publishes it once that time arrives via the same publish_lineup used for a manual publish, so selection/Attendance/notifications all work identically either way. A pending schedule can be cancelled or published early from the same screen. 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/0027_lineup_scheduled_publish_at.py
Normal file
18
events/migrations/0027_lineup_scheduled_publish_at.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-22 18:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('events', '0026_refereesignup'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='lineup',
|
||||
name='scheduled_publish_at',
|
||||
field=models.DateTimeField(blank=True, help_text='If set (and not yet published), this line-up publishes itself automatically at this time -- events.tasks.publish_scheduled_lineups is the periodic sweep that catches it.', null=True, verbose_name='scheduled publish at'),
|
||||
),
|
||||
]
|
||||
@@ -211,6 +211,12 @@ class Lineup(UUIDModel):
|
||||
event = models.OneToOneField(Event, on_delete=models.CASCADE, related_name="lineup", verbose_name=_("event"))
|
||||
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name="lineups", verbose_name=_("team"))
|
||||
published_at = models.DateTimeField(_("published at"), null=True, blank=True)
|
||||
scheduled_publish_at = models.DateTimeField(
|
||||
_("scheduled publish at"),
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_("If set (and not yet published), this line-up publishes itself automatically at this time -- events.tasks.publish_scheduled_lineups is the periodic sweep that catches it."),
|
||||
)
|
||||
created_by = models.ForeignKey(Member, on_delete=models.SET_NULL, null=True, blank=True, related_name="created_lineups", verbose_name=_("created by"))
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -36,9 +36,15 @@ def publish_lineup(lineup):
|
||||
previously unused anywhere. Every selected member becomes SELECTED; every
|
||||
other member with an Attendance row for this event who was actually
|
||||
available (not out/silent, see UNAVAILABLE_STATUSES) becomes
|
||||
NOT_SELECTED. Notifies only the selected players."""
|
||||
NOT_SELECTED. Notifies only the selected players.
|
||||
|
||||
Clears scheduled_publish_at regardless of whether this run came from a
|
||||
coach tapping Publish directly or from the scheduled sweep (events.tasks.
|
||||
publish_scheduled_lineups) catching a due one -- once published, there's
|
||||
nothing left pending either way."""
|
||||
lineup.published_at = timezone.now()
|
||||
lineup.save(update_fields=["published_at"])
|
||||
lineup.scheduled_publish_at = None
|
||||
lineup.save(update_fields=["published_at", "scheduled_publish_at"])
|
||||
|
||||
selected_member_ids = set(LineupSelection.objects.filter(lineup=lineup).values_list("member_id", flat=True))
|
||||
|
||||
@@ -53,6 +59,24 @@ def publish_lineup(lineup):
|
||||
return lineup
|
||||
|
||||
|
||||
def schedule_lineup_publish(lineup, when):
|
||||
"""Sets ``lineup`` to publish itself automatically at ``when`` (an aware
|
||||
datetime in the future) instead of waiting for a manual Publish tap --
|
||||
events.tasks.publish_scheduled_lineups is the periodic sweep that
|
||||
actually calls publish_lineup once that time arrives. Overwrites any
|
||||
previous schedule rather than erroring -- picking a new time is the
|
||||
whole point of letting a coach come back and adjust it."""
|
||||
lineup.scheduled_publish_at = when
|
||||
lineup.save(update_fields=["scheduled_publish_at"])
|
||||
return lineup
|
||||
|
||||
|
||||
def cancel_scheduled_publish(lineup):
|
||||
lineup.scheduled_publish_at = None
|
||||
lineup.save(update_fields=["scheduled_publish_at"])
|
||||
return lineup
|
||||
|
||||
|
||||
def selected_members_by_position(lineup):
|
||||
"""Every LineupSelection for ``lineup``, grouped by the member's roster
|
||||
position for the lineup's own team/season ("category") -- the read side
|
||||
|
||||
@@ -9,8 +9,9 @@ from celery import shared_task
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from events.models import Attendance, Event, EventSeries
|
||||
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 Maintenance
|
||||
from members.models import Member
|
||||
from notifications.services import notify_members
|
||||
@@ -113,3 +114,24 @@ def send_deadline_reminders():
|
||||
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.")
|
||||
|
||||
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)."
|
||||
|
||||
101
events/tests.py
101
events/tests.py
@@ -35,7 +35,7 @@ from .services import (
|
||||
team_no_shows,
|
||||
)
|
||||
from .services.calendar import add_months, month_bounds, month_grid, season_grid, week_bounds, week_grid
|
||||
from .services.lineup import notify_dropout, publish_lineup, selected_members_by_position, toggle_selection
|
||||
from .services.lineup import cancel_scheduled_publish, notify_dropout, publish_lineup, schedule_lineup_publish, selected_members_by_position, toggle_selection
|
||||
from .services.rbihf_import import RBIHFImportError, apply_plan, build_plan, extract_team_id, parse_fixtures, suggested_location, suggested_opponent
|
||||
from .services.referees import (
|
||||
RefereeAssignmentError,
|
||||
@@ -50,7 +50,7 @@ from .services.referees import (
|
||||
set_referee_fee,
|
||||
sync_referee_invites,
|
||||
)
|
||||
from .tasks import send_deadline_reminders
|
||||
from .tasks import publish_scheduled_lineups, send_deadline_reminders
|
||||
|
||||
|
||||
class EventsTestBase(TestCase):
|
||||
@@ -485,6 +485,36 @@ class LineupServiceTests(EventsTestBase):
|
||||
lineup.refresh_from_db()
|
||||
self.assertIsNotNone(lineup.published_at)
|
||||
|
||||
def test_publish_clears_a_pending_schedule(self):
|
||||
_event, lineup = self.make_game_with_lineup()
|
||||
lineup.scheduled_publish_at = timezone.now() + timedelta(days=1)
|
||||
lineup.save()
|
||||
|
||||
publish_lineup(lineup)
|
||||
|
||||
lineup.refresh_from_db()
|
||||
self.assertIsNone(lineup.scheduled_publish_at)
|
||||
|
||||
def test_schedule_lineup_publish_sets_the_time(self):
|
||||
_event, lineup = self.make_game_with_lineup()
|
||||
when = timezone.now() + timedelta(days=1)
|
||||
|
||||
schedule_lineup_publish(lineup, when)
|
||||
|
||||
lineup.refresh_from_db()
|
||||
self.assertEqual(lineup.scheduled_publish_at, when)
|
||||
self.assertIsNone(lineup.published_at)
|
||||
|
||||
def test_cancel_scheduled_publish_clears_the_time(self):
|
||||
_event, lineup = self.make_game_with_lineup()
|
||||
lineup.scheduled_publish_at = timezone.now() + timedelta(days=1)
|
||||
lineup.save()
|
||||
|
||||
cancel_scheduled_publish(lineup)
|
||||
|
||||
lineup.refresh_from_db()
|
||||
self.assertIsNone(lineup.scheduled_publish_at)
|
||||
|
||||
def test_publish_selects_picked_members_and_not_selects_the_rest(self):
|
||||
event, lineup = self.make_game_with_lineup()
|
||||
LineupSelection.objects.create(lineup=lineup, member=self.alice)
|
||||
@@ -1950,3 +1980,70 @@ class SendDeadlineRemindersTests(EventsTestBase):
|
||||
|
||||
with self.assertRaises(RuntimeError):
|
||||
send_deadline_reminders()
|
||||
|
||||
|
||||
class PublishScheduledLineupsTests(EventsTestBase):
|
||||
"""events.tasks.publish_scheduled_lineups -- the periodic sweep behind a
|
||||
coach's "schedule for later" Publish option (mobile/coach_views.py's
|
||||
CoachLineupPublishView, events.services.lineup.schedule_lineup_publish)."""
|
||||
|
||||
def setUp(self):
|
||||
# Same Maintenance-cache leak concern as SendDeadlineRemindersTests above.
|
||||
cache.clear()
|
||||
self.addCleanup(cache.clear)
|
||||
|
||||
def make_game_with_lineup(self, **event_kwargs):
|
||||
event_kwargs.setdefault("kind", Event.EventKind.GAME)
|
||||
event = self.make_event(**event_kwargs)
|
||||
event.teams.add(self.team)
|
||||
return event, Lineup.objects.create(event=event, team=self.team)
|
||||
|
||||
def test_publishes_a_lineup_whose_time_has_arrived(self):
|
||||
_event, lineup = self.make_game_with_lineup()
|
||||
lineup.scheduled_publish_at = timezone.now() - timedelta(minutes=1)
|
||||
lineup.save()
|
||||
|
||||
result = publish_scheduled_lineups()
|
||||
|
||||
lineup.refresh_from_db()
|
||||
self.assertIsNotNone(lineup.published_at)
|
||||
self.assertIsNone(lineup.scheduled_publish_at)
|
||||
self.assertIn("Published 1 scheduled line-up(s)", result)
|
||||
|
||||
def test_leaves_a_not_yet_due_schedule_alone(self):
|
||||
_event, lineup = self.make_game_with_lineup()
|
||||
lineup.scheduled_publish_at = timezone.now() + timedelta(days=1)
|
||||
lineup.save()
|
||||
|
||||
publish_scheduled_lineups()
|
||||
|
||||
lineup.refresh_from_db()
|
||||
self.assertIsNone(lineup.published_at)
|
||||
self.assertIsNotNone(lineup.scheduled_publish_at)
|
||||
|
||||
def test_ignores_a_lineup_with_no_schedule_at_all(self):
|
||||
_event, lineup = self.make_game_with_lineup()
|
||||
|
||||
result = publish_scheduled_lineups()
|
||||
|
||||
lineup.refresh_from_db()
|
||||
self.assertIsNone(lineup.published_at)
|
||||
self.assertIn("Published 0 scheduled line-up(s)", result)
|
||||
|
||||
def test_already_published_lineup_is_not_touched_again(self):
|
||||
_event, lineup = self.make_game_with_lineup()
|
||||
published_at = timezone.now() - timedelta(days=1)
|
||||
lineup.published_at = published_at
|
||||
lineup.scheduled_publish_at = timezone.now() - timedelta(minutes=1)
|
||||
lineup.save()
|
||||
|
||||
publish_scheduled_lineups()
|
||||
|
||||
lineup.refresh_from_db()
|
||||
self.assertEqual(lineup.published_at, published_at)
|
||||
|
||||
def test_raises_during_maintenance_instead_of_silently_skipping(self):
|
||||
Maintenance.start(user=None)
|
||||
|
||||
with self.assertRaises(RuntimeError):
|
||||
publish_scheduled_lineups()
|
||||
|
||||
Reference in New Issue
Block a user