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:
@@ -11,6 +11,7 @@ from django.http import Http404, HttpResponseForbidden, HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse
|
||||
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 ngettext
|
||||
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 events.models import Attendance, Event, Lineup, LineupSelection
|
||||
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 management.forms import EventForm, NewsForm
|
||||
from news.models import News
|
||||
@@ -539,8 +540,12 @@ class CoachLineupView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
|
||||
|
||||
|
||||
class CoachLineupPublishView(CoachScopeMixin, LoginRequiredMixin, View):
|
||||
"""Writes the line-up into the game record and notifies the selected
|
||||
players -- events.services.lineup.publish_lineup does the actual work."""
|
||||
"""Publish now, schedule for a later time, or cancel a pending schedule --
|
||||
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):
|
||||
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)
|
||||
lineup = get_object_or_404(Lineup, event=event)
|
||||
publish_lineup(lineup)
|
||||
notify(request, f"s|{_('Line-up published')}|{_('Selected players have been notified.')}")
|
||||
action = request.POST.get("action", "publish_now")
|
||||
|
||||
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}))
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,11 @@
|
||||
{% block header_extra %}
|
||||
<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>
|
||||
{% 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 class="mt-1 text-xs text-on-dark-dim">{{ event.title }} · {{ event.start|date:"D d M H:i" }}</div>
|
||||
{% endblock header_extra %}
|
||||
@@ -75,9 +79,37 @@
|
||||
{% endif %}
|
||||
|
||||
{% 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">
|
||||
{% csrf_token %}
|
||||
<button class="btn w-full bg-ice text-ice-ink" type="submit">{% trans "Publish" %}</button>
|
||||
</form>
|
||||
{% if lineup.scheduled_publish_at %}
|
||||
<div class="m-card-dark mt-2 p-4">
|
||||
<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>
|
||||
<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 %}
|
||||
{% endblock content %}
|
||||
|
||||
@@ -3202,6 +3202,50 @@ class CoachLineupViewTests(TestCase):
|
||||
lineup.refresh_from_db()
|
||||
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):
|
||||
physio_user = self.make_physio()
|
||||
self.client.force_login(physio_user)
|
||||
|
||||
Reference in New Issue
Block a user