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>
This commit is contained in:
Ali Asadi 2026-09-02 11:35:33 +03:30
parent a643fee027
commit a84e2b3793
3 changed files with 28 additions and 7 deletions

View file

@ -127,12 +127,18 @@ class PushMessage(BaseModel):
def get_gotify_extras(self):
"""Merge the resolved click url into extras using Gotify's click-action
convention, without disturbing any other extras keys the caller already
set."""
set. Also passes action_code through as-is, when set — even if it
didn't resolve to a click_url yet (e.g. no NotificationLink row for
it) — so the frontend can see which action_code a notification
carries, not just the URL it happened to resolve to."""
extras = dict(self.extras or {})
click_url = self.resolve_click_url()
if 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

View file

@ -25,7 +25,8 @@ is clickable at all — is under a reserved key, per
"ads::ad::approve": true,
"ad_uuid": "8fb638c2-e6a4-4baf-aefe-83dab78fb5bd",
"client::notification": {
"click": { "url": "https://app.gooyal.ir/ads/8fb638c2-e6a4-4baf-aefe-83dab78fb5bd" }
"click": { "url": "https://app.gooyal.ir/ads/8fb638c2-e6a4-4baf-aefe-83dab78fb5bd" },
"action_code": "billboard.approved"
}
}
}
@ -34,6 +35,13 @@ is clickable at all — is under a reserved key, per
Pull `extras["client::notification"]["click"]["url"]`. If it's present, tapping
navigates there. **If it's absent, do nothing on tap** — see next section.
`extras["client::notification"]["action_code"]` rides along too, whenever the
producer sent one — even if it hasn't resolved to a `click` URL yet (no
`NotificationLink` row for it, or it's inactive). You don't need it to
implement tap-to-navigate; it's there for client-side logic keyed off the
notification *type* itself (grouping, icons, analytics, a fallback in-app
handler for a code before its admin row exists), not just its destination.
## `click_url` is optional — most notifications are not clickable
Do not assume every notification carries a URL. The backend field this comes

View file

@ -87,7 +87,10 @@ Two ways to set a destination on a `PushMessage`:
GOTIFY_CLICK_EXTRA_KEY = "client::notification" # Gotify's own reserved namespace
click_url = models.URLField(max_length=1000, null=True, blank=True)
action_code = models.SlugField(max_length=100, null=True, blank=True, db_index=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):
@ -103,15 +106,19 @@ def resolve_click_url(self):
def get_gotify_extras(self):
extras = dict(self.extras or {})
click_url = self.resolve_click_url()
if 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.