notifications/docs/implementation.md
Ali Asadi 354bbc26cf docs(notifications): add service overview, implementation guide, and frontend handoff doc
Three docs for three audiences: service-overview.md for anyone integrating
with or operating the service, implementation.md for engineers maintaining
this codebase, and frontend-notification-click-actions.md as a self-contained
handoff for the client team to start building tap-to-navigate against — it
flags the click_url URL format as an unconfirmed placeholder needing their
input, and catalogs every notification type currently sending one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 11:39:30 +03:30

8.4 KiB

Notifications Service — Implementation Guide

Technical walkthrough of how the service is actually built, for anyone maintaining or extending it. For what the service does and how to integrate with it, see service-overview.md.

Code layout

apps/
  core/               root URL ("/") — a login-gated placeholder home view, nothing else
  users/               local User model, mirrored by uuid from the accounts service
  gooyal_oauth2/        OAuth2 resource-server wiring: token validator, permission class,
                         get_application() helper
  push_notifications/    push domain: PushUser, PushMessage, BulkPushMessage
  emails/                email domain: Email, EmailQueue
utils/
  clients/gotify.py     the only code that talks to the Gotify HTTP API
  models.py              BaseModel (uuid pk, created_at/updated_at)
gotify_rest_api_client/  generated OpenAPI client for Gotify (do not hand-edit)
main/
  settings.py, celery.py, urls.py, wsgi.py/asgi.py

Push notification flow

Single push

POST /push/application/<user_uuid>/application/
  ↓ ApplicationPushUserViewSet.perform_create()   (apps/push_notifications/views.py)
  1. resolve target User by user_uuid
  2. resolve/create their PushUser (Gotify identity) — PushUser.objects.submit(user)
  3. resolve the calling Application from the OAuth2 token
  4. serializer.save(push_user=..., application=...)   → PushMessage row
  5. instance.send_push()   → send_push_notification.delay(uuid)   (Celery)
       ↓ apps/push_notifications/tasks.py
       gotify.send_notif(token=push_user.application_token,
                          title, message, priority,
                          extras=push_message.get_gotify_extras())
       push_message.state = DONE

PushUser.objects.submit(user) provisions three things in Gotify on first use, all via utils/clients/gotify.py, and persists the resulting tokens on PushUser:

  1. submit_user — a Gotify user named after the Gooyal user_uuid (admin-token auth)
  2. submit_client — a Gotify client, whose token becomes PushUser.client_token (basic-auth as the just-created user)
  3. submit_application — a Gotify application under that client, whose token becomes PushUser.application_token — this is the token push messages are actually sent with

Bulk push

An admin action (BulkPushMessageAdmin, apps/push_notifications/admin.py) triggers BulkPushMessage.bulk_push_task() → Celery bulk_push task → BulkPushMessage.push_to_all(extract_data()).

extract_data() reads a legacy .xls file (via xlrd — only .xls, not .xlsx, since xlrd dropped xlsx support at 2.0) with columns:

Col 0 1 2 3 4 5
Field user_uuid title message priority extras (JSON string) click_url

push_to_all() looks up each row's PushUser by user_id; missing users are counted as failures rather than raising, so one bad row doesn't abort the batch. Each successful row creates a PushMessage and calls send_push() — it goes through the exact same Celery task and Gotify call as a single push, so there's no separate bulk-specific delivery code path to keep in sync.

The click_url mechanism

Added in migration 0004_pushmessage_click_url. The design constraint: most notifications aren't clickable, so this had to be fully optional at every layer, and had to work identically for both the single and bulk paths without a second implementation.

apps/push_notifications/models.py:

GOTIFY_CLICK_EXTRA_KEY = "client::notification"   # Gotify's own reserved namespace

click_url = models.URLField(max_length=1000, null=True, blank=True)

def get_gotify_extras(self):
    extras = dict(self.extras or {})
    if self.click_url:
        notification_extra = dict(extras.get(self.GOTIFY_CLICK_EXTRA_KEY) or {})
        notification_extra["click"] = {"url": self.click_url}
        extras[self.GOTIFY_CLICK_EXTRA_KEY] = notification_extra
    return extras

Design decisions worth knowing if you touch this:

  • click_url is a real column, not just an extras key — so it's queryable/auditable independently, and callers don't need to know Gotify's raw extras convention to use it.
  • The merge is additive: any other extras keys a producer already sends (e.g. chat's conversation_uuid/post_id) pass through untouched — get_gotify_extras() only ever adds the client::notification key, never removes others.
  • get_gotify_extras() is the single place this merge happens — tasks.py calls it instead of reading push_message.extras directly, so both the single-push and bulk-push paths (which both eventually call the same task) get it for free.
  • Optionality is enforced at three layers, not just the DB: null=True, blank=True on the field → DRF ModelSerializer derives required=False, allow_null=True, allow_blank=True automatically → and behaviorally, an unset click_url leaves extras completely untouched (verified: get_gotify_extras() on a message with no click_url returns the original extras dict unchanged).

The actual URL values are built by producer services from their own utils/deep_links.py-style helpers (currently only advertising has one) against a placeholder base — see frontend-notification-click-actions.md for why that's still a placeholder and what needs to happen before it's final.

Email flow

Two paths, both in apps/emails/:

  • Immediate: Email.send() → send_email Celery task → Email._send_email() → django.core.mail.send_mail().
  • Queued digest: Email.objects.create(..., queue=some_queue) — EmailQueue rows are checked every 10 seconds by send_email_for_queued_events (registered via celery_app.conf.beat_schedule directly in apps/emails/tasks.py, not through the CELERY_BEAT_SCHEDULE Django setting other Gooyal services use — see the overview doc's known-gaps section). A queue only actually sends once it's been at least 10 minutes since its last_sent, batching every Email created since then into one message.

_send_email() currently hard-codes the recipient to a literal test address instead of self.user's email — this means the immediate-send path doesn't actually reach the intended recipient today. Flagging this here since it's the kind of thing that's easy to assume "must already work" when reading the flow.

Auth internals

This service is an OAuth2 resource server (django-oauth-toolkit), not an OAuth2 provider — it doesn't issue tokens, it validates ones issued by the Gooyal accounts service via introspection (OAUTH2_PROVIDER['RESOURCE_SERVER_INTROSPECTION_URL'], using this service's own CLIENT_ID/CLIENT_SECRET as the introspection credentials).

apps.gooyal_oauth2.rest_framework.IsAuthenticatedOrTokenMatchesOASRequirements is the permission class every producer-facing endpoint uses. It accepts either:

  • a plain authenticated (non-OAuth2) request, or
  • an OAuth2 token whose scopes satisfy the view's required_alternate_scopes

apps.gooyal_oauth2.utils.get_application(request) pulls the calling Application off request.auth.application — this is how a PushMessage/Email row knows which producer service created it, without trusting a client-supplied field.

Testing status

apps/push_notifications/tests.py and apps/emails/tests.py are both empty stubs — python manage.py test reports 0 tests. Everything described in this doc and in notification-click-actions.md was verified manually (unit-level checks via manage.py shell, and live round-trips against the local Gotify 2.6.3 container), not via an automated suite. If you're adding tests, push_notifications is the higher-value target — it's the domain three other services actively depend on.

Notable migrations

  • 0004_pushmessage_click_url — adds click_url. See "The click_url mechanism" above.

Known technical debt (implementation-level)

  • apps/push_notifications/admin.py has a half-built PushMessageAdmin custom form class (PushMessageForm, a custom-action2 URL) that is never registered — admin.site.register(PushMessage) uses the plain default ModelAdmin instead. Dead code, safe to ignore or remove.
  • Duplicate app_name = 'push_notifications' across application_urls.py/user_urls.py (see overview doc).
  • Bulk import is locked to legacy .xls by the xlrd dependency; there's no .xlsx path.