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"))
|
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"))
|
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name="lineups", verbose_name=_("team"))
|
||||||
published_at = models.DateTimeField(_("published at"), null=True, blank=True)
|
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"))
|
created_by = models.ForeignKey(Member, on_delete=models.SET_NULL, null=True, blank=True, related_name="created_lineups", verbose_name=_("created by"))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@@ -36,9 +36,15 @@ def publish_lineup(lineup):
|
|||||||
previously unused anywhere. Every selected member becomes SELECTED; every
|
previously unused anywhere. Every selected member becomes SELECTED; every
|
||||||
other member with an Attendance row for this event who was actually
|
other member with an Attendance row for this event who was actually
|
||||||
available (not out/silent, see UNAVAILABLE_STATUSES) becomes
|
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.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))
|
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
|
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):
|
def selected_members_by_position(lineup):
|
||||||
"""Every LineupSelection for ``lineup``, grouped by the member's roster
|
"""Every LineupSelection for ``lineup``, grouped by the member's roster
|
||||||
position for the lineup's own team/season ("category") -- the read side
|
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 import timezone
|
||||||
from django.utils.translation import gettext as _
|
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 import generate_occurrences, horizon
|
||||||
|
from events.services.lineup import publish_lineup
|
||||||
from features.models import Maintenance
|
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
|
||||||
@@ -113,3 +114,24 @@ def send_deadline_reminders():
|
|||||||
events_reminded += 1
|
events_reminded += 1
|
||||||
|
|
||||||
return f"Reminded {members_notified} member(s) across {events_reminded} event(s)."
|
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,
|
team_no_shows,
|
||||||
)
|
)
|
||||||
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.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.rbihf_import import RBIHFImportError, apply_plan, build_plan, extract_team_id, parse_fixtures, suggested_location, suggested_opponent
|
||||||
from .services.referees import (
|
from .services.referees import (
|
||||||
RefereeAssignmentError,
|
RefereeAssignmentError,
|
||||||
@@ -50,7 +50,7 @@ from .services.referees import (
|
|||||||
set_referee_fee,
|
set_referee_fee,
|
||||||
sync_referee_invites,
|
sync_referee_invites,
|
||||||
)
|
)
|
||||||
from .tasks import send_deadline_reminders
|
from .tasks import publish_scheduled_lineups, send_deadline_reminders
|
||||||
|
|
||||||
|
|
||||||
class EventsTestBase(TestCase):
|
class EventsTestBase(TestCase):
|
||||||
@@ -485,6 +485,36 @@ class LineupServiceTests(EventsTestBase):
|
|||||||
lineup.refresh_from_db()
|
lineup.refresh_from_db()
|
||||||
self.assertIsNotNone(lineup.published_at)
|
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):
|
def test_publish_selects_picked_members_and_not_selects_the_rest(self):
|
||||||
event, lineup = self.make_game_with_lineup()
|
event, lineup = self.make_game_with_lineup()
|
||||||
LineupSelection.objects.create(lineup=lineup, member=self.alice)
|
LineupSelection.objects.create(lineup=lineup, member=self.alice)
|
||||||
@@ -1950,3 +1980,70 @@ class SendDeadlineRemindersTests(EventsTestBase):
|
|||||||
|
|
||||||
with self.assertRaises(RuntimeError):
|
with self.assertRaises(RuntimeError):
|
||||||
send_deadline_reminders()
|
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()
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ JOB_REGISTRY = {
|
|||||||
"description": _("Nudges whoever still hasn't answered an event, one week before its answer deadline (or its start, when no deadline is set)."),
|
"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"),
|
"schedule": _("Daily at 07:00"),
|
||||||
},
|
},
|
||||||
|
"events.tasks.publish_scheduled_lineups": {
|
||||||
|
"label": _("Publish scheduled line-ups"),
|
||||||
|
"description": _("Publishes any line-up whose coach-picked publish time has arrived."),
|
||||||
|
"schedule": _("Every 15 minutes"),
|
||||||
|
},
|
||||||
"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."),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from django.http import Http404, HttpResponseForbidden, HttpResponseRedirect
|
|||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.utils.dateparse import parse_datetime
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.utils.translation import ngettext
|
from django.utils.translation import ngettext
|
||||||
from django.views.generic import TemplateView, View
|
from django.views.generic import TemplateView, View
|
||||||
@@ -20,7 +21,7 @@ from club.services.access import can_add_news, current_season
|
|||||||
from controlpanel.messages import notify
|
from controlpanel.messages import notify
|
||||||
from events.models import Attendance, Event, Lineup, LineupSelection
|
from events.models import Attendance, Event, Lineup, LineupSelection
|
||||||
from events.services.attendance import record_check_in
|
from events.services.attendance import record_check_in
|
||||||
from events.services.lineup import UNAVAILABLE_STATUSES, publish_lineup, toggle_selection
|
from events.services.lineup import UNAVAILABLE_STATUSES, cancel_scheduled_publish, publish_lineup, schedule_lineup_publish, toggle_selection
|
||||||
from events.tasks import notify_new_event
|
from events.tasks import notify_new_event
|
||||||
from management.forms import EventForm, NewsForm
|
from management.forms import EventForm, NewsForm
|
||||||
from news.models import News
|
from news.models import News
|
||||||
@@ -539,8 +540,12 @@ class CoachLineupView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
|
|||||||
|
|
||||||
|
|
||||||
class CoachLineupPublishView(CoachScopeMixin, LoginRequiredMixin, View):
|
class CoachLineupPublishView(CoachScopeMixin, LoginRequiredMixin, View):
|
||||||
"""Writes the line-up into the game record and notifies the selected
|
"""Publish now, schedule for a later time, or cancel a pending schedule --
|
||||||
players -- events.services.lineup.publish_lineup does the actual work."""
|
events.services.lineup.publish_lineup/schedule_lineup_publish/
|
||||||
|
cancel_scheduled_publish do the actual work; events.tasks.
|
||||||
|
publish_scheduled_lineups is the periodic sweep that catches a schedule
|
||||||
|
once its time arrives. ``action`` picks which (default "publish_now",
|
||||||
|
so the plain "Publish" button posts with no extra fields)."""
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
if not self.can_manage_active_team:
|
if not self.can_manage_active_team:
|
||||||
@@ -548,8 +553,24 @@ class CoachLineupPublishView(CoachScopeMixin, LoginRequiredMixin, View):
|
|||||||
|
|
||||||
event = get_object_or_404(Event, pk=kwargs["event_id"], club=request.club, teams=self.active_team, kind=Event.EventKind.GAME)
|
event = get_object_or_404(Event, pk=kwargs["event_id"], club=request.club, teams=self.active_team, kind=Event.EventKind.GAME)
|
||||||
lineup = get_object_or_404(Lineup, event=event)
|
lineup = get_object_or_404(Lineup, event=event)
|
||||||
publish_lineup(lineup)
|
action = request.POST.get("action", "publish_now")
|
||||||
notify(request, f"s|{_('Line-up published')}|{_('Selected players have been notified.')}")
|
|
||||||
|
if action == "schedule":
|
||||||
|
when = parse_datetime(request.POST.get("publish_at", ""))
|
||||||
|
if when is not None and timezone.is_naive(when):
|
||||||
|
when = timezone.make_aware(when)
|
||||||
|
if when is None or when <= timezone.now():
|
||||||
|
notify(request, f"e|{_('Could not schedule')}|{_('Pick a date and time in the future.')}")
|
||||||
|
else:
|
||||||
|
schedule_lineup_publish(lineup, when)
|
||||||
|
notify(request, f"s|{_('Publish scheduled')}|{_('The line-up will publish itself automatically at that time.')}")
|
||||||
|
elif action == "cancel_schedule":
|
||||||
|
cancel_scheduled_publish(lineup)
|
||||||
|
notify(request, f"s|{_('Schedule cancelled')}|{_('Publish it manually whenever you are ready.')}")
|
||||||
|
else:
|
||||||
|
publish_lineup(lineup)
|
||||||
|
notify(request, f"s|{_('Line-up published')}|{_('Selected players have been notified.')}")
|
||||||
|
|
||||||
return HttpResponseRedirect(reverse("mobile:coach_lineup", kwargs={"event_id": event.pk}))
|
return HttpResponseRedirect(reverse("mobile:coach_lineup", kwargs={"event_id": event.pk}))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,11 @@
|
|||||||
{% block header_extra %}
|
{% block header_extra %}
|
||||||
<div class="mt-3 flex items-center justify-between">
|
<div class="mt-3 flex items-center justify-between">
|
||||||
<span class="font-display text-xl leading-none font-extrabold text-white uppercase">{% trans "Line-up" %}</span>
|
<span class="font-display text-xl leading-none font-extrabold text-white uppercase">{% trans "Line-up" %}</span>
|
||||||
{% if lineup.published_at %}<span class="pill pill-info">{% trans "Published" %}</span>{% endif %}
|
{% if lineup.published_at %}
|
||||||
|
<span class="pill pill-info">{% trans "Published" %}</span>
|
||||||
|
{% elif lineup.scheduled_publish_at %}
|
||||||
|
<span class="pill pill-warn">{% trans "Scheduled" %}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-xs text-on-dark-dim">{{ event.title }} · {{ event.start|date:"D d M H:i" }}</div>
|
<div class="mt-1 text-xs text-on-dark-dim">{{ event.title }} · {{ event.start|date:"D d M H:i" }}</div>
|
||||||
{% endblock header_extra %}
|
{% endblock header_extra %}
|
||||||
@@ -75,9 +79,37 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if can_manage_active_team and not lineup.published_at %}
|
{% if can_manage_active_team and not lineup.published_at %}
|
||||||
<form class="mt-2" method="post" action="{% url "mobile:coach_lineup_publish" event.pk %}" hx-boost="false">
|
{% if lineup.scheduled_publish_at %}
|
||||||
{% csrf_token %}
|
<div class="m-card-dark mt-2 p-4">
|
||||||
<button class="btn w-full bg-ice text-ice-ink" type="submit">{% trans "Publish" %}</button>
|
<p class="text-sm text-white">{% blocktrans with time=lineup.scheduled_publish_at|date:"D d M H:i" %}Scheduled to publish {{ time }}{% endblocktrans %}</p>
|
||||||
</form>
|
<div class="mt-3 grid grid-cols-2 gap-2">
|
||||||
|
<form method="post" action="{% url "mobile:coach_lineup_publish" event.pk %}" hx-boost="false">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="publish_now">
|
||||||
|
<button class="btn w-full bg-ice text-ice-ink" type="submit">{% trans "Publish now" %}</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{% url "mobile:coach_lineup_publish" event.pk %}" hx-boost="false">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="cancel_schedule">
|
||||||
|
<button class="btn bg-steel text-on-dark w-full" type="submit">{% trans "Cancel schedule" %}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<form class="mt-2" method="post" action="{% url "mobile:coach_lineup_publish" event.pk %}" hx-boost="false">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="publish_now">
|
||||||
|
<button class="btn w-full bg-ice text-ice-ink" type="submit">{% trans "Publish" %}</button>
|
||||||
|
</form>
|
||||||
|
<details class="mt-2">
|
||||||
|
<summary class="cursor-pointer font-display text-xs font-extrabold tracking-wide text-on-dark-dim uppercase">{% trans "Or schedule for later" %}</summary>
|
||||||
|
<form class="mt-2 flex items-center gap-2" method="post" action="{% url "mobile:coach_lineup_publish" event.pk %}" hx-boost="false">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="schedule">
|
||||||
|
<input type="datetime-local" name="publish_at" class="h-10 min-w-0 flex-1 rounded-lg border border-steel bg-steel px-2 text-sm text-white" required>
|
||||||
|
<button class="btn shrink-0 bg-steel text-on-dark" type="submit">{% trans "Schedule" %}</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
@@ -3202,6 +3202,50 @@ class CoachLineupViewTests(TestCase):
|
|||||||
lineup.refresh_from_db()
|
lineup.refresh_from_db()
|
||||||
self.assertIsNotNone(lineup.published_at)
|
self.assertIsNotNone(lineup.published_at)
|
||||||
|
|
||||||
|
def test_schedule_sets_scheduled_publish_at_without_publishing(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
lineup = Lineup.objects.create(event=self.event, team=self.team)
|
||||||
|
publish_at = timezone.now() + datetime.timedelta(days=1)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("mobile:coach_lineup_publish", kwargs={"event_id": self.event.pk}),
|
||||||
|
{"action": "schedule", "publish_at": publish_at.strftime("%Y-%m-%dT%H:%M")},
|
||||||
|
HTTP_HOST="ajax-united.rosterchief.app",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("mobile:coach_lineup", kwargs={"event_id": self.event.pk}), fetch_redirect_response=False)
|
||||||
|
lineup.refresh_from_db()
|
||||||
|
self.assertIsNone(lineup.published_at)
|
||||||
|
self.assertIsNotNone(lineup.scheduled_publish_at)
|
||||||
|
|
||||||
|
def test_schedule_rejects_a_time_in_the_past(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
lineup = Lineup.objects.create(event=self.event, team=self.team)
|
||||||
|
publish_at = timezone.now() - datetime.timedelta(days=1)
|
||||||
|
|
||||||
|
self.client.post(
|
||||||
|
reverse("mobile:coach_lineup_publish", kwargs={"event_id": self.event.pk}),
|
||||||
|
{"action": "schedule", "publish_at": publish_at.strftime("%Y-%m-%dT%H:%M")},
|
||||||
|
HTTP_HOST="ajax-united.rosterchief.app",
|
||||||
|
)
|
||||||
|
|
||||||
|
lineup.refresh_from_db()
|
||||||
|
self.assertIsNone(lineup.scheduled_publish_at)
|
||||||
|
|
||||||
|
def test_cancel_schedule_clears_the_scheduled_time(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
lineup = Lineup.objects.create(event=self.event, team=self.team, scheduled_publish_at=timezone.now() + datetime.timedelta(days=1))
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("mobile:coach_lineup_publish", kwargs={"event_id": self.event.pk}),
|
||||||
|
{"action": "cancel_schedule"},
|
||||||
|
HTTP_HOST="ajax-united.rosterchief.app",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("mobile:coach_lineup", kwargs={"event_id": self.event.pk}), fetch_redirect_response=False)
|
||||||
|
lineup.refresh_from_db()
|
||||||
|
self.assertIsNone(lineup.scheduled_publish_at)
|
||||||
|
|
||||||
def test_non_managing_staff_sees_a_read_only_view(self):
|
def test_non_managing_staff_sees_a_read_only_view(self):
|
||||||
physio_user = self.make_physio()
|
physio_user = self.make_physio()
|
||||||
self.client.force_login(physio_user)
|
self.client.force_login(physio_user)
|
||||||
|
|||||||
@@ -310,6 +310,9 @@ CELERY_TIMEZONE = TIME_ZONE
|
|||||||
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)},
|
"send-deadline-reminders": {"task": "events.tasks.send_deadline_reminders", "schedule": crontab(hour=7, minute=0)},
|
||||||
|
# Every 15 minutes, not daily like the jobs above -- a coach's scheduled
|
||||||
|
# publish time should take effect close to it, not up to a day late.
|
||||||
|
"publish-scheduled-lineups": {"task": "events.tasks.publish_scheduled_lineups", "schedule": crontab(minute="*/15")},
|
||||||
"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)},
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user