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>
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:
submit_user— a Gotify user named after the Gooyaluser_uuid(admin-token auth)submit_client— a Gotify client, whose token becomesPushUser.client_token(basic-auth as the just-created user)submit_application— a Gotify application under that client, whose token becomesPushUser.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_urlis a real column, not just anextraskey — 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
extraskeys a producer already sends (e.g. chat'sconversation_uuid/post_id) pass through untouched —get_gotify_extras()only ever adds theclient::notificationkey, never removes others. get_gotify_extras()is the single place this merge happens —tasks.pycalls it instead of readingpush_message.extrasdirectly, 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=Trueon the field → DRFModelSerializerderivesrequired=False, allow_null=True, allow_blank=Trueautomatically → and behaviorally, an unsetclick_urlleavesextrascompletely untouched (verified:get_gotify_extras()on a message with noclick_urlreturns the originalextrasdict 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_emailCelery task →Email._send_email()→django.core.mail.send_mail(). - Queued digest:
Email.objects.create(..., queue=some_queue)—EmailQueuerows are checked every 10 seconds bysend_email_for_queued_events(registered viacelery_app.conf.beat_scheduledirectly inapps/emails/tasks.py, not through theCELERY_BEAT_SCHEDULEDjango 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 itslast_sent, batching everyEmailcreated 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— addsclick_url. See "The click_url mechanism" above.
Known technical debt (implementation-level)
apps/push_notifications/admin.pyhas a half-builtPushMessageAdmincustom form class (PushMessageForm, acustom-action2URL) that is never registered —admin.site.register(PushMessage)uses the plain defaultModelAdmininstead. Dead code, safe to ignore or remove.- Duplicate
app_name = 'push_notifications'acrossapplication_urls.py/user_urls.py(see overview doc). - Bulk import is locked to legacy
.xlsby thexlrddependency; there's no.xlsxpath.