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:
18
news/migrations/0005_alter_news_status.py
Normal file
18
news/migrations/0005_alter_news_status.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user