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

@@ -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")