Skip to content

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, an OrganizationOwnedModel with user (FK), title, message, notification_type (NotificationTypes: general/update/warning/error/billing), url (optional click-through link, CharField so 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, injects notifications / unread_count / notifications_has_more into every template automatically (registered in config/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, namespace notifications).

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 shows unread_count as a badge, and opens a fixed-size panel (no scrollbar - it's capped, not scrolling) listing the PANEL_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-within alone 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"> to notifications:mark-as-read; a hidden next field carries the notification's url so submitting the form marks it read and redirects to its link in one action (views.py::_redirect_target, validated with url_has_allowed_host_and_scheme before honoring it - falls back to HTTP_REFERER for notifications with no url). "Mark all as read" posts to notifications:all-read the same way.
  • Overflow: when there are more than PANEL_PAGE_SIZE notifications, the panel shows a "Show previous notifications" link to notifications:list instead of growing/scrolling.
  • Full list page (notifications:listtemplates/notifications/list.html): shows the first LIST_PAGE_SIZE (currently 20) notifications with a "Show more" button that fetches additional pages from notifications:load-more and 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 to OWNER and ADMIN only in DEFAULT_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-kwarg ValueError, and that it creates against the passed organization regardless 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, the target_type == "user" clean() validation against org membership.
  • notification_list / load_more_notifications - pagination boundaries, X-Has-More / X-Next-Offset headers, org scoping.

Run existing tests with:

python manage.py test prismio.notifications prismio.organizations prismio.internal