Notify members when a new event is planned

New events.tasks.notify_new_event, scheduled from
management.views.EventCreateView.form_valid -- the deliberate "a staff
member planned one new event" action, not every Event row that happens
to get created. A recurring series' rolling-horizon extension
(extend_event_series) and bulk fixture imports are NOT wired to this on
purpose: either would flood everyone with one push per occurrence
instead of the single, deliberate action this is meant to catch.

Notifies whoever is still NO_RESPONSE right after creation -- exactly
"everyone who needs to respond", using the existing notify_members() ->
Notification -> mobile.signals push chain already in place, so this is
in-app (visible in the M7 inbox, mark read/mark-all-read) and a push
notification both, with no new plumbing needed there.

Generalized mobile.views.NotificationsView's News-only "tap to open the
source" handling (previously isinstance(source, News)) into
_notification_source_link, covering Event sources too -- tapping an
"new event" notification now marks it read and opens the event's answer
screen, the same way a news notification already opened the article.

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 17:03:26 +02:00
parent 1bf3a9d37a
commit 81f8f7f7dd
6 changed files with 101 additions and 28 deletions

View File

@@ -1,10 +1,11 @@
{% load i18n %}
{% comment %}
One M7 notification row -- included from notifications.html once per day
bucket. Expects ``row`` ({notification, news_item}) in scope. The whole
row is the tap target (a submit button styled full-width, since a POST
form can't wrap another form/link) -- tapping always marks it read, and
also navigates to the linked News item when there is one (see
bucket. Expects ``row`` ({notification, source_label}) in scope. The
whole row is the tap target (a submit button styled full-width, since a
POST form can't wrap another form/link) -- tapping always marks it read,
and also navigates to the linked News/Event when its source resolves to
one (see mobile.views._notification_source_link and
NotificationsView.post). A club-coloured bar marks it unread, same
treatment as management/templates/management/home.html's own
notifications card.
@@ -18,7 +19,7 @@
<span class="min-w-0 flex-1">
<span class="block text-sm font-semibold text-ink">{{ row.notification.title }}</span>
<span class="block truncate text-xs text-muted">{{ row.notification.body|truncatechars:120 }}</span>
{% if row.news_item %}<span class="block font-display text-[11px] font-extrabold text-club uppercase tracking-wide">{% trans "Club news" %}</span>{% endif %}
{% if row.source_label %}<span class="block font-display text-[11px] font-extrabold text-club uppercase tracking-wide">{{ row.source_label }}</span>{% endif %}
</span>
<span class="shrink-0 font-mono text-[11px] text-dim">{% blocktrans with time=row.notification.created|timesince %}{{ time }} ago{% endblocktrans %}</span>
</button>

View File

@@ -897,6 +897,17 @@ class NotificationsViewTests(TestCase):
redirect_response = self._post({"action": "mark_read", "notification_id": str(notification.pk)})
self.assertRedirects(redirect_response, reverse("mobile:news_detail", kwargs={"slug": news_item.slug}), fetch_redirect_response=False)
def test_notification_with_an_event_source_is_labelled_and_mark_read_redirects_to_it(self):
event = Event.objects.create(club=self.club, title="Practice", start=timezone.now() + datetime.timedelta(days=1))
notification = Notification.objects.create(club=self.club, member=self.member, title="New event", body="Body.", source=event)
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, "New event")
redirect_response = self._post({"action": "mark_read", "notification_id": str(notification.pk)})
self.assertRedirects(redirect_response, reverse("mobile:event_detail", kwargs={"pk": event.pk}), fetch_redirect_response=False)
def test_empty_account_gets_a_graceful_empty_state(self):
bare_user = User.objects.create_user(email="new@example.com", password="pw-secret-123")
self.client.force_login(bare_user)

View File

@@ -669,18 +669,32 @@ class EditProfileView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
)
def _notification_source_link(source):
"""(label, url) for a notification's ``source`` when it's something this
app has a detail page for -- (None, None) otherwise. One place both
NotificationsView.get_context_data (the row's own label) and .post (the
tap-to-mark-read redirect target) read from, so a new source type only
needs adding here."""
if isinstance(source, News):
return _("Club news"), reverse("mobile:news_detail", kwargs={"slug": source.slug})
if isinstance(source, Event):
return _("Event"), reverse("mobile:event_detail", kwargs={"pk": source.pk})
return None, None
class NotificationsView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
"""M7 -- design_handoff_rosterchief_platform/README.md's M7 section
("Inbox"). The mockup shows rich per-type cards (RSVP-needed, medical
form missing, invoice due, line-up published, ...) with inline quick
actions, but notifications.models.Notification is generic -- title/body/
created/read_at plus an optional ``source`` -- and the only thing that
creates member-facing rows today is news.tasks.notify_news_published.
There's no type/category field to key a richer layout or the mockup's
"Action"/"Club" filter off, so this is deliberately a flat, generic list:
day-grouped ("Today"/"Earlier this week"/"Older", echoing Calendar's own
"This week"/"Next week" bucketing) with an unread treatment and a link to
the underlying News item when ``source`` happens to resolve to one.
created/read_at plus an optional ``source`` -- and the only things that
create member-facing rows today are news.tasks.notify_news_published and
events.tasks.notify_new_event. There's no type/category field to key a
richer layout or the mockup's "Action"/"Club" filter off, so this is
deliberately a flat, generic list: day-grouped ("Today"/"Earlier this
week"/"Older", echoing Calendar's own "This week"/"Next week" bucketing)
with an unread treatment and a link to the underlying News/Event when
``source`` resolves to one (see _notification_source_link above).
Scoped to every one of ``self.managed_people`` (not just scope_person) --
same scope PersonScopeMixin.get_context_data already uses for
@@ -704,9 +718,8 @@ class NotificationsView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
notifications = Notification.objects.filter(club=self.request.club, member__in=self.managed_people).select_related("member").order_by("-created")
for notification in notifications:
source = notification.source
news_item = source if isinstance(source, News) else None
row = {"notification": notification, "news_item": news_item}
source_label, _url = _notification_source_link(notification.source)
row = {"notification": notification, "source_label": source_label}
created_date = timezone.localtime(notification.created).date()
if created_date == local_today:
@@ -733,12 +746,12 @@ class NotificationsView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
notification.read_at = timezone.now()
notification.save(update_fields=["read_at", "modified"])
# A row whose source resolves to a News item doubles as a link to
# it (see the class docstring) -- the plain-POST tap that marks
# it read also lands the member on the article, no separate
# "next" field needed since the server already has the source.
if isinstance(notification.source, News):
return HttpResponseRedirect(reverse("mobile:news_detail", kwargs={"slug": notification.source.slug}))
return HttpResponseRedirect(reverse("mobile:notifications"))
# A row whose source resolves to something this app has a detail
# page for doubles as a link to it (see _notification_source_link)
# -- the plain-POST tap that marks it read also lands the member
# there, no separate "next" field needed since the server already
# has the source.
_label, url = _notification_source_link(notification.source)
return HttpResponseRedirect(url or reverse("mobile:notifications"))
return HttpResponseBadRequest(_("Unknown action."))