Notifications¶
This document describes the in-app notification system: the Notification
model, how any other code in this Django process sends a notification, the
internal staff tool for sending test notifications, and the topbar bell
UI (dropdown panel + full notifications page).
Not covered: push notifications, email notifications, or any delivery channel other than the in-app bell/panel. There is no Celery/websocket/SSE infrastructure in this app - notifications are plain rows read on normal page loads via a context processor.
Overview¶
Everything lives in prismio/notifications/:
models.py-Notification, anOrganizationOwnedModelwithuser(FK),title,message,notification_type(NotificationTypes:general/update/warning/error/billing),url(optional click-through link,CharFieldso relative paths like/dashboard/work, not just absolute URLs),is_read,created_at. Ordered-created_at.services.py-send_notification(...), the one function everything else in the app should call to create notifications. See below.context_processors.py-notifications_context, injectsnotifications/unread_count/notifications_has_moreinto every template automatically (registered inconfig/settings/base.py:TEMPLATES[0]["OPTIONS"]["context_processors"]). Powers the topbar bell.views.py- mark-as-read (single/all), the full notifications list page, and its "Show more" pagination endpoint.urls.py- mounted at/notifications/(config/urls.py, namespacenotifications).
Sending a notification¶
Any code running in this Django process - a view, a signal handler, a
management command, another app's service module - sends a notification by
calling send_notification from prismio.notifications.services:
from prismio.notifications.services import send_notification
send_notification(
organization, # the Organization the notification belongs to
"Import finished", # title
"Your file finished processing with 0 errors.", # message
url="/files/uploads/42/", # optional; omit or "" for no click-through
users=some_user, # exactly one of the three targeting kwargs below
)
Exactly one of these targeting kwargs is required (ValueError if zero
or more than one is given):
| kwarg | who gets notified |
|---|---|
users=<User or iterable of User> |
that specific user, or each user in the iterable |
to_all_members=True |
every active member of organization |
permission_codename="organization.some.codename" |
every active member of organization who resolves to holding that permission codename (via prismio.organizations.permissions.memberships_with_permission, which honors per-membership permission overrides the same way user_has_permission does - not just each role's default set) |
notification_type defaults to NotificationTypes.GENERAL; pass one of the
other NotificationTypes choices to change the icon shown in the panel
(see common/app/partials/notification_items.html).
send_notification returns the list of created Notification rows (useful
for reporting a count back to a caller, e.g. the internal test tool does
f"Sent {len(created)} notification(s).").
Gotcha: it always uses unscoped_objects¶
Notification.objects is OrganizationScopedManager (from
OrganizationOwnedModel) - it silently filters/creates against whatever
organization is set as "current" by OrganizationMiddleware for the
request making the call, not the organization argument you pass in. If
you're calling send_notification for organization B while the current
request's active org is A (e.g. an internal staff member sending a
notification to a client org), using the scoped manager would attach the
notification to the wrong org. send_notification already handles this
correctly internally via Notification.unscoped_objects.bulk_create(...) -
you don't need to think about it as a caller, just know why the service
doesn't use the default manager. See docs/multitenancy.md for the general
pattern (the same one DashboardWidget.unscoped_objects uses in
prismio/metrics/internal_views.py).
Examples¶
# One user, with a link
send_notification(
organization, "Weekly digest ready", "Your report is ready to view.",
url="/dashboard/", users=membership.user,
)
# Everyone in the org
send_notification(
organization, "Scheduled maintenance", "We'll have brief downtime at 2am ET.",
notification_type=NotificationTypes.WARNING, to_all_members=True,
)
# Everyone with billing access
send_notification(
organization, "Invoice overdue", "Invoice #1042 is 5 days past due.",
notification_type=NotificationTypes.BILLING,
url="/billing/", permission_codename="organization.billing.view",
)
Internal test tool¶
Internal staff can send a one-off test notification to any organization/
target from Internal → Tools → Send Test Notification
(internal:send-test-notification,
prismio/internal/views.py::SendTestNotificationView,
SendTestNotificationForm in prismio/internal/forms.py). The form exposes
the same three targeting modes as send_notification (specific user / all
members / members with a permission, the last one populated from
organizations.services.list_available_permission_codenames()), calls
send_notification directly, and reports how many notifications were
created via messages.success.
Gated by InternalPermissionRequiredMixin requiring
organization.notifications.send - see Permission codenames below.
Topbar bell UI¶
- Badge + dropdown panel (
common/app/partials/topbar.html): the bell showsunread_countas a badge, and opens a fixed-size panel (no scrollbar - it's capped, not scrolling) listing thePANEL_PAGE_SIZE(currently 8) most recent notifications from the context processor. The panel is opened/closed via JS (prismio/static/prismio/js/notifications.js) - click the bell to toggle, click anywhere outside the panel or press
Escape to close. (This is a deliberate exception to the rest of the
topbar's
:focus-within-only dropdowns - org-switcher/user-menu - because a panel this large needs a reliable click-outside-to-close that:focus-withinalone doesn't guarantee for clicks on non-focusable page content.) - Row markup lives in its own partial,
common/app/partials/notification_items.html, so the dropdown panel and the full list page render identical rows (including hover state) without duplicating markup. - Mark as read: each row is a
<form method="post">tonotifications:mark-as-read; a hiddennextfield carries the notification'surlso submitting the form marks it read and redirects to its link in one action (views.py::_redirect_target, validated withurl_has_allowed_host_and_schemebefore honoring it - falls back toHTTP_REFERERfor notifications with nourl). "Mark all as read" posts tonotifications:all-readthe same way. - Overflow: when there are more than
PANEL_PAGE_SIZEnotifications, the panel shows a "Show previous notifications" link tonotifications:listinstead of growing/scrolling. - Full list page (
notifications:list→templates/notifications/list.html): shows the firstLIST_PAGE_SIZE(currently 20) notifications with a "Show more" button that fetches additional pages fromnotifications:load-moreand appends them in place (notifications.js,[data-notifications-more]) - no page reload, no pagination UI.
Permission codenames¶
organization.notifications.send- required to use the internal "Send Test Notification" tool. Added toOWNERandADMINonly inDEFAULT_ROLE_PERMISSIONS(prismio/organizations/roles.py).
After adding or changing a permission codename, run
python manage.py sync_role_permissions (or rely on the post_migrate
signal in prismio/organizations/apps.py) to propagate it into
RolePermission rows - see docs/permissions.md.
URLs¶
All under /notifications/ (namespace notifications):
| name | method | purpose |
|---|---|---|
notifications:list |
GET | full notifications page |
notifications:all-read |
POST | mark every notification (current user + org) read |
notifications:mark-as-read |
POST | mark one notification read, redirect to its url (or next/referer) |
notifications:load-more |
GET | HTML fragment of the next page of notifications, for the list page's "Show more" |
Testing checklist¶
Existing coverage: prismio/notifications/tests/test_context_processors.py
(org scoping, unread count, unauthenticated/no-org cases) and
prismio/notifications/tests/test_views.py (mark-as-read / mark-all-read:
auth required, POST required, org-scoped).
Not yet covered (worth adding if you're touching this system further):
services.send_notification- each targeting mode, the exactly-one-kwargValueError, and that it creates against the passedorganizationregardless of the caller's current org context.organizations.permissions.memberships_with_permission- role-default grant, per-membership override grant, and override-revokes-default cases.SendTestNotificationView/SendTestNotificationForm- permission gate, thetarget_type == "user"clean() validation against org membership.notification_list/load_more_notifications- pagination boundaries,X-Has-More/X-Next-Offsetheaders, org scoping.
Run existing tests with: