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

@@ -0,0 +1,18 @@
# Generated by Django 6.0.6 on 2026-08-21 07:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('news', '0004_news_body_en_news_title_en'),
]
operations = [
migrations.AlterField(
model_name='news',
name='status',
field=models.CharField(choices=[('draft', 'draft'), ('pending_review', 'pending review'), ('published', 'published')], default='draft', max_length=20, verbose_name='status'),
),
]

View File

@@ -20,6 +20,7 @@ class News(ClubScopedModel):
class Status(models.TextChoices):
DRAFT = "draft", _("draft")
PENDING_REVIEW = "pending_review", _("pending review")
PUBLISHED = "published", _("published")
title = models.CharField(_("title"), max_length=255)
@@ -41,7 +42,7 @@ class News(ClubScopedModel):
teams = models.ManyToManyField(Team, related_name="news_items", blank=True, verbose_name=_("teams"), help_text=_("Leave empty for club-wide news."))
visibility = models.CharField(_("visibility"), max_length=10, choices=Visibility.choices, default=Visibility.INTERNAL)
status = models.CharField(_("status"), max_length=10, choices=Status.choices, default=Status.DRAFT)
status = models.CharField(_("status"), max_length=20, choices=Status.choices, default=Status.DRAFT)
published_at = models.DateTimeField(_("publish date"), null=True, blank=True, help_text=_("When this goes live. In the future to schedule it ahead of time."))
created_by = models.ForeignKey(Member, on_delete=models.SET_NULL, null=True, blank=True, related_name="news_items", verbose_name=_("created by"))
@@ -57,6 +58,15 @@ class News(ClubScopedModel):
def __str__(self):
return self.title
def submit_for_review(self):
"""A non-editor author (see club.services.access.can_add_news vs
can_publish_news) hands a draft off to an editor/admin instead of
publishing it themselves -- see news.services.notify_editors_of_pending_review,
which this doesn't call itself: the notification is the view's job,
same as publish()/unpublish() never send anything either."""
self.status = self.Status.PENDING_REVIEW
self.save(update_fields=["status"])
def publish(self, at=None):
self.status, self.published_at = self.Status.PUBLISHED, at or timezone.now()
self.save(update_fields=["status", "published_at"])

View File

@@ -14,6 +14,7 @@ import markdown as _markdown
import nh3
from django.utils.html import strip_tags
from django.utils.text import Truncator
from django.utils.translation import gettext_lazy as _
_EXTENSIONS = [
"nl2br", # staff type in a plain textarea -- a single Enter should break the line,
@@ -39,3 +40,20 @@ def render_body_excerpt(body: str, *, words: int) -> str:
verbatim in what's meant to be a short teaser."""
plain_text = strip_tags(render_body_html(body))
return Truncator(plain_text).words(words, truncate="")
def notify_editors_of_pending_review(news_item):
"""In-app only (see notifications.services.notify_members's send_email
param) -- a review queue that emailed every editor/admin on every
submission would get noisy fast; the topbar bell and the dashboard card
are enough for this. Called from management.views.NewsSubmitForReviewView,
not from News.submit_for_review() itself, same as publish()/unpublish()
never send anything on their own either."""
from club.models import ClubRole
from members.models import Member
from notifications.services import notify_members
editors = Member.objects.filter(roles__club=news_item.club, roles__role__in=[ClubRole.Roles.ADMIN, ClubRole.Roles.EDITOR]).distinct()
title = _("%(news)s” is ready for review") % {"news": news_item.title}
body = _("%(author)s submitted this news item for review before it can go live.") % {"author": news_item.created_by or _("Someone")}
return notify_members(editors, club=news_item.club, title=title, body=body, source=news_item, send_email=False)

View File

@@ -7,12 +7,13 @@ from django.db import IntegrityError
from django.test import TestCase
from django.utils import timezone
from club.models import Club, ClubMembership, Season
from club.models import Club, ClubMembership, ClubRole, Season
from members.models import Member
from notifications.models import Notification
from teams.models import Position, Team, TeamMembership
from .models import News, NewsPhoto
from .services import notify_editors_of_pending_review
from .tasks import notify_news_published
User = get_user_model()
@@ -76,6 +77,13 @@ class NewsModelTests(TestCase):
self.assertEqual(item.published_at, future)
self.assertTrue(item.is_scheduled)
def test_submit_for_review_moves_a_draft_to_pending_review(self):
item = News.objects.create(club=self.club, title="Item", body="Body.")
item.submit_for_review()
self.assertEqual(item.status, News.Status.PENDING_REVIEW)
def test_unpublish_clears_the_publish_date(self):
item = News.objects.create(club=self.club, title="Item", body="Body.")
item.publish()
@@ -211,3 +219,47 @@ class NotifyNewsPublishedTests(TestCase):
notification = Notification.objects.get(member=member)
self.assertEqual(notification.body, "Bold text.")
self.assertEqual(len(mail.outbox), 1)
class NotifyEditorsOfPendingReviewTests(TestCase):
"""news.services.notify_editors_of_pending_review -- in-app only (no
email), every ADMIN/EDITOR for the club."""
@classmethod
def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
def make_role(self, first_name, role):
member = Member.objects.create(first_name=first_name, last_name="Staff")
ClubRole.objects.create(club=self.club, member=member, role=role)
return member
def test_notifies_admins_and_editors(self):
admin = self.make_role("Ada", ClubRole.Roles.ADMIN)
editor = self.make_role("Ed", ClubRole.Roles.EDITOR)
author = self.make_role("Cara", ClubRole.Roles.MEMBER)
news_item = News.objects.create(club=self.club, title="Draft item", body="Body.", created_by=author)
notify_editors_of_pending_review(news_item)
self.assertTrue(Notification.objects.filter(member=admin).exists())
self.assertTrue(Notification.objects.filter(member=editor).exists())
self.assertFalse(Notification.objects.filter(member=author).exists())
def test_sends_no_email(self):
self.make_role("Ada", ClubRole.Roles.ADMIN)
news_item = News.objects.create(club=self.club, title="Draft item", body="Body.")
notify_editors_of_pending_review(news_item)
self.assertEqual(len(mail.outbox), 0)
self.assertIsNone(Notification.objects.first().sent_at)
def test_the_notification_names_the_author(self):
self.make_role("Ada", ClubRole.Roles.ADMIN)
author = Member.objects.create(first_name="Cara", last_name="Coach")
news_item = News.objects.create(club=self.club, title="Draft item", body="Body.", created_by=author)
notify_editors_of_pending_review(news_item)
self.assertIn("Cara Coach", Notification.objects.first().body)