Add Coach mode C5: post news, reusing the desktop's own NewsForm
management.forms.NewsForm defaults its teams field to every club team -- fine for an editor/admin, but a real gap for a coach, who should only ever post as their own team, never "on behalf of" one they don't run. Re-scoped to teams_managed_by via self.managed_teams, and made required (a coach's post is always team-scoped, never empty/club-wide -- that stays an editor/admin claim). Gated with club.services.access.can_add_news, which already includes is_coach_manager. On submit the post goes straight to News.submit_for_review() plus the same notify_editors_of_pending_review call the desktop's own NewsSubmitForReviewView makes, landing in an editor's queue instead of a silent draft. The button reads "Send" rather than the mock's "Publish" -- can_publish_news stays editor/admin-only, so that's the honest description of what actually happens. visibility is left at the model's own INTERNAL default rather than building the mock's "also on club website" toggle -- an editor reviewing the pending post can widen it before publishing if a public-site placement is actually warranted; that's a real gate, not a decorative row, so it isn't reproduced as one here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
@@ -14,13 +14,15 @@ from django.utils import timezone
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
from club.services.access import current_season
|
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
|
from events.models import Attendance, Event
|
||||||
from events.services.attendance import record_check_in
|
from events.services.attendance import record_check_in
|
||||||
from events.tasks import notify_new_event
|
from events.tasks import notify_new_event
|
||||||
from management.forms import EventForm
|
from management.forms import EventForm, NewsForm
|
||||||
from teams.models import TeamMembership
|
from news.models import News
|
||||||
|
from news.services import notify_editors_of_pending_review
|
||||||
|
from teams.models import Team, TeamMembership
|
||||||
|
|
||||||
from .coach_mixins import CoachScopeMixin
|
from .coach_mixins import CoachScopeMixin
|
||||||
from .forms import _INPUT_CLASSES
|
from .forms import _INPUT_CLASSES
|
||||||
@@ -251,3 +253,80 @@ class CoachCreateEventView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
|
|||||||
# series' occurrences aren't wired to this.
|
# series' occurrences aren't wired to this.
|
||||||
notify_new_event.delay(str(event.pk))
|
notify_new_event.delay(str(event.pk))
|
||||||
return HttpResponseRedirect(reverse("mobile:coach_today"))
|
return HttpResponseRedirect(reverse("mobile:coach_today"))
|
||||||
|
|
||||||
|
|
||||||
|
class CoachCreateNewsView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
|
||||||
|
"""C5 -- reuses management.forms.NewsForm, re-scoped to the coach's own
|
||||||
|
managed team(s). NewsForm.__init__ defaults ``teams`` to every club team
|
||||||
|
-- fine for an editor/admin, but a real gap for a coach, who should only
|
||||||
|
ever be able to post as their own team, never "on behalf of" one they
|
||||||
|
don't run.
|
||||||
|
|
||||||
|
Gated with club.services.access.can_add_news, which already includes
|
||||||
|
is_coach_manager -- no new authorization logic needed. On submit, the
|
||||||
|
post is handed straight to News.submit_for_review() (plus the same
|
||||||
|
notify_editors_of_pending_review call the desktop's own
|
||||||
|
NewsSubmitForReviewView makes) rather than left a silent draft, so it
|
||||||
|
actually reaches an editor's queue -- can_publish_news stays editor/
|
||||||
|
admin-only, so "Send for review" is the honest label here, not "Publish"
|
||||||
|
the way the design mock has it.
|
||||||
|
|
||||||
|
title_en/body_en (the optional English fallback) and a cover photo
|
||||||
|
aren't part of this screen -- both text fields are blank=True on the
|
||||||
|
model (a coach posting from their phone isn't expected to also draft an
|
||||||
|
English translation), and News has no image field for a cover at all to
|
||||||
|
begin with. ``visibility`` is left at the model's own default
|
||||||
|
(INTERNAL -- team families, in-app only) rather than building the mock's
|
||||||
|
"also on club website" toggle: a coach's post always lands as
|
||||||
|
PENDING_REVIEW first, and an editor reviewing it can widen visibility
|
||||||
|
before publishing if a public-site placement is actually warranted --
|
||||||
|
that's a real gate, not a decorative row, so it isn't reproduced here as
|
||||||
|
one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
template_name = "mobile/coach/news_form.html"
|
||||||
|
screen_title = _("New post")
|
||||||
|
active_tab = "coach_today"
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
if not can_add_news(request.user, request.club):
|
||||||
|
return HttpResponseRedirect(reverse("mobile:coach_today"))
|
||||||
|
return super().get(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def build_form(self, data=None):
|
||||||
|
instance = News(club=self.request.club, created_by=self.me)
|
||||||
|
form = NewsForm(data, club=self.request.club, instance=instance)
|
||||||
|
del form.fields["title_en"]
|
||||||
|
del form.fields["body_en"]
|
||||||
|
del form.fields["visibility"]
|
||||||
|
# A coach's post is always about their own team(s) -- never empty
|
||||||
|
# (which News.teams's own help_text defines as "club-wide", an
|
||||||
|
# editor/admin-only claim), and never any other team in the club.
|
||||||
|
form.fields["teams"].queryset = Team.objects.filter(pk__in=[team.pk for team in self.managed_teams])
|
||||||
|
form.fields["teams"].required = True
|
||||||
|
form.fields["teams"].widget = forms.CheckboxSelectMultiple()
|
||||||
|
if self.active_team is not None and data is None:
|
||||||
|
form.fields["teams"].initial = [self.active_team.pk]
|
||||||
|
for field_name in ("title", "body"):
|
||||||
|
form.fields[field_name].widget.attrs["class"] = _INPUT_CLASSES
|
||||||
|
return form
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
kwargs.setdefault("form", self.build_form())
|
||||||
|
return super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
if not can_add_news(request.user, request.club):
|
||||||
|
return HttpResponseForbidden()
|
||||||
|
|
||||||
|
form = self.build_form(request.POST)
|
||||||
|
if not form.is_valid():
|
||||||
|
return self.render_to_response(self.get_context_data(form=form))
|
||||||
|
|
||||||
|
news_item = form.save()
|
||||||
|
news_item.submit_for_review()
|
||||||
|
notify_editors_of_pending_review(news_item)
|
||||||
|
|
||||||
|
body = _("“%(news)s” is ready for review.") % {"news": news_item}
|
||||||
|
notify(request, f"s|{_('Sent for review')}|{body}")
|
||||||
|
return HttpResponseRedirect(reverse("mobile:coach_today"))
|
||||||
|
|||||||
58
mobile/templates/mobile/coach/news_form.html
Normal file
58
mobile/templates/mobile/coach/news_form.html
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{% extends "mobile/coach/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
C5 -- design_handoff_rosterchief_platform/README.md's C5 section: title,
|
||||||
|
body, and which of the coach's own teams this is for. See
|
||||||
|
CoachCreateNewsView's own docstring for what's scoped down from the mock
|
||||||
|
(no cover photo -- News has no such field; no English fallback; no
|
||||||
|
audience/visibility toggle) and why the button reads "Send for review"
|
||||||
|
rather than the mock's "Publish" -- a coach's post always needs an
|
||||||
|
editor's sign-off first.
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="-mx-4 -mt-5 flex items-center gap-3 border-b border-line bg-white px-4 py-3">
|
||||||
|
<a class="font-display text-sm font-extrabold tracking-wide text-muted uppercase" href="{% url "mobile:coach_today" %}">{% trans "Cancel" %}</a>
|
||||||
|
<span class="min-w-0 flex-1 truncate text-center font-display text-lg font-extrabold text-ink uppercase">{% trans "New post" %}</span>
|
||||||
|
<button class="font-display text-sm font-extrabold tracking-wide text-club uppercase" type="submit" form="coach-news-form">{% trans "Send" %}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if form.non_field_errors %}
|
||||||
|
<div class="m-card border border-danger-border bg-danger-bg p-3 text-sm text-club-dark">
|
||||||
|
{% for error in form.non_field_errors %}<p>{{ error }}</p>{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form id="coach-news-form" method="post" action="{% url "mobile:coach_create_news" %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="m-card flex flex-col p-4">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ form.title.id_for_label }}">{% trans "Headline" %}</label>
|
||||||
|
{{ form.title }}
|
||||||
|
{% for error in form.title.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="my-3 h-px bg-rule"></div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ form.body.id_for_label }}">{% trans "Body" %}</label>
|
||||||
|
{{ form.body }}
|
||||||
|
{% for error in form.body.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="m-card p-4">
|
||||||
|
<div class="mb-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Audience" %}</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
{% for checkbox in form.teams %}
|
||||||
|
<label class="flex items-center gap-2 text-sm text-ink">
|
||||||
|
{{ checkbox.tag }}
|
||||||
|
{{ checkbox.choice_label }}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% for error in form.teams.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
|
||||||
|
<p class="mt-2 text-xs text-dim">{% trans "Sent to an editor for review before it goes out to families." %}</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock content %}
|
||||||
@@ -17,7 +17,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if can_manage_active_team %}
|
{% if can_manage_active_team %}
|
||||||
<a class="btn btn-dark w-full" href="{% url "mobile:coach_create_event" %}">{% trans "New event" %}</a>
|
<div class="flex gap-2">
|
||||||
|
<a class="btn btn-dark flex-1" href="{% url "mobile:coach_create_event" %}">{% trans "New event" %}</a>
|
||||||
|
<a class="btn btn-secondary flex-1" href="{% url "mobile:coach_create_news" %}">{% trans "New post" %}</a>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="grid grid-cols-3 gap-2.5">
|
<div class="grid grid-cols-3 gap-2.5">
|
||||||
|
|||||||
@@ -2049,3 +2049,86 @@ class CoachCreateEventViewTests(TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertFalse(Event.objects.filter(kind=Event.EventKind.TRAINING).exists())
|
self.assertFalse(Event.objects.filter(kind=Event.EventKind.TRAINING).exists())
|
||||||
self.assertTrue(response.context["form"].errors)
|
self.assertTrue(response.context["form"].errors)
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
|
||||||
|
class CoachCreateNewsViewTests(TestCase):
|
||||||
|
"""C5 -- reuses management.forms.NewsForm, re-scoped to the coach's own
|
||||||
|
managed team(s); see CoachCreateNewsView's own docstring for what's
|
||||||
|
scoped down from the design mock."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.club = make_club()
|
||||||
|
today = timezone.localdate()
|
||||||
|
cls.season = Season.objects.create(club=cls.club, start_date=today - datetime.timedelta(days=30), end_date=today + datetime.timedelta(days=300))
|
||||||
|
cls.user = User.objects.create_user(email="coach@example.com", password="pw-secret-123")
|
||||||
|
cls.member = Member.objects.create(first_name="Sam", last_name="Coach", email="coach@example.com", user=cls.user)
|
||||||
|
cls.team = Team.objects.create(club=cls.club, name="U16", short_name="U16")
|
||||||
|
cls.position = Position.objects.create(club=cls.club, name="Head coach", short_name="HC", staff_position=True, management_position=True)
|
||||||
|
StaffAssignment.objects.create(team=cls.team, member=cls.member, season=cls.season, position=cls.position)
|
||||||
|
|
||||||
|
def _post(self, **overrides):
|
||||||
|
data = {"title": "Big win Saturday", "body": "Great game everyone.", "teams": [str(self.team.pk)]}
|
||||||
|
data.update(overrides)
|
||||||
|
return self.client.post(reverse("mobile:coach_create_news"), data, HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
def test_requires_login(self):
|
||||||
|
response = self.client.get(reverse("mobile:coach_create_news"), HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
def test_get_redirects_a_non_managing_staffer(self):
|
||||||
|
physio_position = Position.objects.create(club=self.club, name="Physio", short_name="PHY", staff_position=True, management_position=False)
|
||||||
|
physio_user = User.objects.create_user(email="physio@example.com", password="pw-secret-123")
|
||||||
|
physio_member = Member.objects.create(first_name="Pat", last_name="Physio", user=physio_user)
|
||||||
|
StaffAssignment.objects.create(team=self.team, member=physio_member, season=self.season, position=physio_position)
|
||||||
|
self.client.force_login(physio_user)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("mobile:coach_create_news"), HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("mobile:coach_today"), fetch_redirect_response=False)
|
||||||
|
|
||||||
|
def test_teams_field_is_scoped_to_managed_teams(self):
|
||||||
|
other_team = Team.objects.create(club=self.club, name="U14", short_name="U14")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("mobile:coach_create_news"), HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
team_choices = list(response.context["form"].fields["teams"].queryset)
|
||||||
|
self.assertEqual(team_choices, [self.team])
|
||||||
|
self.assertNotIn(other_team, team_choices)
|
||||||
|
|
||||||
|
def test_valid_post_creates_a_pending_review_post_scoped_to_the_team(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._post(title="Big win Saturday")
|
||||||
|
|
||||||
|
news_item = News.objects.get(title="Big win Saturday")
|
||||||
|
self.assertEqual(news_item.club, self.club)
|
||||||
|
self.assertEqual(news_item.created_by, self.member)
|
||||||
|
self.assertEqual(news_item.status, News.Status.PENDING_REVIEW)
|
||||||
|
self.assertEqual(news_item.visibility, News.Visibility.INTERNAL)
|
||||||
|
self.assertIn(self.team, news_item.teams.all())
|
||||||
|
self.assertRedirects(response, reverse("mobile:coach_today"), fetch_redirect_response=False)
|
||||||
|
|
||||||
|
def test_a_team_is_required_never_defaults_to_club_wide(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._post(title="No team picked", teams=[])
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertFalse(News.objects.filter(title="No team picked").exists())
|
||||||
|
self.assertTrue(response.context["form"].errors)
|
||||||
|
|
||||||
|
def test_non_managing_staff_cannot_post(self):
|
||||||
|
physio_position = Position.objects.create(club=self.club, name="Physio", short_name="PHY", staff_position=True, management_position=False)
|
||||||
|
physio_user = User.objects.create_user(email="physio@example.com", password="pw-secret-123")
|
||||||
|
physio_member = Member.objects.create(first_name="Pat", last_name="Physio", user=physio_user)
|
||||||
|
StaffAssignment.objects.create(team=self.team, member=physio_member, season=self.season, position=physio_position)
|
||||||
|
self.client.force_login(physio_user)
|
||||||
|
|
||||||
|
response = self._post(title="Blocked post")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
self.assertFalse(News.objects.filter(title="Blocked post").exists())
|
||||||
|
|||||||
@@ -26,4 +26,5 @@ urlpatterns = [
|
|||||||
path("coach/", coach_views.CoachTodayView.as_view(), name="coach_today"),
|
path("coach/", coach_views.CoachTodayView.as_view(), name="coach_today"),
|
||||||
path("coach/attendance/<uuid:event_id>/", coach_views.CoachAttendanceView.as_view(), name="coach_attendance"),
|
path("coach/attendance/<uuid:event_id>/", coach_views.CoachAttendanceView.as_view(), name="coach_attendance"),
|
||||||
path("coach/events/new/", coach_views.CoachCreateEventView.as_view(), name="coach_create_event"),
|
path("coach/events/new/", coach_views.CoachCreateEventView.as_view(), name="coach_create_event"),
|
||||||
|
path("coach/news/new/", coach_views.CoachCreateNewsView.as_view(), name="coach_create_news"),
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user