Add a news app: coach_manager authoring, team tagging, photos, editor release flow

News and NewsPhoto (team-tagged instead of categorised, one photo taggable
as main via a partial unique constraint), gated per club/services/access.py:
any current-season coach_manager, EDITOR, or ADMIN can draft and edit a news
item; only EDITOR/ADMIN can publish it, or edit it once it's live. Publishing
takes a date so it can be scheduled ahead of time rather than only right now.

Authoring/release only for now -- no member-facing reading page or public API
yet, the visibility field (internal/external/both) is there for when those land.
This commit is contained in:
2026-08-03 18:46:37 +02:00
parent ce35348b31
commit 3e0c63ec36
20 changed files with 985 additions and 19 deletions

View File

@@ -3,9 +3,11 @@ admin-only sections (seasons, positions, roles, shop, forms) from plain staff.
The underlying views are gated regardless (``ClubAdminRequiredMixin``) -- this is
purely so the nav doesn't show a link a coach or manager can't actually follow.
Same reasoning for ``news_permissions`` below, gating just the "New news item"
action rather than the whole section (``NewsAuthorRequiredMixin``/``can_add_news``).
"""
from club.services.access import has_management_access, is_club_admin
from club.services.access import can_add_news, has_management_access, is_club_admin
#: Every management URL name, mapped to the nav item it should light up --
#: management/templates/management/_nav_items.html compares against this.
@@ -47,6 +49,15 @@ _NAV_SECTIONS = {
"team_detail": "team_list",
"roster_list": "roster_list",
"staff_list": "staff_list",
"news_list": "news_list",
"news_create": "news_list",
"news_detail": "news_list",
"news_update": "news_list",
"news_publish": "news_list",
"news_unpublish": "news_list",
"news_photo_upload": "news_list",
"news_photo_set_main": "news_list",
"news_photo_delete": "news_list",
"event_list": "event_list",
"event_series_list": "event_series_list",
"location_list": "location_list",
@@ -88,3 +99,13 @@ def management_link(request):
return {"has_management_access": False}
return {"has_management_access": has_management_access(request.user, club)}
def news_permissions(request):
"""Whether the "New news item" action should show -- viewing the News
section itself is open to any staff, same as Roster/Staff."""
club = getattr(request, "club", None)
if club is None or not request.user.is_authenticated:
return {"can_add_news": False}
return {"can_add_news": can_add_news(request.user, club)}

View File

@@ -2,11 +2,13 @@ from decimal import Decimal
from django import forms
from django.contrib.auth import get_user_model
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from club.models import ClubMembership, ClubRole, FeePayment
from members.models import Family, FamilyMembership, Member
from members.services.family import find_member_by_email
from news.models import News
from teams.models import Position, Team
User = get_user_model()
@@ -146,6 +148,53 @@ class ClubMembershipForm(forms.ModelForm):
fields = ["license", "status", "fee_status", "fee_amount"]
class NewsForm(forms.ModelForm):
"""Title/teams/visibility/body only -- status and published_at are never
directly editable, only through the publish/unpublish actions."""
class Meta:
model = News
fields = ["title", "teams", "visibility", "body"]
widgets = {"teams": forms.CheckboxSelectMultiple, "body": forms.Textarea(attrs={"rows": 8})}
def __init__(self, *args, club=None, **kwargs):
super().__init__(*args, **kwargs)
self.fields["teams"].queryset = Team.objects.filter(club=club)
class MultipleFileInput(forms.ClearableFileInput):
allow_multiple_selected = True
class MultipleFileField(forms.FileField):
"""Django's own documented recipe for a multi-file upload field: the plain
FileField only ever picks up one of several selected files, so clean() has
to iterate the list itself instead."""
def __init__(self, *args, **kwargs):
kwargs.setdefault("widget", MultipleFileInput(attrs={"multiple": True}))
super().__init__(*args, **kwargs)
def clean(self, data, initial=None):
single_file_clean = super().clean
if isinstance(data, (list, tuple)):
return [single_file_clean(item, initial) for item in data]
return [single_file_clean(data, initial)] if data else []
class NewsPhotoUploadForm(forms.Form):
images = MultipleFileField(label=_("Photos"))
class NewsPublishForm(forms.Form):
published_at = forms.DateTimeField(
label=_("Publish date"),
initial=timezone.now,
widget=forms.DateTimeInput(attrs={"type": "datetime-local"}),
help_text=_("Leave as now to publish immediately, or pick a future date/time to schedule it."),
)
class RecordFeePaymentForm(forms.Form):
"""Money received against one membership's fee -- see club.services.fees.record_payment.
Reusable for any amount, partial or the exact remaining balance; "Mark fully

View File

@@ -31,6 +31,9 @@
<li><a class="{% if nav == 'roster_list' %}menu-active{% endif %}" href="{% url 'management:roster_list' %}">{% lucide "clipboard-list" size=16 %} {% trans "Roster" %}</a></li>
<li><a class="{% if nav == 'staff_list' %}menu-active{% endif %}" href="{% url 'management:staff_list' %}">{% lucide "hard-hat" size=16 %} {% trans "Staff" %}</a></li>
<li class="menu-title">{% trans "News" %}</li>
<li><a class="{% if nav == 'news_list' %}menu-active{% endif %}" href="{% url 'management:news_list' %}">{% lucide "newspaper" size=16 %} {% trans "News" %}</a></li>
<li class="menu-title">{% trans "Calendar" %}</li>
<li><a class="{% if nav == 'event_list' %}menu-active{% endif %}" href="{% url 'management:event_list' %}">{% lucide "calendar" size=16 %} {% trans "Events" %}</a></li>
<li><a class="{% if nav == 'event_series_list' %}menu-active{% endif %}" href="{% url 'management:event_series_list' %}">{% lucide "repeat" size=16 %} {% trans "Event series" %}</a></li>

View File

@@ -0,0 +1,102 @@
{% extends "management/base.html" %}
{% load i18n lucide ui %}
{% block heading %}{{ news_item.title }}{% endblock heading %}
{% block subheading %}
{% if news_item.status == "draft" %}
{% trans "Draft" %}
{% elif news_item.is_scheduled %}
{% blocktrans with date=news_item.published_at %}Scheduled for {{ date }}{% endblocktrans %}
{% else %}
{% blocktrans with date=news_item.published_at %}Published {{ date }}{% endblocktrans %}
{% endif %}
· {{ news_item.get_visibility_display }}
{% endblock subheading %}
{% block actions %}
{% if can_edit %}
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:news_update' news_item.pk %}">{% lucide "pencil" size=16 %} {% trans "Edit" %}</a>
{% endif %}
{% if can_publish %}
{% if news_item.status == "draft" %}
<button class="btn btn-primary gap-2" type="button" onclick="document.getElementById('publish_modal').showModal()">{% lucide "upload" size=16 %} {% trans "Publish" %}</button>
{% else %}
<button class="btn btn-outline btn-warning gap-2" type="button" onclick="document.getElementById('unpublish_modal').showModal()">{% lucide "eye-off" size=16 %} {% trans "Unpublish" %}</button>
{% endif %}
{% endif %}
{% endblock actions %}
{% block panel %}
<div class="card bg-base-100 shadow">
<div class="card-body">
{% if news_item.teams.all %}
<div class="flex flex-wrap gap-2 mb-2">
{% for team in news_item.teams.all %}
<span class="badge badge-neutral">{{ team.short_name }}</span>
{% endfor %}
</div>
{% endif %}
<p class="whitespace-pre-line">{{ news_item.body }}</p>
</div>
</div>
<div class="card bg-base-100 shadow mt-4">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% trans "Photos" %}</h2>
{% if can_edit %}
<button class="btn btn-outline btn-neutral btn-sm gap-2" type="button" onclick="document.getElementById('add_photos_modal').showModal()">{% lucide "image-plus" size=14 %} {% trans "Add photos" %}</button>
{% endif %}
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-2">
{% for photo in news_item.photos.all %}
<div class="relative">
<img class="rounded-box aspect-square object-cover w-full" src="{{ photo.image.url }}" alt="">
{% if photo.is_main %}
<span class="badge badge-success badge-sm absolute top-1 left-1">{% trans "Main" %}</span>
{% endif %}
{% if can_edit %}
<div class="flex gap-1 mt-1">
{% if not photo.is_main %}
<form method="post" action="{% url 'management:news_photo_set_main' news_item.pk photo.pk %}">
{% csrf_token %}
<button class="btn btn-outline btn-neutral btn-xs gap-1" type="submit">{% lucide "star" size=12 %} {% trans "Set main" %}</button>
</form>
{% endif %}
<button class="btn btn-outline btn-error btn-xs gap-1" type="button" onclick="document.getElementById('{{ photo.pk|dom_id:"delete_photo_modal" }}').showModal()">{% lucide "trash-2" size=12 %} {% trans "Delete" %}</button>
</div>
{% url 'management:news_photo_delete' news_item.pk photo.pk as delete_photo_url %}
{% trans "Delete photo" as delete_photo_title %}
{% trans "This photo will be permanently removed." as delete_photo_body %}
{% trans "Delete" as delete_label %}
{% include "controlpanel/_confirm_modal.html" with modal_id=photo.pk|dom_id:"delete_photo_modal" title=delete_photo_title body=delete_photo_body action_url=delete_photo_url submit_label=delete_label submit_icon="trash-2" %}
{% endif %}
</div>
{% empty %}
<p class="opacity-60 col-span-full">{% trans "No photos yet." %}</p>
{% endfor %}
</div>
</div>
</div>
{% if can_edit %}
{% trans "Add photos" as add_photos_label %}
{% url 'management:news_photo_upload' news_item.pk as add_photos_url %}
{% include "controlpanel/_modal_form.html" with modal_id="add_photos_modal" title=add_photos_label form=photo_upload_form action_url=add_photos_url submit_label=add_photos_label submit_icon="image-plus" %}
{% endif %}
{% if can_publish %}
{% if news_item.status == "draft" %}
{% trans "Publish" as publish_label %}
{% url 'management:news_publish' news_item.pk as publish_url %}
{% trans "Leave as now to publish immediately, or pick a future date/time to schedule it." as publish_blurb %}
{% include "controlpanel/_modal_form.html" with modal_id="publish_modal" title=publish_label form=publish_form action_url=publish_url submit_label=publish_label submit_icon="upload" blurb=publish_blurb %}
{% else %}
{% trans "Unpublish" as unpublish_label %}
{% blocktrans asvar unpublish_body %}This pulls “{{ news_item }}” back to a draft. It won't be visible anywhere until it's published again.{% endblocktrans %}
{% url 'management:news_unpublish' news_item.pk as unpublish_url %}
{% include "controlpanel/_confirm_modal.html" with modal_id="unpublish_modal" title=unpublish_label body=unpublish_body action_url=unpublish_url submit_label=unpublish_label submit_icon="eye-off" %}
{% endif %}
{% endif %}
{% endblock panel %}

View File

@@ -0,0 +1,31 @@
{% extends "management/base.html" %}
{% load i18n lucide ui %}
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New news item" %}{% endif %}{% endblock heading %}
{% block panel %}
<div class="card w-full bg-base-100 shadow">
<div class="card-body">
<form method="post">
{% csrf_token %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
<div class="grid grid-cols-1 gap-4">
{% for field in form %}
{% form_field field %}
{% endfor %}
</div>
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline btn-neutral gap-2" href="{% if update_view %}{% url "management:news_detail" object.pk %}{% else %}{% url "management:news_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>
</form>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,57 @@
{% extends "management/base.html" %}
{% load i18n lucide %}
{% block heading %}{% trans "News" %}{% endblock heading %}
{% block actions %}
{% if can_add_news %}
<a class="btn btn-primary gap-2" href="{% url 'management:news_create' %}">{% lucide "plus" size=16 %} {% trans "New news item" %}</a>
{% endif %}
{% endblock actions %}
{% block panel %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "Title" %}</th>
<th>{% trans "Teams" %}</th>
<th>{% trans "Visibility" %}</th>
<th>{% trans "Status" %}</th>
</tr>
</thead>
<tbody>
{% for news_item in news_items %}
<tr>
<td><a class="link link-hover" href="{% url 'management:news_detail' news_item.pk %}">{{ news_item.title }}</a></td>
<td>
{% for team in news_item.teams.all %}
<span class="badge badge-neutral badge-sm">{{ team.short_name }}</span>
{% empty %}
<span class="opacity-60">{% trans "Club-wide" %}</span>
{% endfor %}
</td>
<td>{{ news_item.get_visibility_display }}</td>
<td>
{% if news_item.status == "draft" %}
<span class="badge badge-neutral badge-sm">{% trans "Draft" %}</span>
{% elif news_item.is_scheduled %}
<span class="badge badge-warning badge-sm">{% blocktrans with date=news_item.published_at %}Scheduled for {{ date }}{% endblocktrans %}</span>
{% else %}
<span class="badge badge-success badge-sm">{% trans "Published" %}</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="4" class="text-center opacity-60">{% trans "No news items yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock panel %}

View File

@@ -17,6 +17,7 @@ from events.models import Event
from management.bulk_import import TEMPLATE_COLUMNS
from management.pdf import PDFExportError, render_pdf
from members.models import Family, FamilyMembership, Member
from news.models import News, NewsPhoto
from shop.models import Order
from teams.models import Position, StaffAssignment, Team, TeamMembership
@@ -1710,3 +1711,169 @@ class MemberBulkImportTests(ManagementTestBase):
response = self.club_post("member_import_confirm", {})
self.assertEqual(response.status_code, 403)
class NewsManagementTests(ManagementTestBase):
def setUp(self):
super().setUp()
self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st")
def make_coach_manager(self, email="coach-news@example.com"):
coach_user = User.objects.create_user(email=email, password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=self.team, member=coach_member, season=self.season, position=position)
return coach_user
def make_plain_staff(self, email="physio-news@example.com"):
staff_user = User.objects.create_user(email=email, password="pw-secret-123")
staff_member = Member.objects.create(user=staff_user, first_name="Pat", last_name="Physio")
position = Position.objects.create(club=self.club, name="Physio", short_name="PH", staff_position=True, management_position=False)
StaffAssignment.objects.create(team=self.team, member=staff_member, season=self.season, position=position)
return staff_user
def make_editor(self, email="editor-news@example.com"):
editor_user = User.objects.create_user(email=email, password="pw-secret-123")
editor_member = Member.objects.create(user=editor_user, first_name="Eve", last_name="Editor")
ClubMembership.objects.create(club=self.club, member=editor_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
ClubRole.objects.filter(club=self.club, member=editor_member).update(role=ClubRole.Roles.EDITOR)
enrol_mfa(editor_user) # ClubRole ADMIN/EDITOR requires a second factor; StaffAssignment-only doesn't.
return editor_user
def test_list_is_scoped_to_the_club(self):
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
News.objects.create(club=other_club, title="Rival news", body="Body.")
self.client.force_login(self.admin_user)
response = self.club_get("news_list")
self.assertNotContains(response, "Rival news")
def test_a_coach_manager_can_create_a_draft(self):
self.client.force_login(self.make_coach_manager())
response = self.club_post("news_create", {"title": "Season Kickoff", "body": "Big news.", "visibility": News.Visibility.INTERNAL, "teams": [str(self.team.pk)]})
item = News.objects.get(club=self.club, title="Season Kickoff")
self.assertRedirects(response, reverse("management:news_detail", args=[item.pk]))
self.assertEqual(item.status, News.Status.DRAFT)
def test_plain_staff_cannot_create_news(self):
self.client.force_login(self.make_plain_staff())
response = self.club_post("news_create", {"title": "Not allowed", "body": "Body.", "visibility": News.Visibility.INTERNAL})
self.assertEqual(response.status_code, 403)
self.assertFalse(News.objects.filter(club=self.club, title="Not allowed").exists())
def test_coach_manager_cannot_publish(self):
item = News.objects.create(club=self.club, title="Draft item", body="Body.")
self.client.force_login(self.make_coach_manager())
response = self.club_post("news_publish", {"published_at": "2026-08-10T10:00"}, item.pk)
self.assertEqual(response.status_code, 403)
item.refresh_from_db()
self.assertEqual(item.status, News.Status.DRAFT)
def test_editor_can_publish(self):
item = News.objects.create(club=self.club, title="Draft item", body="Body.")
self.client.force_login(self.make_editor())
self.club_post("news_publish", {"published_at": "2026-08-10T10:00"}, item.pk)
item.refresh_from_db()
self.assertEqual(item.status, News.Status.PUBLISHED)
def test_publishing_with_a_future_date_leaves_it_scheduled(self):
item = News.objects.create(club=self.club, title="Draft item", body="Body.")
self.client.force_login(self.make_editor())
future = timezone.now() + datetime.timedelta(days=7)
self.club_post("news_publish", {"published_at": future.strftime("%Y-%m-%dT%H:%M")}, item.pk)
item.refresh_from_db()
self.assertTrue(item.is_scheduled)
def test_publishing_with_now_makes_it_live_immediately(self):
item = News.objects.create(club=self.club, title="Draft item", body="Body.")
self.client.force_login(self.make_editor())
self.club_post("news_publish", {"published_at": timezone.now().strftime("%Y-%m-%dT%H:%M")}, item.pk)
item.refresh_from_db()
self.assertFalse(item.is_scheduled)
def test_unpublishing_reverts_to_draft(self):
item = News.objects.create(club=self.club, title="Live item", body="Body.")
item.publish()
self.client.force_login(self.make_editor())
self.club_post("news_unpublish", {}, item.pk)
item.refresh_from_db()
self.assertEqual(item.status, News.Status.DRAFT)
self.assertIsNone(item.published_at)
def test_a_coach_manager_can_edit_someone_elses_draft(self):
item = News.objects.create(club=self.club, title="Old title", body="Body.")
self.client.force_login(self.make_coach_manager())
self.club_post("news_update", {"title": "New title", "body": "Body.", "visibility": News.Visibility.INTERNAL}, item.pk)
item.refresh_from_db()
self.assertEqual(item.title, "New title")
def test_a_coach_manager_cannot_edit_once_published(self):
item = News.objects.create(club=self.club, title="Old title", body="Body.")
item.publish()
self.client.force_login(self.make_coach_manager())
response = self.club_post("news_update", {"title": "New title", "body": "Body.", "visibility": News.Visibility.INTERNAL}, item.pk)
self.assertEqual(response.status_code, 403)
def test_an_editor_can_still_edit_once_published(self):
item = News.objects.create(club=self.club, title="Old title", body="Body.")
item.publish()
self.client.force_login(self.make_editor())
self.club_post("news_update", {"title": "New title", "body": "Body.", "visibility": News.Visibility.INTERNAL}, item.pk)
item.refresh_from_db()
self.assertEqual(item.title, "New title")
def test_uploading_multiple_photos_creates_one_per_file_and_marks_the_first_main(self):
item = News.objects.create(club=self.club, title="Match report", body="Body.")
self.client.force_login(self.make_coach_manager())
images = [
SimpleUploadedFile("one.jpg", b"fake-bytes-one", content_type="image/jpeg"),
SimpleUploadedFile("two.jpg", b"fake-bytes-two", content_type="image/jpeg"),
]
self.club_post("news_photo_upload", {"images": images}, item.pk)
self.assertEqual(item.photos.count(), 2)
self.assertEqual(item.photos.filter(is_main=True).count(), 1)
def test_set_main_moves_the_main_flag(self):
item = News.objects.create(club=self.club, title="Match report", body="Body.")
first = NewsPhoto.objects.create(news_item=item, image=SimpleUploadedFile("one.jpg", b"one", content_type="image/jpeg"), is_main=True)
second = NewsPhoto.objects.create(news_item=item, image=SimpleUploadedFile("two.jpg", b"two", content_type="image/jpeg"), is_main=False)
self.client.force_login(self.make_coach_manager())
self.club_post("news_photo_set_main", {}, item.pk, second.pk)
first.refresh_from_db()
second.refresh_from_db()
self.assertFalse(first.is_main)
self.assertTrue(second.is_main)
def test_deleting_a_photo_removes_it(self):
item = News.objects.create(club=self.club, title="Match report", body="Body.")
photo = NewsPhoto.objects.create(news_item=item, image=SimpleUploadedFile("one.jpg", b"one", content_type="image/jpeg"))
self.client.force_login(self.make_coach_manager())
self.club_post("news_photo_delete", {}, item.pk, photo.pk)
self.assertFalse(NewsPhoto.objects.filter(pk=photo.pk).exists())

View File

@@ -42,6 +42,16 @@ urlpatterns = [
path("teams/<uuid:pk>/edit/", views.TeamUpdateView.as_view(), name="team_update"),
path("roster/", views.RosterListView.as_view(), name="roster_list"),
path("staff/", views.StaffListView.as_view(), name="staff_list"),
# News
path("news/", views.NewsListView.as_view(), name="news_list"),
path("news/new/", views.NewsCreateView.as_view(), name="news_create"),
path("news/<uuid:pk>/", views.NewsDetailView.as_view(), name="news_detail"),
path("news/<uuid:pk>/edit/", views.NewsUpdateView.as_view(), name="news_update"),
path("news/<uuid:pk>/publish/", views.NewsPublishView.as_view(), name="news_publish"),
path("news/<uuid:pk>/unpublish/", views.NewsUnpublishView.as_view(), name="news_unpublish"),
path("news/<uuid:pk>/photos/", views.NewsPhotoUploadView.as_view(), name="news_photo_upload"),
path("news/<uuid:pk>/photos/<uuid:photo_pk>/set-main/", views.NewsPhotoSetMainView.as_view(), name="news_photo_set_main"),
path("news/<uuid:pk>/photos/<uuid:photo_pk>/delete/", views.NewsPhotoDeleteView.as_view(), name="news_photo_delete"),
# Calendar
path("events/", views.EventListView.as_view(), name="event_list"),
path("event-series/", views.EventSeriesListView.as_view(), name="event_series_list"),

View File

@@ -6,11 +6,12 @@ from django.urls import reverse
from django.utils import timezone
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext
from django.views.generic import CreateView, DetailView, FormView, ListView, TemplateView, UpdateView, View
from club.mixins import ClubAdminRequiredMixin, ClubStaffRequiredMixin
from club.mixins import ClubAdminRequiredMixin, ClubStaffRequiredMixin, NewsAuthorRequiredMixin, NewsEditRequiredMixin, NewsPublisherRequiredMixin
from club.models import ClubMembership, ClubRole, Season
from club.services.access import current_season, members_visible_to
from club.services.access import can_edit_news, can_publish_news, current_season, members_visible_to
from club.services.fees import mark_as_paid, record_payment, remaining_balance
from controlpanel.messages import notify
from controlpanel.mixins import RedirectOnInvalidMixin
@@ -20,6 +21,7 @@ from formbuilder.models import Form as FormBuilderForm
from formbuilder.models import Submission
from members.models import Family, FamilyMembership, Member
from members.services.family import add_child_to_family, add_parent_to_family, attach_to_family, detach_from_family, grant_login, register_family
from news.models import News, NewsPhoto
from shop.models import Discount, Invoice, Order, Product
from teams.models import Position, StaffAssignment, Team, TeamMembership
@@ -34,6 +36,9 @@ from .forms import (
GrantLoginForm,
MemberForm,
MemberImportUploadForm,
NewsForm,
NewsPhotoUploadForm,
NewsPublishForm,
PositionForm,
RecordFeePaymentForm,
TeamForm,
@@ -723,8 +728,8 @@ class TeamDetailView(ClubStaffRequiredMixin, DetailView):
#: What each non-default role actually grants -- shown on the roles overview so an
#: admin granting one knows what they're handing out. See club/services/access.py.
ROLE_DESCRIPTIONS = {
ClubRole.Roles.ADMIN: _("Full control over the club: memberships, positions, roles, teams, shop, and every event."),
ClubRole.Roles.EDITOR: _("Can create and edit events, but not memberships, positions, roles, or shop settings."),
ClubRole.Roles.ADMIN: _("Full control over the club: memberships, positions, roles, teams, shop, every event, and news."),
ClubRole.Roles.EDITOR: _("Can create and edit events, and publish news items, but not memberships, positions, roles, or shop settings."),
}
@@ -950,6 +955,169 @@ class PositionUpdateView(ClubAdminRequiredMixin, UpdateView):
return super().get_context_data(update_view=True, **kwargs)
# --- News: draft/edit is broad (any coach_manager/editor/admin), but only EDITOR/ADMIN
# may publish -- the release flow the news app exists for ---------------------------
class NewsListView(ClubStaffRequiredMixin, ListView):
template_name = "management/news_list.html"
context_object_name = "news_items"
def get_queryset(self):
return News.objects.filter(club=self.request.club).prefetch_related("teams")
class NewsCreateView(NewsAuthorRequiredMixin, CreateView):
model = News
form_class = NewsForm
template_name = "management/news_form.html"
def get_form_kwargs(self):
return super().get_form_kwargs() | {"club": self.request.club}
def form_valid(self, form):
form.instance.club = self.request.club
form.instance.created_by = Member.objects.filter(user=self.request.user).first()
response = super().form_valid(form)
body = _("%(news)s” created.") % {"news": self.object}
notify(self.request, f"s|{_('News item created')}|{body}")
return response
def get_success_url(self):
return reverse("management:news_detail", args=[self.object.pk])
class NewsDetailView(ClubStaffRequiredMixin, DetailView):
template_name = "management/news_detail.html"
context_object_name = "news_item"
def get_queryset(self):
return News.objects.filter(club=self.request.club).prefetch_related("teams", "photos")
def get_context_data(self, **kwargs):
return super().get_context_data(
can_edit=can_edit_news(self.request.user, self.object),
can_publish=can_publish_news(self.request.user, self.request.club),
publish_form=NewsPublishForm(),
photo_upload_form=NewsPhotoUploadForm(),
**kwargs,
)
class NewsUpdateView(NewsEditRequiredMixin, UpdateView):
model = News
form_class = NewsForm
template_name = "management/news_form.html"
def get_news_item(self):
return get_object_or_404(News.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def get_queryset(self):
return News.objects.filter(club=self.request.club)
def get_form_kwargs(self):
return super().get_form_kwargs() | {"club": self.request.club}
def form_valid(self, form):
response = super().form_valid(form)
body = _("%(news)s” updated.") % {"news": self.object}
notify(self.request, f"s|{_('News item updated')}|{body}")
return response
def get_success_url(self):
return reverse("management:news_detail", args=[self.object.pk])
def get_context_data(self, **kwargs):
return super().get_context_data(update_view=True, **kwargs)
class NewsPublishView(NewsPublisherRequiredMixin, RedirectOnInvalidMixin, FormView):
form_class = NewsPublishForm
http_method_names = ["post"]
invalid_redirect_url_name = "management:news_detail"
def get_invalid_redirect_kwargs(self):
return {"pk": self.kwargs["pk"]}
def form_valid(self, form):
news_item = get_object_or_404(News.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
news_item.publish(at=form.cleaned_data["published_at"])
if news_item.is_scheduled:
body = _("%(news)s” is scheduled to go live on %(date)s.") % {"news": news_item, "date": news_item.published_at}
else:
body = _("%(news)s” is now live.") % {"news": news_item}
notify(self.request, f"s|{_('News item published')}|{body}")
return redirect("management:news_detail", pk=news_item.pk)
class NewsUnpublishView(NewsPublisherRequiredMixin, View):
def post(self, request, pk):
news_item = get_object_or_404(News.objects.filter(club=request.club), pk=pk)
news_item.unpublish()
body = _("%(news)s” is back to a draft.") % {"news": news_item}
notify(request, f"w|{_('News item unpublished')}|{body}")
return redirect("management:news_detail", pk=news_item.pk)
class NewsPhotoUploadView(NewsEditRequiredMixin, RedirectOnInvalidMixin, FormView):
"""One NewsPhoto per uploaded file; if the item had none yet, the first one
in this batch is auto-flagged main so there's always one once any photo exists."""
form_class = NewsPhotoUploadForm
http_method_names = ["post"]
invalid_redirect_url_name = "management:news_detail"
def get_news_item(self):
return get_object_or_404(News.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def get_invalid_redirect_kwargs(self):
return {"pk": self.kwargs["pk"]}
def form_valid(self, form):
news_item = self.get_news_item()
has_main = news_item.photos.filter(is_main=True).exists()
count = 0
for image in form.cleaned_data["images"]:
NewsPhoto.objects.create(news_item=news_item, image=image, is_main=not has_main)
has_main = True
count += 1
body = ngettext("%(count)d photo added.", "%(count)d photos added.", count) % {"count": count}
notify(self.request, f"s|{_('Photos added')}|{body}")
return redirect("management:news_detail", pk=news_item.pk)
class NewsPhotoSetMainView(NewsEditRequiredMixin, View):
def get_news_item(self):
return get_object_or_404(News.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def post(self, request, pk, photo_pk):
news_item = self.get_news_item()
photo = get_object_or_404(NewsPhoto, pk=photo_pk, news_item=news_item)
with transaction.atomic():
NewsPhoto.objects.filter(news_item=news_item).update(is_main=False)
photo.is_main = True
photo.save(update_fields=["is_main"])
return redirect("management:news_detail", pk=news_item.pk)
class NewsPhotoDeleteView(NewsEditRequiredMixin, View):
def get_news_item(self):
return get_object_or_404(News.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def post(self, request, pk, photo_pk):
news_item = self.get_news_item()
photo = get_object_or_404(NewsPhoto, pk=photo_pk, news_item=news_item)
photo.delete()
notify(request, f"w|{_('Photo removed')}|{_('The photo was removed.')}")
return redirect("management:news_detail", pk=news_item.pk)
class RosterListView(ClubStaffRequiredMixin, StubListMixin, ListView):
page_title = _("Roster")