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:
@@ -33,6 +33,7 @@ from management.recurrence_ui import build_rrule, describe_rrule, parse_rrule
|
||||
from members.models import Family, FamilyMembership, Group, GroupMembership, Member, ParentClaim
|
||||
from members.services.claims import children_awaiting_a_parent
|
||||
from news.models import News, NewsPhoto
|
||||
from notifications.models import Notification
|
||||
from shop.models import Order
|
||||
from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership, TeamPhoto
|
||||
from teams.services import eligible_roster_members
|
||||
@@ -4172,6 +4173,46 @@ class NewsManagementTests(ManagementTestBase):
|
||||
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
def test_a_coach_manager_can_submit_a_draft_for_review(self):
|
||||
item = News.objects.create(club=self.club, title="Draft item", body="Body.")
|
||||
self.client.force_login(self.coach_manager)
|
||||
|
||||
self.club_post("news_submit_for_review", {}, item.pk)
|
||||
|
||||
item.refresh_from_db()
|
||||
self.assertEqual(item.status, News.Status.PENDING_REVIEW)
|
||||
|
||||
def test_submitting_for_review_notifies_editors_in_app_only(self):
|
||||
editor_member = Member.objects.get(user=self.editor)
|
||||
item = News.objects.create(club=self.club, title="Draft item", body="Body.")
|
||||
self.client.force_login(self.coach_manager)
|
||||
|
||||
self.club_post("news_submit_for_review", {}, item.pk)
|
||||
|
||||
self.assertTrue(Notification.objects.filter(member=editor_member, title__contains="Draft item").exists())
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
def test_plain_staff_cannot_submit_for_review(self):
|
||||
item = News.objects.create(club=self.club, title="Draft item", body="Body.")
|
||||
self.client.force_login(self.plain_staff)
|
||||
|
||||
response = self.club_post("news_submit_for_review", {}, item.pk)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
item.refresh_from_db()
|
||||
self.assertEqual(item.status, News.Status.DRAFT)
|
||||
|
||||
def test_an_editor_still_publishes_directly_without_a_review_step(self):
|
||||
# The button _news_preview.html shows for can_publish is Publish, never
|
||||
# Send for review -- editors skip the queue entirely.
|
||||
item = News.objects.create(club=self.club, title="Draft item", body="Body.")
|
||||
self.client.force_login(self.editor)
|
||||
|
||||
response = self.club_get("news_detail", item.pk)
|
||||
|
||||
self.assertContains(response, reverse("management:news_publish", args=[item.pk]))
|
||||
self.assertNotContains(response, reverse("management:news_submit_for_review", args=[item.pk]))
|
||||
|
||||
def test_unpublishing_reverts_to_draft(self):
|
||||
item = News.objects.create(club=self.club, title="Live item", body="Body.")
|
||||
item.publish()
|
||||
@@ -7097,6 +7138,92 @@ class SidebarCounterTests(ManagementTestBase):
|
||||
self.assertNotContains(response, reverse("management:referee_management"))
|
||||
|
||||
|
||||
class NotificationBellTests(ManagementTestBase):
|
||||
"""The topbar bell (every page) and the dashboard's fuller list -- see
|
||||
management.context_processors.notification_bell. Unlike SidebarCounterTests'
|
||||
admin-only badges above, any signed-in staff member with a Member row can
|
||||
have notifications."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
def test_none_when_nothing_is_pending_but_zero_is_shown_once_therere_notifications(self):
|
||||
response = self.club_get("home")
|
||||
|
||||
self.assertEqual(response.context["unread_notification_count"], 0)
|
||||
self.assertEqual(list(response.context["recent_notifications"]), [])
|
||||
|
||||
def test_unread_count_and_recent_list_reflect_real_notifications(self):
|
||||
Notification.objects.create(club=self.club, member=self.admin_member, title="Big win", body="We won 3-0.")
|
||||
|
||||
response = self.club_get("home")
|
||||
|
||||
self.assertEqual(response.context["unread_notification_count"], 1)
|
||||
self.assertContains(response, "Big win")
|
||||
|
||||
def test_a_read_notification_does_not_count_as_unread(self):
|
||||
Notification.objects.create(club=self.club, member=self.admin_member, title="Old news", body="Body.", read_at=timezone.now())
|
||||
|
||||
response = self.club_get("home")
|
||||
|
||||
self.assertEqual(response.context["unread_notification_count"], 0)
|
||||
|
||||
def test_only_this_members_own_notifications_show(self):
|
||||
other_member = Member.objects.create(first_name="Other", last_name="Staff")
|
||||
Notification.objects.create(club=self.club, member=other_member, title="Not for you", body="Body.")
|
||||
|
||||
response = self.club_get("home")
|
||||
|
||||
self.assertEqual(response.context["unread_notification_count"], 0)
|
||||
self.assertNotContains(response, "Not for you")
|
||||
|
||||
def test_the_bell_badge_renders_on_every_page_not_just_the_dashboard(self):
|
||||
Notification.objects.create(club=self.club, member=self.admin_member, title="Big win", body="We won 3-0.")
|
||||
|
||||
response = self.club_get("member_list")
|
||||
|
||||
self.assertContains(response, 'id="notification-menu"')
|
||||
|
||||
def test_anonymous_gets_no_bell(self):
|
||||
self.client.logout()
|
||||
|
||||
response = self.client.get(reverse("account_login"), HTTP_HOST="ajax-united.rosterchief.app")
|
||||
|
||||
self.assertNotContains(response, 'id="notification-menu"')
|
||||
|
||||
|
||||
class NotificationMarkAllReadViewTests(ManagementTestBase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
def test_marks_every_unread_notification_read(self):
|
||||
first = Notification.objects.create(club=self.club, member=self.admin_member, title="One", body="Body.")
|
||||
second = Notification.objects.create(club=self.club, member=self.admin_member, title="Two", body="Body.")
|
||||
|
||||
self.club_post("notification_mark_all_read", {})
|
||||
|
||||
first.refresh_from_db()
|
||||
second.refresh_from_db()
|
||||
self.assertIsNotNone(first.read_at)
|
||||
self.assertIsNotNone(second.read_at)
|
||||
|
||||
def test_does_not_touch_another_members_notification(self):
|
||||
other_member = Member.objects.create(first_name="Other", last_name="Staff")
|
||||
other_notification = Notification.objects.create(club=self.club, member=other_member, title="Not yours", body="Body.")
|
||||
|
||||
self.club_post("notification_mark_all_read", {})
|
||||
|
||||
other_notification.refresh_from_db()
|
||||
self.assertIsNone(other_notification.read_at)
|
||||
|
||||
def test_redirects_to_next_when_given(self):
|
||||
response = self.club_post("notification_mark_all_read", {"next": reverse("management:member_list")})
|
||||
|
||||
self.assertRedirects(response, reverse("management:member_list"))
|
||||
|
||||
|
||||
class ManagementListPaginationTests(ManagementTestBase):
|
||||
"""paginate_by on the six paginated lists (MemberListView/EventListView/
|
||||
TeamListView/GroupListView/NewsListView/FamilyListView) and the shared
|
||||
|
||||
Reference in New Issue
Block a user