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

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