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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; });
|
||||
});
|
||||
})();
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"]
|
||||
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user