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:
25
mobile/templates/mobile/_notification_row.html
Normal file
25
mobile/templates/mobile/_notification_row.html
Normal 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>
|
||||||
75
mobile/templates/mobile/notifications.html
Normal file
75
mobile/templates/mobile/notifications.html
Normal 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 %}
|
||||||
110
mobile/tests.py
110
mobile/tests.py
@@ -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")
|
||||||
|
|||||||
@@ -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."))
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user