notifications app
Cross-cutting notification fan-out. Most apps emit notifications; this app handles delivery.
Models (8)
Notification— single in-app notification;is_readflag,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 23EventTypechoicesForumSubscription— 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-writtensummary,notification_count,sent_at)PushToken— a device push token (APNs for iOS, FCM for Android),is_activeflagPushDeliveryRecord— per-attempt outcome of a push send (success/unregistered/transient_failure/skipped_no_credentials/suppressed_by_preference); powers the platform admin push-health widgetUserQuietHours— per-user quiet-hours window (enabled,start_time/end_time, IANAtimezone); a hardcoded critical-event allowlist (billing, security, account lifecycle) bypasses it
Key endpoints
Mounted at /api/ (notifications router + explicit paths).
| URL | Purpose |
|---|---|
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 theIsAuthenticatedpermission 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 failuresend_push_for_notification(notification_id)— dispatches to the recipient's activePushTokens via APNs (HTTP/2 provider-token) and FCM (HTTP v1). Enqueued on every notification creation: apost_savesignal (enqueue_push_on_notification_create) coversNotification.objects.create(...)sites, andbulk_createsites callenqueue_push_for_notifications(...)explicitly (both fire after transaction commit viaservices/dispatch.py). Honors per-eventNotificationPreference.push_enabled, soft-prunes unregistered tokens, and records each attempt inPushDeliveryRecord.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 aNotificationDigestrow, optionally AI-summarizedprune_push_delivery_records— Celery beat Sundays at 06:30; deletesPushDeliveryRecordrows older thanPUSH_DELIVERY_RECORD_RETENTION_DAYS(default 90,0disables). 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, bypassingNotificationentirely.--check-onlyreports credential and token state without sending, which is the fastest way to confirm an env-var change reached the running task. Sends are recorded withnotification=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.
| Setting | Notes |
|---|---|
APNS_KEY / APNS_KEY_PATH | Raw .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_ID | Team ID, the key's 10-char ID, and the bundle ID used as the APNs topic |
APNS_USE_SANDBOX | Sandbox 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 / _PATH | Firebase service-account JSON. Uses the FCM HTTP v1 API — legacy server keys do not work |
FCM_PROJECT_ID | Optional; 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.swiftmust forward the APNs callbacks to Capacitor.@capacitor/push-notificationsobtains the iOS token exclusively viadidRegisterForRemoteNotificationsWithDeviceToken/didFailToRegisterForRemoteNotificationsWithError, re-posted onNotificationCenter. Without themregister()still succeeds and Apple still returns a token — it is just dropped, and the JSregistrationlistener 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 emitregistrationas 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-orgOrganizationEmailConfigoverride) - Mobile push (FCM / APNs) — tokens stored as
PushTokenrows (related_name="push_tokens"onUser)
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 theenqueue_push_on_notification_createpost_savesignal (signals.py) bulk_createsites (which don't emitpost_save) callenqueue_push_for_notifications(...)fromservices/dispatch.pyexplicitly- Both funnel into the same
send_push_for_notificationtask, 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 thealert-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_saveonForumPost/ForumComment— enqueuescreate_new_post_notifications/create_new_comment_notifications(which createNotificationrows and queue email)post_saveonNotification(created=Trueonly) — enqueues push viaenqueue_push_on_notification_createpost_saveonForumMembership— 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