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 %}
+
+
+
+
+
+
+ {% if form.non_field_errors %}
+
+ {% for error in form.non_field_errors %}
{{ error }}
{% endfor %}
+
+ {% endif %}
+
+
+{% 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" %}
+
{% 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"),
]