Add spacing, a larger body, and photo uploads to the coach news form

The post-composer screen was cramped and text-only, and its "audience"
picker exposed the same "which team(s)" choice the create-event screen
already dropped -- hard-lock teams to the coach's active team instead
(hidden field, required=True so a tampered empty submission can't
silently become club-wide), matching how CoachCreateEventView.teams
already works. Photos reuse management's own NewsPhotoUploadForm/
NewsPhoto machinery, first upload becomes the main picture.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 17:47:49 +02:00
parent b9900510b1
commit 148f3a24c9
6 changed files with 125 additions and 71 deletions

View File

@@ -29,16 +29,16 @@ from events.services.attendance import member_attendance_counts, record_check_in
from events.services.calendar import agenda_groups
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, EventSeriesForm, LocationForm, NewsForm, OpponentForm
from management.forms import EventForm, EventSeriesForm, LocationForm, NewsForm, NewsPhotoUploadForm, OpponentForm
from members.models import Member
from news.models import News
from news.models import News, NewsPhoto
from news.services import notify_editors_of_pending_review
from notifications.services import notify_members
from teams.models import Position, StaffAssignment, Team, TeamMembership
from teams.services import eligible_roster_members
from .coach_mixins import CoachScopeMixin
from .forms import _INPUT_CLASSES, CoachRosterEditForm
from .forms import _INPUT_CLASSES, _TEXTAREA_CLASSES, CoachRosterEditForm
#: RSVP states that count as "in" for the stat tile -- present/selected are an
#: explicit yes, maybe is still a lean-in rather than silence.
@@ -404,10 +404,15 @@ class CoachCreateEventView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
#
# teams: hard-locked to the active team, not a picker -- see this
# view's own docstring. A hidden MultipleHiddenInput renders its
# `initial` on GET without any template code, and the queryset
# restriction means a tampered request still can't set another team.
# `initial` on GET without any template code; the queryset
# restriction means a tampered request can't set a *different* team,
# and required=True (Event.teams itself is blank=True, so the form
# field defaults to optional) means a tampered request can't submit
# an empty selection either -- both would otherwise still produce a
# real, saveable event, just not one scoped to a single team anymore.
form.fields["teams"].widget = forms.MultipleHiddenInput()
form.fields["teams"].queryset = Team.objects.filter(pk=self.active_team.pk) if self.active_team is not None else Team.objects.none()
form.fields["teams"].required = True
# form.initial (not just field.initial) -- ModelForm.__init__ already
# populated form.initial["teams"] = [] from the new, unsaved Event/
# EventSeries instance's own (necessarily empty) m2m, and
@@ -587,10 +592,12 @@ class CoachOpponentCreateView(CoachScopeMixin, LoginRequiredMixin, View):
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.
active team. ``teams`` isn't a picker at all here (same reasoning as
CoachCreateEventView's own ``teams`` -- there's no "which team" question
on a screen already scoped to one): hard-locked to the active team, a
hidden field an editor can still widen from the desktop when reviewing
the submission, which is the honest place for that judgment call to live
-- not a checkbox list a coach has to get right on a phone.
Gated with club.services.access.can_add_news, which already includes
is_coach_manager -- no new authorization logic needed. On submit, the
@@ -599,19 +606,20 @@ class CoachCreateNewsView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
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.
the way the design mock has it. Any uploaded photos are attached via
management.forms.NewsPhotoUploadForm's own "images" field and
NewsPhotoUploadView's own first-upload-becomes-main logic, reused
directly rather than a second copy of either.
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.
title_en/body_en (the optional English fallback) aren't part of this
screen -- both are blank=True on the model (a coach posting from their
phone isn't expected to also draft an English translation).
``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"
@@ -629,35 +637,42 @@ class CoachCreateNewsView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
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.
#
# Widget swapped in *before* .queryset is set -- ModelChoiceField.
# queryset's setter pushes `self.widget.choices = self.choices` as a
# side effect at the moment it's assigned; setting the queryset first
# and swapping the widget after discards that push, leaving the new
# widget's own .choices empty and the checkbox list rendering nothing
# at all (a real, previously-undetected bug -- see the regression
# test, and mobile.coach_views.CoachCreateEventView._scope_shared_
# fields' own near-identical comment for the ModelMultipleChoiceField
# version of the same trap).
form.fields["teams"].widget = forms.CheckboxSelectMultiple()
form.fields["teams"].queryset = Team.objects.filter(pk__in=[team.pk for team in self.managed_teams])
# teams: hard-locked to the active team, not a picker -- see this
# view's own docstring. Widget swapped in *before* .queryset is set --
# ModelChoiceField.queryset's setter pushes `self.widget.choices =
# self.choices` as a side effect at the moment it's assigned, and
# setting the queryset first (then swapping the widget after) would
# discard that push -- harmless for MultipleHiddenInput specifically
# (it renders from `value`/`initial`, not `choices`), but kept in
# this order anyway for consistency with the ModelMultipleChoiceField
# fields that *do* need it (CoachCreateEventView._scope_shared_fields'
# own comment has the full mechanism).
form.fields["teams"].widget = forms.MultipleHiddenInput()
form.fields["teams"].queryset = Team.objects.filter(pk=self.active_team.pk) if self.active_team is not None else Team.objects.none()
# News.teams is blank=True, so the auto-generated field defaults to
# required=False -- without this, a tampered empty submission would
# still save, silently becoming a club-wide post (see the analogous
# fix and full comment on CoachCreateEventView._scope_shared_fields'
# own "teams" field).
form.fields["teams"].required = True
if self.active_team is not None and data is None:
# form.initial, not field.initial -- ModelForm.__init__ already set
# form.initial["teams"] = [] from the new, unsaved News instance's
# own (necessarily empty) m2m, and dict.get(name, field.initial)
# only falls back to field.initial when the key is *absent*, not
# merely empty, so field.initial alone silently renders unchecked.
form.initial["teams"] = [self.active_team.pk]
for field_name in ("title", "body"):
form.fields[field_name].widget.attrs["class"] = _INPUT_CLASSES
# form.initial, not field.initial -- ModelForm.__init__ already set
# form.initial["teams"] = [] from the new, unsaved News instance's
# own (necessarily empty) m2m, and dict.get(name, field.initial)
# only falls back to field.initial when the key is *absent*, not
# merely empty, so field.initial alone silently renders unchecked.
form.initial["teams"] = [self.active_team.pk] if self.active_team is not None else []
form.fields["title"].widget.attrs["class"] = _INPUT_CLASSES
form.fields["body"].widget.attrs["class"] = _TEXTAREA_CLASSES
return form
def build_photo_form(self, data=None, files=None):
photo_form = NewsPhotoUploadForm(data, files)
photo_form.fields["images"].required = False
return photo_form
def get_context_data(self, **kwargs):
kwargs.setdefault("form", self.build_form())
kwargs.setdefault("photo_form", self.build_photo_form())
return super().get_context_data(**kwargs)
def post(self, request, *args, **kwargs):
@@ -665,10 +680,13 @@ class CoachCreateNewsView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
return HttpResponseForbidden()
form = self.build_form(request.POST)
if not form.is_valid():
return self.render_to_response(self.get_context_data(form=form))
photo_form = self.build_photo_form(request.POST, request.FILES)
if not form.is_valid() or not photo_form.is_valid():
return self.render_to_response(self.get_context_data(form=form, photo_form=photo_form))
news_item = form.save()
for index, image in enumerate(photo_form.cleaned_data["images"]):
NewsPhoto.objects.create(news_item=news_item, image=image, is_main=index == 0)
news_item.submit_for_review()
notify_editors_of_pending_review(news_item)

View File

@@ -10,6 +10,12 @@ from teams.models import Position, TeamMembership
#: form so far just bakes its own classes straight into the widget.
_INPUT_CLASSES = "h-11 w-full rounded-lg border border-stroke bg-paper px-3 text-[15px] text-ink placeholder:text-dim focus:border-ink focus:outline-none"
#: Same look as _INPUT_CLASSES, sized for a multi-line body instead of a
#: single-line value -- a fixed height (not h-11) plus vertical padding a
#: fixed-height input doesn't need, and resize-y so a longer post isn't
#: stuck scrolling inside a short box.
_TEXTAREA_CLASSES = "h-40 w-full resize-y rounded-lg border border-stroke bg-paper px-3 py-2.5 text-[15px] text-ink placeholder:text-dim focus:border-ink focus:outline-none"
class MemberProfileForm(forms.ModelForm):
"""M6 -- "Edit personal info" (design_handoff_rosterchief_platform/README.md).

View File

@@ -3,10 +3,10 @@
{% 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"
body, photos, posted for the coach's active team. See CoachCreateNewsView's
own docstring for what's scoped down from the mock (no English fallback,
no audience/visibility toggle -- teams is a hidden field locked to the
active team, not a picker) 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 %}
@@ -18,14 +18,16 @@
<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 %}
{% for error in form.non_field_errors %}
<div class="m-card border border-danger-border bg-danger-bg p-3 text-sm text-club-dark"><p>{{ error }}</p></div>
{% endfor %}
{% for error in photo_form.non_field_errors %}
<div class="m-card border border-danger-border bg-danger-bg p-3 text-sm text-club-dark"><p>{{ error }}</p></div>
{% endfor %}
<form id="coach-news-form" method="post" action="{% url "mobile:coach_create_news" %}" hx-boost="false">
<form id="coach-news-form" class="flex flex-col gap-4" method="post" action="{% url "mobile:coach_create_news" %}" enctype="multipart/form-data" hx-boost="false">
{% csrf_token %}
{{ form.teams }}
<div class="m-card flex flex-col p-4">
<div>
@@ -42,17 +44,12 @@
</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>
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ photo_form.images.id_for_label }}">{% trans "Photos" %}</label>
{{ photo_form.images }}
<p class="mt-1 text-xs text-dim">{% trans "Optional. The first photo becomes the main picture." %}</p>
{% for error in photo_form.images.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
</div>
<p class="px-1 text-xs text-dim">{% blocktrans with team=active_team.name %}Posted for {{ team }} and sent to an editor for review before it goes out to families -- they can widen who it reaches if needed.{% endblocktrans %}</p>
</form>
{% endblock content %}

View File

@@ -3963,13 +3963,15 @@ class CoachCreateNewsViewTests(TestCase):
self.assertEqual(team_choices, [self.team])
self.assertNotIn(other_team, team_choices)
def test_teams_field_is_prechecked_with_the_active_team(self):
def test_teams_is_hidden_and_locked_to_the_active_team(self):
self.client.force_login(self.user)
response = self.client.get(reverse("mobile:coach_create_news"), HTTP_HOST="ajax-united.rosterchief.app")
self.assertIsInstance(response.context["form"].fields["teams"].widget, forms.MultipleHiddenInput)
self.assertEqual(response.context["form"].initial.get("teams"), [self.team.pk])
self.assertContains(response, f'value="{self.team.pk}" id="id_teams_0" checked')
self.assertContains(response, f'<input type="hidden" name="teams" value="{self.team.pk}"')
self.assertNotContains(response, 'name="teams" type="checkbox"')
def test_valid_post_creates_a_pending_review_post_scoped_to_the_team(self):
self.client.force_login(self.user)
@@ -3993,6 +3995,31 @@ class CoachCreateNewsViewTests(TestCase):
self.assertFalse(News.objects.filter(title="No team picked").exists())
self.assertTrue(response.context["form"].errors)
def test_photos_are_optional(self):
self.client.force_login(self.user)
response = self._post(title="No photos here")
self.assertRedirects(response, reverse("mobile:coach_today"), fetch_redirect_response=False)
news_item = News.objects.get(title="No photos here")
self.assertEqual(news_item.photos.count(), 0)
def test_uploaded_photos_are_attached_first_one_is_main(self):
self.client.force_login(self.user)
response = self.client.post(
reverse("mobile:coach_create_news"),
{"title": "Photo finish", "body": "Great game everyone.", "teams": [str(self.team.pk)], "images": [make_image_file("one.png"), make_image_file("two.png")]},
HTTP_HOST="ajax-united.rosterchief.app",
)
self.assertRedirects(response, reverse("mobile:coach_today"), fetch_redirect_response=False)
news_item = News.objects.get(title="Photo finish")
photos = list(news_item.photos.order_by("ordering"))
self.assertEqual(len(photos), 2)
self.assertTrue(photos[0].is_main)
self.assertFalse(photos[1].is_main)
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")

View File

@@ -3948,6 +3948,9 @@
.h-24 {
height: calc(var(--spacing) * 24);
}
.h-40 {
height: calc(var(--spacing) * 40);
}
.h-44 {
height: calc(var(--spacing) * 44);
}
@@ -4311,6 +4314,9 @@
.resize {
resize: both;
}
.resize-y {
resize: vertical;
}
.scrollbar-gutter-stable {
scrollbar-gutter: stable;
}

File diff suppressed because one or more lines are too long