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

120 lines
8.4 KiB
Markdown

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