Add a news review workflow and a staff notification area

News gains a PENDING_REVIEW status between draft and published. A
non-editor author (can_add_news but not can_publish_news -- a
coach_manager, not an ADMIN/EDITOR) gets a "Send for review" button
instead of Publish; an editor/admin always sees Publish directly, no
review step. Submitting notifies every ADMIN/EDITOR in-app only (see
notify_members' new send_email=False) -- a review queue that emailed
on every submission would get noisy fast.

The notification area itself: a topbar bell (badge + dropdown, same
<details>/<summary> convention as the sidebar's user-menu, generalised
to a shared .dismissable-details close handler) visible on every page,
plus a fuller "Notifications" card on the dashboard, both fed by a new
notification_bell context processor. "Mark all read" clears the
signed-in staff member's own unread notifications for this club.

This is the reusable notification system's first consumer beyond news
publishing itself -- validates that notify_members()/Notification
generalise the way they were meant to.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
2026-08-21 09:52:59 +02:00
parent 20a44a915f
commit 0ecdeac354
16 changed files with 421 additions and 26 deletions

View File

@@ -49,7 +49,9 @@ from members.models import Family, FamilyMembership, Group, GroupMembership, Mem
from members.services.claims import ClaimError, approve_claim, children_awaiting_a_parent, reject_claim, send_claim_approved_email, suggested_children
from members.services.family import add_child_to_family, add_parent_to_family, attach_to_family, detach_from_family, get_or_create_login_user, grant_login, register_family
from news.models import News, NewsPhoto
from news.services import notify_editors_of_pending_review
from news.tasks import notify_news_published
from notifications.models import Notification
from shop.models import Discount, Invoice, Order, Product
from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership, TeamPhoto
from teams.services import eligible_roster_members
@@ -214,6 +216,23 @@ def group_by_family(members):
return groups, ungrouped
class NotificationMarkAllReadView(ClubStaffRequiredMixin, View):
"""The topbar bell dropdown's "Mark all read" action -- every one of the
signed-in staff member's own unread notifications in this club, not just
the handful the dropdown actually shows (see
management.context_processors.notification_bell's [:8] slice)."""
def post(self, request):
member = Member.objects.filter(user=request.user).first()
if member is not None:
Notification.objects.filter(club=request.club, member=member, read_at__isnull=True).update(read_at=timezone.now())
next_url = request.POST.get("next")
if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}, require_https=request.is_secure()):
return redirect(next_url)
return redirect("management:home")
class MemberListView(ClubStaffRequiredMixin, ListView):
"""One flat list, everybody -- family is a column, not a grouping. Each
member's family/role is attached in Python below (from a single query over
@@ -2065,7 +2084,11 @@ class NewsListView(ClubStaffRequiredMixin, ListView):
status_filter = self.request.GET.get("status", "all")
now = timezone.now()
if status_filter == "draft":
queryset = queryset.filter(status=News.Status.DRAFT)
# Pending review rolls into the Drafts chip -- see _news_preview.html
# for how it's still told apart there (its own badge colour), rather
# than adding a fifth, rarely-used chip next to the four D8 already
# fits on one line.
queryset = queryset.filter(status__in=[News.Status.DRAFT, News.Status.PENDING_REVIEW])
elif status_filter == "scheduled":
queryset = queryset.filter(status=News.Status.PUBLISHED, published_at__gt=now)
elif status_filter == "published":
@@ -2093,7 +2116,7 @@ class NewsListView(ClubStaffRequiredMixin, ListView):
status_filter=self.request.GET.get("status", "all"),
counts={
"all": base.count(),
"draft": base.filter(status=News.Status.DRAFT).count(),
"draft": base.filter(status__in=[News.Status.DRAFT, News.Status.PENDING_REVIEW]).count(),
"scheduled": base.filter(status=News.Status.PUBLISHED, published_at__gt=now).count(),
"published": base.filter(status=News.Status.PUBLISHED, published_at__lte=now).count(),
},
@@ -2184,6 +2207,25 @@ class NewsDeleteView(NewsEditRequiredMixin, View):
return redirect("management:news_list")
class NewsSubmitForReviewView(NewsEditRequiredMixin, View):
"""A non-editor author's hand-off to an editor/admin -- the button
_news_preview.html shows instead of Publish when can_publish is False.
Gated the same as editing (can_edit_news, broad while it's a draft), not
can_publish_news -- that's exactly who this exists for."""
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):
news_item = self.get_news_item()
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 redirect("management:news_detail", pk=news_item.pk)
class NewsPublishView(NewsPublisherRequiredMixin, RedirectOnInvalidMixin, FormView):
form_class = NewsPublishForm
http_method_names = ["post"]