# 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](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//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`: ```python 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](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](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.