diff --git a/mobile/coach_views.py b/mobile/coach_views.py index afe374e..18a477d 100644 --- a/mobile/coach_views.py +++ b/mobile/coach_views.py @@ -14,13 +14,15 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ 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 events.models import Attendance, Event from events.services.attendance import record_check_in from events.tasks import notify_new_event -from management.forms import EventForm -from teams.models import TeamMembership +from management.forms import EventForm, NewsForm +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 .forms import _INPUT_CLASSES @@ -251,3 +253,80 @@ class CoachCreateEventView(CoachScopeMixin, LoginRequiredMixin, TemplateView): # series' occurrences aren't wired to this. notify_new_event.delay(str(event.pk)) 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")) diff --git a/mobile/templates/mobile/coach/news_form.html b/mobile/templates/mobile/coach/news_form.html new file mode 100644 index 0000000..658d507 --- /dev/null +++ b/mobile/templates/mobile/coach/news_form.html @@ -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 %} +
+ {% trans "Cancel" %} + {% trans "New post" %} + +
+ + {% if form.non_field_errors %} +
+ {% for error in form.non_field_errors %}

{{ error }}

{% endfor %} +
+ {% endif %} + +
+ {% csrf_token %} + +
+
+ + {{ form.title }} + {% for error in form.title.errors %}

{{ error }}

{% endfor %} +
+
+
+ + {{ form.body }} + {% for error in form.body.errors %}

{{ error }}

{% endfor %} +
+
+ +
+
{% trans "Audience" %}
+
+ {% for checkbox in form.teams %} + + {% endfor %} +
+ {% for error in form.teams.errors %}

{{ error }}

{% endfor %} +

{% trans "Sent to an editor for review before it goes out to families." %}

+
+
+{% endblock content %} diff --git a/mobile/templates/mobile/coach/today.html b/mobile/templates/mobile/coach/today.html index 9aea841..03bf46f 100644 --- a/mobile/templates/mobile/coach/today.html +++ b/mobile/templates/mobile/coach/today.html @@ -17,7 +17,10 @@ {% else %} {% if can_manage_active_team %} - {% trans "New event" %} +
+ {% trans "New event" %} + {% trans "New post" %} +
{% endif %}
diff --git a/mobile/tests.py b/mobile/tests.py index de111f3..1bacc7d 100644 --- a/mobile/tests.py +++ b/mobile/tests.py @@ -2049,3 +2049,86 @@ class CoachCreateEventViewTests(TestCase): self.assertEqual(response.status_code, 200) self.assertFalse(Event.objects.filter(kind=Event.EventKind.TRAINING).exists()) 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()) diff --git a/mobile/urls.py b/mobile/urls.py index fcef0a7..9a5b421 100644 --- a/mobile/urls.py +++ b/mobile/urls.py @@ -26,4 +26,5 @@ urlpatterns = [ path("coach/", coach_views.CoachTodayView.as_view(), name="coach_today"), path("coach/attendance//", coach_views.CoachAttendanceView.as_view(), name="coach_attendance"), 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"), ]