Build M7 Notifications for the mobile app

A flat, day-grouped notification list (Today / Earlier this week / Older)
scoped to every person the account manages, not just the switched-to one
-- matching how the header's unread badge already counts. The design
mock's per-type cards (RSVP-needed, invoice-due, medical-form, ...) have
no backing model support yet -- only news publishing creates a member
notification today -- so this stays generic rather than fabricating
categories; a notification whose source resolves to a News item links
through to it. Mark-all-read and mark-one-read actions on the same URL,
plus a first UI trigger for the push-subscribe plumbing built earlier.

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 13:52:44 +02:00
parent 13f369e10c
commit f41a255b61
6 changed files with 286 additions and 2 deletions

View File

@@ -0,0 +1,25 @@
{% 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
NotificationsView.post). A club-coloured bar marks it unread, same
treatment as management/templates/management/home.html's own
notifications card.
{% endcomment %}
<form method="post" action="{% url "mobile:notifications" %}">
{% csrf_token %}
<input type="hidden" name="action" value="mark_read">
<input type="hidden" name="notification_id" value="{{ row.notification.pk }}">
<button type="submit" class="m-card flex w-full items-center gap-3 p-3.5 text-left">
<span class="h-8.5 w-1.5 shrink-0 rounded-sm {% if not row.notification.read_at %}bg-club{% else %}bg-line{% endif %}"></span>
<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 %}
</span>
<span class="shrink-0 font-mono text-[11px] text-dim">{% blocktrans with time=row.notification.created|timesince %}{{ time }} ago{% endblocktrans %}</span>
</button>
</form>

View File

@@ -0,0 +1,75 @@
{% extends "mobile/base.html" %}
{% load i18n %}
{% comment %}
M7 -- design_handoff_rosterchief_platform/README.md's M7 section ("Inbox").
See NotificationsView's own docstring for why this is a flat, generic,
day-grouped list rather than the mockup's per-type cards: the underlying
model has no type/category field, and nothing but news publishing creates
a notification yet. Scoped to every managed_people, not just scope_person
-- same as unread_notification_count itself (mobile/mixins.py).
{% endcomment %}
{% block content %}
{% if not managed_people %}
<div class="m-card p-6 text-center">
<p class="font-display text-lg font-extrabold text-ink uppercase">{% trans "No one to show yet" %}</p>
<p class="mt-1 text-sm text-muted">{% trans "Once you're linked to a member record, their notifications will show up here." %}</p>
</div>
{% else %}
<div class="m-card flex items-center gap-3 p-4" x-data="{ requested: false }" x-show="!requested">
<div class="min-w-0 flex-1">
<div class="text-sm font-semibold text-ink">{% trans "Enable push notifications" %}</div>
<div class="text-xs text-muted">{% trans "Get notified on this device as soon as something new comes in." %}</div>
</div>
<button type="button" class="btn btn-dark shrink-0" @click="window.rosterchiefPush.subscribe(); requested = true">{% trans "Enable" %}</button>
</div>
{% if unread_notification_count %}
<form method="post" action="{% url "mobile:notifications" %}">
{% csrf_token %}
<input type="hidden" name="action" value="mark_all_read">
<button class="font-display text-xs font-extrabold tracking-wide text-club uppercase" type="submit">{% trans "Mark all read" %}</button>
</form>
{% endif %}
{% if not today and not earlier_this_week and not older %}
<div class="m-card p-6 text-center">
<p class="text-sm text-muted">{% trans "Nothing here yet." %}</p>
</div>
{% else %}
{% if today %}
<div>
<div class="sticky top-0 z-10 bg-paper py-1 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Today" %}</div>
<div class="flex flex-col gap-2">
{% for row in today %}
{% include "mobile/_notification_row.html" %}
{% endfor %}
</div>
</div>
{% endif %}
{% if earlier_this_week %}
<div>
<div class="sticky top-0 z-10 bg-paper py-1 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Earlier this week" %}</div>
<div class="flex flex-col gap-2">
{% for row in earlier_this_week %}
{% include "mobile/_notification_row.html" %}
{% endfor %}
</div>
</div>
{% endif %}
{% if older %}
<div>
<div class="sticky top-0 z-10 bg-paper py-1 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Older" %}</div>
<div class="flex flex-col gap-2">
{% for row in older %}
{% include "mobile/_notification_row.html" %}
{% endfor %}
</div>
</div>
{% endif %}
{% endif %}
{% endif %}
{% endblock content %}

View File

@@ -10,6 +10,7 @@ from club.models import Club, ClubMembership, DuesInvoice, Season
from events.models import Attendance, Event from events.models import Attendance, Event
from members.models import Family, FamilyMembership, Member from members.models import Family, FamilyMembership, Member
from news.models import News from news.models import News
from notifications.models import Notification
from teams.models import Position, Team, TeamMembership from teams.models import Position, Team, TeamMembership
from .models import PushSubscription from .models import PushSubscription
@@ -532,3 +533,112 @@ class EventDetailScreenTests(TestCase):
response = self._get() response = self._get()
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class NotificationsViewTests(TestCase):
"""M7 -- design_handoff_rosterchief_platform/README.md's M7 section
("Inbox"), scoped to every managed_people (see NotificationsView's own
docstring for why, not just scope_person)."""
@classmethod
def setUpTestData(cls):
cls.club = make_club()
today = timezone.localdate()
cls.season = Season.objects.create(club=cls.club, start_date=today - datetime.timedelta(days=30), end_date=today + datetime.timedelta(days=300))
cls.user = User.objects.create_user(email="parent@example.com", password="pw-secret-123")
cls.member = Member.objects.create(first_name="Lars", last_name="Bakker", email="parent@example.com", user=cls.user)
ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season)
cls.family = Family.objects.create(name="Bakker")
FamilyMembership.objects.create(family=cls.family, member=cls.member, role=FamilyMembership.FamilyRole.PARENT)
cls.child = Member.objects.create(first_name="Noor", last_name="Bakker")
FamilyMembership.objects.create(family=cls.family, member=cls.child, role=FamilyMembership.FamilyRole.CHILD)
ClubMembership.objects.create(club=cls.club, member=cls.child, season=cls.season)
def _get(self):
return self.client.get(reverse("mobile:notifications"), HTTP_HOST="ajax-united.rosterchief.app")
def _post(self, data):
return self.client.post(reverse("mobile:notifications"), data=data, HTTP_HOST="ajax-united.rosterchief.app")
def test_requires_login(self):
response = self._get()
self.assertEqual(response.status_code, 302)
def test_lists_notifications_for_every_managed_person_not_just_scope_person(self):
Notification.objects.create(club=self.club, member=self.member, title="For Lars", body="Body.")
Notification.objects.create(club=self.club, member=self.child, title="For Noor", body="Body.")
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, "For Lars")
self.assertContains(response, "For Noor")
def test_notification_for_someone_not_managed_is_excluded(self):
stranger = Member.objects.create(first_name="Someone", last_name="Else")
Notification.objects.create(club=self.club, member=stranger, title="Not yours", body="Body.")
self.client.force_login(self.user)
response = self._get()
self.assertNotContains(response, "Not yours")
def test_mark_all_read_updates_every_unread_row_and_the_unread_count(self):
first = Notification.objects.create(club=self.club, member=self.member, title="First", body="Body.")
second = Notification.objects.create(club=self.club, member=self.child, title="Second", body="Body.")
self.client.force_login(self.user)
response = self._post({"action": "mark_all_read"})
self.assertRedirects(response, reverse("mobile:notifications"), fetch_redirect_response=False)
first.refresh_from_db()
second.refresh_from_db()
self.assertIsNotNone(first.read_at)
self.assertIsNotNone(second.read_at)
follow_up = self._get()
self.assertEqual(follow_up.context["unread_notification_count"], 0)
def test_mark_read_marks_a_single_notification_and_redirects_back(self):
notification = Notification.objects.create(club=self.club, member=self.member, title="First", body="Body.")
self.client.force_login(self.user)
response = self._post({"action": "mark_read", "notification_id": str(notification.pk)})
self.assertRedirects(response, reverse("mobile:notifications"), fetch_redirect_response=False)
notification.refresh_from_db()
self.assertIsNotNone(notification.read_at)
def test_mark_read_rejects_a_notification_belonging_to_someone_not_managed(self):
stranger = Member.objects.create(first_name="Someone", last_name="Else")
notification = Notification.objects.create(club=self.club, member=stranger, title="Not yours", body="Body.")
self.client.force_login(self.user)
response = self._post({"action": "mark_read", "notification_id": str(notification.pk)})
self.assertEqual(response.status_code, 400)
notification.refresh_from_db()
self.assertIsNone(notification.read_at)
def test_notification_with_a_news_source_is_marked_out_and_mark_read_redirects_to_the_article(self):
news_item = News.objects.create(club=self.club, title="Signed: New Player", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now())
notification = Notification.objects.create(club=self.club, member=self.member, title="New article", body="Body.", source=news_item)
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, "Club news")
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_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)
response = self._get()
self.assertEqual(response.status_code, 200)
self.assertContains(response, "No one to show yet")

View File

@@ -31,6 +31,7 @@ from events.services.calendar import week_bounds
from members.models import Member from members.models import Member
from members.views import ClubScopedPublicMixin from members.views import ClubScopedPublicMixin
from news.models import News from news.models import News
from notifications.models import Notification
from teams.models import TeamMembership from teams.models import TeamMembership
from .mixins import PersonScopeMixin from .mixins import PersonScopeMixin
@@ -386,6 +387,76 @@ class EditProfileView(_PlaceholderScreen):
active_tab = "me" active_tab = "me"
class NotificationsView(_PlaceholderScreen): 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.
Scoped to every one of ``self.managed_people`` (not just scope_person) --
same scope PersonScopeMixin.get_context_data already uses for
unread_notification_count, since notifications aren't really "per
switched person" the way RSVPs are.
"""
template_name = "mobile/notifications.html"
screen_title = _("Notifications") screen_title = _("Notifications")
active_tab = "me" active_tab = "me"
def get_context_data(self, **kwargs):
today = []
earlier_this_week = []
older = []
if self.managed_people:
this_week_start, _this_week_end = week_bounds(timezone.localdate())
local_today = timezone.localdate()
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}
created_date = timezone.localtime(notification.created).date()
if created_date == local_today:
today.append(row)
elif created_date >= this_week_start:
earlier_this_week.append(row)
else:
older.append(row)
return super().get_context_data(today=today, earlier_this_week=earlier_this_week, older=older, **kwargs)
def post(self, request, *args, **kwargs):
action = request.POST.get("action")
if action == "mark_all_read":
Notification.objects.filter(club=request.club, member__in=self.managed_people, read_at__isnull=True).update(read_at=timezone.now())
return HttpResponseRedirect(reverse("mobile:notifications"))
if action == "mark_read":
notification = Notification.objects.filter(pk=request.POST.get("notification_id"), club=request.club, member__in=self.managed_people).first()
if notification is None:
return HttpResponseBadRequest(_("You can't mark that notification as read."))
if notification.read_at is None:
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"))
return HttpResponseBadRequest(_("Unknown action."))

View File

@@ -4832,6 +4832,9 @@
.text-center { .text-center {
text-align: center; text-align: center;
} }
.text-left {
text-align: left;
}
.text-right { .text-right {
text-align: right; text-align: right;
} }

File diff suppressed because one or more lines are too long