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

@@ -298,3 +298,30 @@ def sidebar_counters(request):
"pending_parent_claims_count": ParentClaim.objects.filter(club=club, status=ParentClaim.Status.PENDING).count(),
"games_missing_referees_count": games_missing_referees_count(club, limit=int(RefereeManagementDashboardView.DEFAULT_RANGE)),
}
def notification_bell(request):
"""The topbar bell's badge count and dropdown contents -- every signed-in
staff member can have notifications (not just admins, unlike
sidebar_counters' admin-only queues above), since notifications.Notification
is keyed to whichever Member they are, not to a role.
None (not 0) when there's no club/signed-in user/Member row to key off --
same "hidden means not applicable, 0 means an empty inbox" distinction
sidebar_counters draws."""
club = getattr(request, "club", None)
if club is None or not request.user.is_authenticated:
return {"unread_notification_count": None, "recent_notifications": None}
from members.models import Member
from notifications.models import Notification
member = Member.objects.filter(user=request.user).first()
if member is None:
return {"unread_notification_count": None, "recent_notifications": None}
notifications = Notification.objects.filter(club=club, member=member).order_by("-created")[:8]
return {
"unread_notification_count": Notification.objects.filter(club=club, member=member, read_at__isnull=True).count(),
"recent_notifications": notifications,
}

View File

@@ -16,6 +16,8 @@
<div class="flex flex-wrap items-center gap-2">
{% if news_item.status == "draft" %}
<span class="badge">{% trans "Draft" %}</span>
{% elif news_item.status == "pending_review" %}
<span class="badge badge-warning">{% trans "Pending review" %}</span>
{% elif news_item.is_scheduled %}
<span class="badge badge-info">{% blocktrans with date=news_item.published_at %}Scheduled for {{ date }}{% endblocktrans %}</span>
{% else %}
@@ -29,11 +31,17 @@
<button class="btn btn-outline btn-error btn-sm gap-2" type="button" onclick="document.getElementById('delete_news_modal').showModal()">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
{% endif %}
{% if can_publish %}
{% if news_item.status == "draft" %}
<button class="btn btn-primary btn-sm gap-2" type="button" onclick="document.getElementById('publish_modal').showModal()">{% lucide "upload" size=14 %} {% trans "Publish" %}</button>
{% else %}
{% if news_item.status == "published" %}
<button class="btn btn-outline btn-warning btn-sm gap-2" type="button" onclick="document.getElementById('unpublish_modal').showModal()">{% lucide "eye-off" size=14 %} {% trans "Unpublish" %}</button>
{% else %}
<button class="btn btn-primary btn-sm gap-2" type="button" onclick="document.getElementById('publish_modal').showModal()">{% lucide "upload" size=14 %} {% trans "Publish" %}</button>
{% endif %}
{% elif can_edit and news_item.status == "draft" %}
{# Whoever can edit but not publish (a coach_manager, not an editor/admin -- see club.services.access.can_add_news vs can_publish_news) hands it off instead. #}
<form method="post" action="{% url 'management:news_submit_for_review' news_item.pk %}">
{% csrf_token %}
<button class="btn btn-primary btn-sm gap-2" type="submit">{% lucide "send" size=14 %} {% trans "Send for review" %}</button>
</form>
{% endif %}
</div>
</div>

View File

@@ -79,7 +79,7 @@
Opens upward (bottom-full): the trigger sits at the very bottom of the
sidebar, so a menu opening down would run off the viewport.
{% endcomment %}
<details id="user-menu" class="relative border-t border-sidebar-hairline pt-3.5">
<details id="user-menu" class="dismissable-details relative border-t border-sidebar-hairline pt-3.5">
<summary class="flex cursor-pointer list-none items-center gap-2.5 [&::-webkit-details-marker]:hidden">
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-sidebar-chip font-display text-[13px] font-extrabold text-sidebar-fg">{% if user.member %}{{ user.member.first_name|slice:":1" }}{{ user.member.last_name|slice:":1" }}{% else %}?{% endif %}</span>
<span class="min-w-0 flex-1">
@@ -103,6 +103,40 @@
<span class="font-display text-2xl font-extrabold text-ink uppercase">{% block heading %}{% block topbar_title %}Management{% endblock topbar_title %}{% endblock heading %}</span>
{% block topbar_context %}{% endblock topbar_context %}
<div class="flex-1"></div>
{% if unread_notification_count is not None %}
{# Same <details>/<summary> convention as the sidebar's own user-menu below -- no JS needed to open it, only the shared outside-click/Escape listener to close it. #}
<details id="notification-menu" class="dismissable-details relative">
<summary class="btn btn-outline btn-square list-none [&::-webkit-details-marker]:hidden" aria-label="{% trans "Notifications" %}">
{% lucide "bell" size=16 %}
{% if unread_notification_count %}
<span class="absolute -top-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-club px-1 font-mono text-[9px] font-bold text-white">{{ unread_notification_count }}</span>
{% endif %}
</summary>
<div class="absolute top-full right-0 z-20 mt-2 w-80 rounded-xl border border-line bg-white p-1.5 shadow-lg">
<div class="flex items-center justify-between px-2.5 py-2">
<span class="font-display text-xs font-bold tracking-[.08em] text-ink uppercase">{% trans "Notifications" %}</span>
{% if unread_notification_count %}
<form method="post" action="{% url 'management:notification_mark_all_read' %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button class="text-xs text-club hover:underline" type="submit">{% trans "Mark all read" %}</button>
</form>
{% endif %}
</div>
<div class="flex max-h-96 flex-col overflow-y-auto">
{% for notification in recent_notifications %}
<div class="flex flex-col gap-0.5 rounded-lg px-2.5 py-2 {% if not notification.read_at %}bg-subhead{% endif %}">
<span class="text-[13px] font-semibold text-ink">{{ notification.title }}</span>
<span class="text-xs text-muted">{{ notification.body|truncatechars:100 }}</span>
<span class="font-mono text-[10px] text-dim">{{ notification.created|timesince }} {% trans "ago" %}</span>
</div>
{% empty %}
<p class="px-2.5 py-6 text-center text-sm text-muted">{% trans "Nothing yet." %}</p>
{% endfor %}
</div>
</div>
</details>
{% endif %}
<div class="flex items-center gap-2">
{% block actions %}{% endblock actions %}
</div>
@@ -140,13 +174,19 @@
<script>
(() => {
const menu = document.getElementById("user-menu");
if (!menu) return;
// <details>, not a click-driven dropdown -- no JS needed to open either
// the user menu or the notification bell, only this shared outside-
// click/Escape listener to close whichever one is open (a <details>
// stays open until its own <summary> is clicked again otherwise).
const menus = document.querySelectorAll(".dismissable-details");
if (!menus.length) return;
document.addEventListener("click", (event) => {
if (menu.open && !menu.contains(event.target)) menu.open = false;
menus.forEach((menu) => {
if (menu.open && !menu.contains(event.target)) menu.open = false;
});
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") menu.open = false;
if (event.key === "Escape") menus.forEach((menu) => { menu.open = false; });
});
})();

View File

@@ -168,6 +168,35 @@
{# --- right: this weekend + news ----------------------------------- #}
<div class="flex flex-col gap-5">
{% if recent_notifications is not None %}
{# Same anatomy as "Needs attention" on the left -- see management.context_processors.notification_bell, also shown as a dropdown from the topbar bell on every page; this is the fuller list. #}
<div class="card flex flex-col">
<div class="flex items-center justify-between border-b border-line px-4.5 py-3.5">
<span class="font-display text-base font-extrabold tracking-[.08em] text-ink uppercase">{% trans "Notifications" %}</span>
{% if unread_notification_count %}
<form method="post" action="{% url 'management:notification_mark_all_read' %}">
{% csrf_token %}
<button class="text-xs font-semibold text-club hover:underline" type="submit">{% trans "Mark all read" %}</button>
</form>
{% endif %}
</div>
<div class="flex flex-col">
{% for notification in recent_notifications %}
<div class="flex items-center gap-3.5 border-b border-rule px-4.5 py-3.5">
<span class="h-8.5 w-1.5 shrink-0 rounded-sm {% if not notification.read_at %}bg-club{% else %}bg-line{% endif %}"></span>
<div class="flex-1">
<div class="text-[15px] font-semibold text-ink">{{ notification.title }}</div>
<div class="text-[13px] text-muted">{{ notification.body|truncatechars:120 }}</div>
</div>
<span class="shrink-0 font-mono text-xs text-dim">{{ notification.created|timesince }} {% trans "ago" %}</span>
</div>
{% empty %}
<div class="px-4.5 py-6 text-center text-sm text-muted">{% trans "Nothing yet." %}</div>
{% endfor %}
</div>
</div>
{% endif %}
<div class="rounded-xl bg-ink p-4.5 text-white">
<div class="mb-3.5 font-display text-base font-extrabold tracking-[.08em] uppercase">{% trans "Upcoming" %}</div>
<div class="flex flex-col gap-3">

View File

@@ -27,14 +27,16 @@
<div class="mb-1 flex items-center gap-2.5">
{% if item.status == "draft" %}
<span class="badge badge-sm">{% trans "Draft" %}</span>
{% elif item.status == "pending_review" %}
<span class="badge badge-warning badge-sm">{% trans "Pending review" %}</span>
{% elif item.is_scheduled %}
<span class="badge badge-info badge-sm">{% trans "Scheduled" %}</span>
{% else %}
<span class="badge badge-success badge-sm">{% trans "Published" %}</span>
{% endif %}
<span class="font-mono text-[11px] text-dim">
{% if item.status == "draft" %}{% blocktrans with time=item.modified|timesince %}Edited {{ time }} ago{% endblocktrans %}
{% else %}{{ item.published_at|date:"j M Y" }}{% endif %}
{% if item.status == "published" %}{{ item.published_at|date:"j M Y" }}
{% else %}{% blocktrans with time=item.modified|timesince %}Edited {{ time }} ago{% endblocktrans %}{% endif %}
</span>
</div>
<div class="truncate font-display text-xl leading-[1.05] font-extrabold text-ink uppercase">{{ item.title }}</div>

View File

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

View File

@@ -6,6 +6,7 @@ app_name = "management"
urlpatterns = [
path("", views.HomeView.as_view(), name="home"),
path("notifications/mark-all-read/", views.NotificationMarkAllReadView.as_view(), name="notification_mark_all_read"),
# People
path("members/", views.MemberListView.as_view(), name="member_list"),
path("memberships/", views.MembershipListView.as_view(), name="membership_list"),
@@ -86,6 +87,7 @@ urlpatterns = [
path("news/<uuid:pk>/", views.NewsDetailView.as_view(), name="news_detail"),
path("news/<uuid:pk>/edit/", views.NewsUpdateView.as_view(), name="news_update"),
path("news/<uuid:pk>/delete/", views.NewsDeleteView.as_view(), name="news_delete"),
path("news/<uuid:pk>/submit-for-review/", views.NewsSubmitForReviewView.as_view(), name="news_submit_for_review"),
path("news/<uuid:pk>/publish/", views.NewsPublishView.as_view(), name="news_publish"),
path("news/<uuid:pk>/unpublish/", views.NewsUnpublishView.as_view(), name="news_unpublish"),
path("news/<uuid:pk>/photos/", views.NewsPhotoUploadView.as_view(), name="news_photo_upload"),

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"]

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)

View File

@@ -51,19 +51,23 @@ def _send_email(notification: Notification, emails: list[str]) -> None:
continue
def notify_members(members, *, club, title: str, body: str, source=None) -> list[Notification]:
"""One Notification per member, emailed to everyone recipient_emails()
resolves for them. Always creates the row, even when nobody was reachable
-- that's still true history for a future in-app feed, not a failure to
silently drop."""
def notify_members(members, *, club, title: str, body: str, source=None, send_email: bool = True) -> list[Notification]:
"""One Notification per member. Emailed to everyone recipient_emails()
resolves for them, unless send_email is False -- e.g. a staff review
queue (see news.services.notify_editors_of_pending_review), which is
in-app only: it doesn't need every editor emailed on every submission,
just the topbar/dashboard entry. Always creates the row, even when
nobody was reachable or emailing was skipped -- that's still true
history for the in-app feed, not a failure to silently drop."""
notifications = []
for member in members:
notification = Notification.objects.create(club=club, member=member, title=title, body=body, source=source)
emails = recipient_emails(member)
if emails:
_send_email(notification, emails)
notification.sent_at = timezone.now()
notification.sent_to_emails = emails
notification.save(update_fields=["sent_at", "sent_to_emails", "modified"])
if send_email:
emails = recipient_emails(member)
if emails:
_send_email(notification, emails)
notification.sent_at = timezone.now()
notification.sent_to_emails = emails
notification.save(update_fields=["sent_at", "sent_to_emails", "modified"])
notifications.append(notification)
return notifications

View File

@@ -204,6 +204,7 @@ TEMPLATES = [
"management.context_processors.news_permissions",
"management.context_processors.feature_sections",
"management.context_processors.sidebar_counters",
"management.context_processors.notification_bell",
],
},
},

View File

@@ -2747,6 +2747,9 @@
}
}
}
.-top-1 {
top: calc(var(--spacing) * -1);
}
.top-1 {
top: var(--spacing);
}
@@ -2765,6 +2768,9 @@
.top-full {
top: 100%;
}
.-right-1 {
right: calc(var(--spacing) * -1);
}
.right-0 {
right: 0;
}
@@ -4015,6 +4021,9 @@
.w-72 {
width: calc(var(--spacing) * 72);
}
.w-80 {
width: calc(var(--spacing) * 80);
}
.w-\[7px\] {
width: 7px;
}
@@ -4081,6 +4090,9 @@
.min-w-0 {
min-width: 0;
}
.min-w-4 {
min-width: calc(var(--spacing) * 4);
}
.min-w-40 {
min-width: calc(var(--spacing) * 40);
}
@@ -4828,6 +4840,9 @@
--otp-size: calc(var(--size-field, 0.25rem) * 12);
}
}
.text-\[9px\] {
font-size: 9px;
}
.text-\[10px\] {
font-size: 10px;
}

File diff suppressed because one or more lines are too long