Reflects the switch from hard-coded FRONTEND_BASE_URL/utils.deep_links to admin-managed NotificationLink rows: implementation.md documents the resolution logic and the new migration, frontend-notification-click-actions.md replaces the "confirm your URL scheme" ask with the actual code catalog (now that the scheme itself is an admin panel concern, not something the frontend team needs to weigh in on for us to ship), and notification-click-actions.md gets a pointer at the top so it reads as the historical first pass it now is, not the current mechanism. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
9.7 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 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| Field | user_uuid |
title |
message |
priority |
extras (JSON string) |
click_url |
code |
click_object_id |
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 action mechanism
Added in migration 0004_pushmessage_click_url, extended in 0005 with
code/click_object_id and the NotificationLink model. The design
constraint throughout: 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.
Two ways to set a destination on a PushMessage:
click_url— a raw URL the caller already knows. Always wins if set.code+click_object_id— the preferred path.codeis a documented, per-notification-type string (e.g.billboard.approved,escrow.timeout— see notification-click-actions.md for the full catalog);click_object_idis the id of the thing the notification is about (an ad's or escrow deal's uuid, typically). Resolved at send time againstNotificationLink, an admin-managed table (code,url_template,is_active) —url_templatesupports a{object_id}placeholder. This is what moved the actual destination strings out of Python and into the Django admin, so a routing-scheme change is a config edit, not a deploy across every producer service.
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)
code = models.SlugField(max_length=100, null=True, blank=True, db_index=True)
click_object_id = models.CharField(max_length=255, null=True, blank=True)
def resolve_click_url(self):
if self.click_url:
return self.click_url
if not self.code:
return None
link = NotificationLink.objects.filter(code=self.code, is_active=True).first()
if not link:
return None
return link.resolve(self.click_object_id)
def get_gotify_extras(self):
extras = dict(self.extras or {})
click_url = self.resolve_click_url()
if click_url:
notification_extra = dict(extras.get(self.GOTIFY_CLICK_EXTRA_KEY) or {})
notification_extra["click"] = {"url": click_url}
extras[self.GOTIFY_CLICK_EXTRA_KEY] = notification_extra
return extras
Design decisions worth knowing if you touch this:
- An unresolvable code is not an error — no matching
NotificationLink, or one markedis_active=False, just means no click action, same as a message with nothing set at all. A producer can start sending a new code before anyone's configured it in the admin; nothing breaks, the notification just isn't clickable yet. - 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 resolution happens —tasks.pycalls it instead of readingpush_message.extras/click_urldirectly, so the single-push path, the bulk-push path, and bothclick_urlandcodeall go through one function.- The admin table is deliberately not seeded with rows by any migration. Populating it is an ops/product decision (which is the entire point of moving it out of code) — see frontend-notification-click-actions.md for the current code catalog they need to fill in.
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.0005_notificationlink_pushmessage_click_object_id_and_more— addsNotificationLink,PushMessage.code,PushMessage.click_object_id. See "The click action 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.