notifications/docs/implementation.md
Ali Asadi a84e2b3793 FEAT(notifications): pass action_code through in Gotify extras
get_gotify_extras() previously only ever surfaced the *resolved* click_url,
never the action_code that produced it -- so the frontend had no way to
know which action_code a notification carried, and got nothing at all for
a code that hasn't been configured in NotificationLink yet. Now
action_code rides along under client::notification.action_code whenever
it's set, independent of whether it resolved to a click_url. Verified live
against a running Gotify container, both resolved and unresolved.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 11:35:33 +03:30

159 lines
11 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 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| Field | `user_uuid` | `title` | `message` | `priority` | `extras` (JSON string) | `click_url` | `action_code` | `click_object_id_value` |
`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, then renamed in
`0006` to `action_code`/`click_object_id_value`. 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`:
1. **`click_url`** — a raw URL the caller already knows. Always wins if set.
2. **`action_code` + `click_object_id_value`** — the preferred path.
`action_code` is a documented, per-notification-type string (e.g.
`billboard.approved`, `escrow.timeout` — see
[notification-click-actions.md](notification-click-actions.md) for the
full catalog); `click_object_id_value` is the id of the thing the
notification is about (an ad's or escrow deal's uuid, typically).
Resolved at send time against `NotificationLink`, an admin-managed table
(`action_code`, `url_template`, `is_active`) — `url_template` supports 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`:
```python
GOTIFY_CLICK_EXTRA_KEY = "client::notification" # Gotify's own reserved namespace
click_url = models.URLField(max_length=1000, null=True, blank=True)
# CharField, not SlugField -- the documented catalog is dotted
# (billboard.approved, escrow.timeout, ...) and SlugField rejects dots.
action_code = models.CharField(max_length=100, null=True, blank=True, db_index=True,
validators=[validate_action_code])
click_object_id_value = 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.action_code:
return None
link = NotificationLink.objects.filter(action_code=self.action_code, is_active=True).first()
if not link:
return None
return link.resolve(self.click_object_id_value)
def get_gotify_extras(self):
extras = dict(self.extras or {})
click_url = self.resolve_click_url()
if click_url or self.action_code:
notification_extra = dict(extras.get(self.GOTIFY_CLICK_EXTRA_KEY) or {})
if click_url:
notification_extra["click"] = {"url": click_url}
if self.action_code:
notification_extra["action_code"] = self.action_code
extras[self.GOTIFY_CLICK_EXTRA_KEY] = notification_extra
return extras
```
Design decisions worth knowing if you touch this:
- **An unresolvable action_code is not an error** — no matching `NotificationLink`, or one marked `is_active=False`, just means no click action, same as a message with nothing set at all. A producer can start sending a new action_code before anyone's configured it in the admin; nothing breaks, the notification just isn't clickable yet.
- **`action_code` itself rides along in extras, not just the URL it resolves to** — added after testing against a live Gotify container surfaced that the frontend had no way to see which action_code produced a notification, only its resolved destination (or nothing, if unresolved). It's included even when there's no click_url yet, so the frontend can key client-side logic off the notification type itself.
- **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 resolution happens** — `tasks.py` calls it instead of reading `push_message.extras`/`click_url` directly, so the single-push path, the bulk-push path, and both `click_url` and `action_code` all 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](frontend-notification-click-actions.md) for the current action_code catalog they need to fill in.
## 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`.
- `0005_notificationlink_pushmessage_click_object_id_and_more` — adds `NotificationLink`, `PushMessage.code`, `PushMessage.click_object_id`.
- `0006_rename_code_notificationlink_action_code_and_more` — renames `NotificationLink.code`/`PushMessage.code` to `action_code`, and `PushMessage.click_object_id` to `click_object_id_value`. See "The click action 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.