Skip to main content

notifications app

Cross-cutting notification fan-out. Most apps emit notifications; this app handles delivery.

Models (8)

  • Notification — single in-app notification; is_read flag, notification_type + category, target_url, requires_action/action_label, scheduled_for (set when quiet hours defer delivery)
  • NotificationPreference — per-user, per-event-type channel toggles (in_app_enabled / email_enabled / push_enabled), one of 23 EventType choices
  • ForumSubscription — subscriptions to forum threads (for new-reply notifications)
  • DigestPreference — per-user digest config (daily / weekly frequency)
  • NotificationDigest — a generated digest instance (period_start/period_end, AI-written summary, notification_count, sent_at)
  • PushToken — a device push token (APNs for iOS, FCM for Android), is_active flag
  • PushDeliveryRecord — per-attempt outcome of a push send (success / unregistered / transient_failure / skipped_no_credentials / suppressed_by_preference); powers the platform admin push-health widget
  • UserQuietHours — per-user quiet-hours window (enabled, start_time/end_time, IANA timezone); a hardcoded critical-event allowlist (billing, security, account lifecycle) bypasses it

Key endpoints

Mounted at /api/ (notifications router + explicit paths).

URLPurpose
GET /api/notifications/List my notifications (supports category, requires_action, is_read filters; capped at 50)
GET /api/notifications/unread_count/Unread count for the navbar badge
GET /api/notifications/action_required_count/Count of unread notifications requiring action
GET /api/notifications/alert-summary/Unified header-bell counts: notifications + approvals + compliance
POST /api/notifications/mark_read/Mark one/many as read ({notification_ids: [...]}) or all ({mark_all: true})
POST /api/push-tokens/, DELETE /api/push-tokens/<id>/Register a device push token / soft-deactivate one
GET/POST /api/notifications/preferences/My per-event-type preferences (all 23 event types)
GET/PUT /api/notifications/digest-preference/My digest frequency
GET/PUT /api/notifications/quiet-hours/My quiet-hours window (lazily created with defaults)
GET /api/notifications/digests/My generated digest history
GET /api/notifications/platform-push-health/Platform-admin 24h push delivery health (per platform/status)

Permissions

  • IsAuthenticated — all endpoints (own notifications only)
  • Platform-admin check inside PlatformPushHealthView (returns 403 for non-platform-admins despite the IsAuthenticated permission class)

Background tasks

  • send_notification_email(subject, body, recipients, template_name=None, context=None, org_id=None) — sends via branded HTML template, falling back to plain text on failure
  • send_push_for_notification(notification_id) — dispatches to the recipient's active PushTokens via APNs (HTTP/2 provider-token) and FCM (HTTP v1). Enqueued on every notification creation: a post_save signal (enqueue_push_on_notification_create) covers Notification.objects.create(...) sites, and bulk_create sites call enqueue_push_for_notifications(...) explicitly (both fire after transaction commit via services/dispatch.py). Honors per-event NotificationPreference.push_enabled, soft-prunes unregistered tokens, and records each attempt in PushDeliveryRecord.
  • dispatch_deferred_notifications — Celery beat every 5 min; re-fires push for notifications deferred by quiet hours once the window closes (scheduled_for <= now()).
  • generate_notification_digests — Celery beat daily at 07:00 UTC; assembles per-user digest from the prior period's notifications into a NotificationDigest row, optionally AI-summarized
  • prune_push_delivery_records — Celery beat Sundays at 06:30; deletes PushDeliveryRecord rows older than PUSH_DELIVERY_RECORD_RETENTION_DAYS (default 90, 0 disables). One row is written per token per send attempt and nothing else deletes from that table, so this is its only bound. Deletes in batches of 5,000 rather than one statement, since the backlog can be large on first run.

Management commands

  • send_test_push --email <address> — sends a push straight to a user's active devices, bypassing Notification entirely. --check-only reports credential and token state without sending, which is the fastest way to confirm an env-var change reached the running task. Sends are recorded with notification=None, so they appear on the health widget alongside real traffic.

Provider credentials

APNs and FCM are configured per-provider and independently; a provider with no credentials records skipped_no_credentials rather than raising, so a half-configured platform is a visible state instead of an outage.

SettingNotes
APNS_KEY / APNS_KEY_PATHRaw .p8 contents (inline) or a filesystem path. An APNs auth key from Certificates, IDs & Profiles → Keys — not an App Store Connect API key, which is also a .p8 from Apple
APNS_TEAM_ID, APNS_KEY_ID, APNS_BUNDLE_IDTeam ID, the key's 10-char ID, and the bundle ID used as the APNs topic
APNS_USE_SANDBOXSandbox host instead of production. TestFlight and App Store builds register against production; a token from one environment sent to the other returns BadDeviceToken
FCM_SERVICE_ACCOUNT_JSON / _PATHFirebase service-account JSON. Uses the FCM HTTP v1 API — legacy server keys do not work
FCM_PROJECT_IDOptional; otherwise read from the service account's own project_id

On iOS the aps-environment entitlement is driven by the APS_ENVIRONMENT build setting (development in Debug, production in Release) rather than hardcoded, because both configurations share one entitlements file.

The client half

Correct backend credentials are necessary but not sufficient — the device has to produce a token in the first place, and two client-side requirements are easy to lose:

  • AppDelegate.swift must forward the APNs callbacks to Capacitor. @capacitor/push-notifications obtains the iOS token exclusively via didRegisterForRemoteNotificationsWithDeviceToken / didFailToRegisterForRemoteNotificationsWithError, re-posted on NotificationCenter. Without them register() still succeeds and Apple still returns a token — it is just dropped, and the JS registration listener never fires. The stock Capacitor iOS template omits these methods, so regenerating the project silently reintroduces the bug (#773). Android is unaffected: FCM registration goes through the plugin and never touches AppDelegate.
  • Listeners attach before register(). The native layer can emit registration as soon as the OS returns a token, so a listener attached afterwards loses it (#770).

Diagnosing this from the server is a dead end: request-level logging does not capture POST /api/push-tokens/, so a successful registration produces no log line and absence of logs proves nothing. Query for a PushToken row instead, or use send_test_push --check-only. Client-side, usePushRegistrationStore records the outcome and the notification-preferences screen surfaces a failed or declined registration to the user.

External integrations

  • Email backend (apps.common.email.send_org_email / send_branded_email, with per-org OrganizationEmailConfig override)
  • Mobile push (FCM / APNs) — tokens stored as PushToken rows (related_name="push_tokens" on User)

Notable patterns

Notification fan-out pattern

There's no single notify() helper — other apps create Notification rows directly (Notification.objects.create(...) for one recipient, or bulk_create for fan-out to many, e.g. compliance alerts, invoice notices, election results, forum replies) and separately call send_notification_email / apps.notifications.tasks._get_user_preference if they want to gate on the recipient's NotificationPreference. Push dispatch is the one piece that's automatic for every creation path:

  • Single .create(...) calls are caught by the enqueue_push_on_notification_create post_save signal (signals.py)
  • bulk_create sites (which don't emit post_save) call enqueue_push_for_notifications(...) from services/dispatch.py explicitly
  • Both funnel into the same send_push_for_notification task, scheduled on transaction commit

Event types

There's no event_types.py constants module. Event/notification types are defined as Django TextChoices directly on the models:

  • NotificationPreference.EventType — 23 choices a user sets channel preferences for (e.g. reply_to_post, election_published, invoice_issued, course_assigned, recognition_received, platform_alert)
  • Notification.NotificationType — a larger, overlapping set used to tag individual notification rows (adds e.g. account_request_submitted, pnm_name_pending, service_hours_approved, event_waitlist_promoted)
  • Notification.Category — coarser grouping for the alert-summary/list filters: forum, election, compliance, billing, learning, system, recognition, events

Digest

generate_notification_digests runs daily; it generates a daily-frequency digest every run and a weekly-frequency digest additionally on Mondays, per each user's DigestPreference. It collects the user's Notifications since their last digest, builds a bullet-list summary (optionally rewritten by AI, gated by the org's ai_scope_notifications toggle), persists a NotificationDigest row, and emails it via send_notification_email (generic template — there's no dedicated digest HTML template). Digests do not suppress the underlying in-app/email/push sends for those same notifications; it's an additional daily/weekly rollup, not a routing mode.

Read state

Notification.is_read boolean (not read); the UI marks rows read via POST /api/notifications/mark_read/. Unread count drives the navbar badge:

Notification.objects.filter(recipient=user, is_read=False).count()

Signals

  • post_save on ForumPost / ForumComment — enqueues create_new_post_notifications / create_new_comment_notifications (which create Notification rows and queue email)
  • post_save on Notification (created=True only) — enqueues push via enqueue_push_on_notification_create
  • post_save on ForumMembership — auto-subscribes the user to the forum (ForumSubscription)

Code paths

  • Models: backend/apps/notifications/models.py
  • Services (push dispatch, push senders, quiet hours, push health): backend/apps/notifications/services/
  • Signals: backend/apps/notifications/signals.py
  • Tasks: backend/apps/notifications/tasks.py