REFACTOR(notifications): rename code/click_object_id to action_code/click_object_id_value #2

Merged
Ghasemi merged 1 commit from feature/notification-actions into master 2026-08-23 10:25:36 -04:00
7 changed files with 100 additions and 67 deletions

View file

@ -14,9 +14,9 @@ admin.site.register(PushUser)
@admin.register(NotificationLink) @admin.register(NotificationLink)
class NotificationLinkAdmin(admin.ModelAdmin): class NotificationLinkAdmin(admin.ModelAdmin):
list_display = ['code', 'url_template', 'is_active', 'description'] list_display = ['action_code', 'url_template', 'is_active', 'description']
list_filter = ['is_active'] list_filter = ['is_active']
search_fields = ['code', 'description'] search_fields = ['action_code', 'description']
from django import forms from django import forms

View file

@ -0,0 +1,28 @@
# Generated by Django 5.2.6 on 2026-08-23 08:38
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('push_notifications', '0005_notificationlink_pushmessage_click_object_id_and_more'),
]
operations = [
migrations.RenameField(
model_name='notificationlink',
old_name='code',
new_name='action_code',
),
migrations.RenameField(
model_name='pushmessage',
old_name='code',
new_name='action_code',
),
migrations.RenameField(
model_name='pushmessage',
old_name='click_object_id',
new_name='click_object_id_value',
),
]

View file

@ -46,12 +46,12 @@ class PushUser(BaseModel):
class NotificationLink(BaseModel): class NotificationLink(BaseModel):
""" """
Admin-managed code -> URL mapping for notification click actions. Producer Admin-managed action_code -> URL mapping for notification click actions.
services send a `code` (documented per notification type) instead of a raw Producer services send an `action_code` (documented per notification
URL, so where a tap should navigate is a config change here, not a code type) instead of a raw URL, so where a tap should navigate is a config
deploy across every producer service. change here, not a code deploy across every producer service.
""" """
code = models.SlugField(max_length=100, unique=True, db_index=True) action_code = models.SlugField(max_length=100, unique=True, db_index=True)
description = models.CharField(max_length=255, blank=True) description = models.CharField(max_length=255, blank=True)
url_template = models.CharField( url_template = models.CharField(
max_length=1000, max_length=1000,
@ -60,7 +60,7 @@ class NotificationLink(BaseModel):
is_active = models.BooleanField(default=True) is_active = models.BooleanField(default=True)
def __str__(self): def __str__(self):
return self.code return self.action_code
def resolve(self, object_id=None): def resolve(self, object_id=None):
if object_id and '{object_id}' in self.url_template: if object_id and '{object_id}' in self.url_template:
@ -89,28 +89,30 @@ class PushMessage(BaseModel):
priority = models.IntegerField(default=5) priority = models.IntegerField(default=5)
extras = models.JSONField(default=dict) extras = models.JSONField(default=dict)
click_url = models.URLField(max_length=1000, null=True, blank=True) click_url = models.URLField(max_length=1000, null=True, blank=True)
# Alternative to a raw click_url: a documented per-notification-type code, # Alternative to a raw click_url: a documented per-notification-type
# resolved against NotificationLink at send time (see get_gotify_extras). # action_code, resolved against NotificationLink at send time (see
# click_object_id is substituted into that code's {object_id} placeholder. # get_gotify_extras). click_object_id_value is substituted into that
code = models.SlugField(max_length=100, null=True, blank=True, db_index=True) # action_code's {object_id} placeholder.
click_object_id = models.CharField(max_length=255, null=True, blank=True) action_code = models.SlugField(max_length=100, null=True, blank=True, db_index=True)
click_object_id_value = models.CharField(max_length=255, null=True, blank=True)
stats = models.IntegerField(default=StateChoices.INIT, choices=StateChoices.choices) stats = models.IntegerField(default=StateChoices.INIT, choices=StateChoices.choices)
bulk = models.ForeignKey("BulkPushMessage", null=True, blank=True, on_delete=models.PROTECT) bulk = models.ForeignKey("BulkPushMessage", null=True, blank=True, on_delete=models.PROTECT)
def resolve_click_url(self): def resolve_click_url(self):
"""An explicit click_url always wins; otherwise resolve via `code` """An explicit click_url always wins; otherwise resolve via
against NotificationLink. Returns None (not an error) if `code` isn't `action_code` against NotificationLink. Returns None (not an error)
set, or isn't configured/active yet — not every notification is if `action_code` isn't set, or isn't configured/active yet — not
clickable, and a not-yet-configured code shouldn't block delivery.""" every notification is clickable, and a not-yet-configured
action_code shouldn't block delivery."""
if self.click_url: if self.click_url:
return self.click_url return self.click_url
if not self.code: if not self.action_code:
return None return None
link = NotificationLink.objects.filter(code=self.code, is_active=True).first() link = NotificationLink.objects.filter(action_code=self.action_code, is_active=True).first()
if not link: if not link:
return None return None
return link.resolve(self.click_object_id) return link.resolve(self.click_object_id_value)
def get_gotify_extras(self): def get_gotify_extras(self):
"""Merge the resolved click url into extras using Gotify's click-action """Merge the resolved click url into extras using Gotify's click-action
@ -161,8 +163,8 @@ class BulkPushMessage(BaseModel):
priority=data[user_uuid].get('priority', 5), priority=data[user_uuid].get('priority', 5),
extras=data[user_uuid].get("extras", {}), extras=data[user_uuid].get("extras", {}),
click_url=data[user_uuid].get("click_url") or None, click_url=data[user_uuid].get("click_url") or None,
code=data[user_uuid].get("code") or None, action_code=data[user_uuid].get("action_code") or None,
click_object_id=data[user_uuid].get("click_object_id") or None) click_object_id_value=data[user_uuid].get("click_object_id_value") or None)
push_message.send_push() push_message.send_push()
self.success_count += 1 self.success_count += 1
@ -193,8 +195,8 @@ class BulkPushMessage(BaseModel):
else: else:
extras = {} extras = {}
click_url = str(sh.cell(rowx=row, colx=5).value).strip() if sh.ncols > 5 else "" click_url = str(sh.cell(rowx=row, colx=5).value).strip() if sh.ncols > 5 else ""
code = str(sh.cell(rowx=row, colx=6).value).strip() if sh.ncols > 6 else "" action_code = str(sh.cell(rowx=row, colx=6).value).strip() if sh.ncols > 6 else ""
click_object_id = str(sh.cell(rowx=row, colx=7).value).strip() if sh.ncols > 7 else "" click_object_id_value = str(sh.cell(rowx=row, colx=7).value).strip() if sh.ncols > 7 else ""
push_dict[user_uuid] = { push_dict[user_uuid] = {
"title" : title, "title" : title,
@ -202,7 +204,7 @@ class BulkPushMessage(BaseModel):
"priority" : priority, "priority" : priority,
"extras" : extras, "extras" : extras,
"click_url": click_url or None, "click_url": click_url or None,
"code": code or None, "action_code": action_code or None,
"click_object_id": click_object_id or None, "click_object_id_value": click_object_id_value or None,
} }
return push_dict return push_dict

View file

@ -23,7 +23,7 @@ class PushMessageSerializer(serializers.ModelSerializer):
"priority", "priority",
"extras", "extras",
"click_url", "click_url",
"code", "action_code",
"click_object_id", "click_object_id_value",
) )
read_only_fields = ("push_user","application") read_only_fields = ("push_user","application")

View file

@ -52,33 +52,33 @@ error.
## URLs are admin-configured, not hard-coded — nothing for you to build here ## URLs are admin-configured, not hard-coded — nothing for you to build here
The actual destination string is no longer computed in backend code. Each The actual destination string is no longer computed in backend code. Each
notification type has a **code**, and the notifications service resolves notification type has an **action_code**, and the notifications service
that code against an admin-managed table (`NotificationLink`: `code`, resolves that action_code against an admin-managed table (`NotificationLink`:
`url_template`, `is_active`) at send time. `url_template` supports a `action_code`, `url_template`, `is_active`) at send time. `url_template`
`{object_id}` placeholder, e.g. `https://app.gooyal.ir/ads/{object_id}` or supports a `{object_id}` placeholder, e.g. `https://app.gooyal.ir/ads/{object_id}`
`gooyal://ads/{object_id}` — whichever scheme your app actually uses. or `gooyal://ads/{object_id}` — whichever scheme your app actually uses.
This means: **your routing scheme is a config decision, not a code change**. This means: **your routing scheme is a config decision, not a code change**.
Whoever manages the notifications service's admin panel enters one row per Whoever manages the notifications service's admin panel enters one row per
code below with your actual URL format, and every notification of that type action_code below with your actual URL format, and every notification of
immediately starts carrying it. If a code has no row yet (or its row is that type immediately starts carrying it. If an action_code has no row yet
marked inactive), that notification simply has no `click_url` — same as any (or its row is marked inactive), that notification simply has no
other non-clickable notification, no error. `click_url` — same as any other non-clickable notification, no error.
Practically, this doesn't change anything about what you build — you still Practically, this doesn't change anything about what you build — you still
just read `extras["client::notification"]["click"]["url"]` and navigate if just read `extras["client::notification"]["click"]["url"]` and navigate if
it's present. It changes who's responsible for the URL *string itself*: not it's present. It changes who's responsible for the URL *string itself*: not
a backend deploy, just an admin panel entry. If you want a different value a backend deploy, just an admin panel entry. If you want a different value
than what's currently configured for any code, ask whoever owns that panel than what's currently configured for any action_code, ask whoever owns that
to update it. panel to update it.
## What's clickable today, by code ## What's clickable today, by action_code
All of these are live in the `advertising` service: All of these are live in the `advertising` service:
### Billboards & content ### Billboards & content
| Code | Notification text (fa) | | Action code | Notification text (fa) |
|---|---| |---|---|
| `billboard.viewed` | شخصی شروع به مشاهده بیلبورد شما کرد. | | `billboard.viewed` | شخصی شروع به مشاهده بیلبورد شما کرد. |
| `billboard.created` | بیلبود شما با موفقیت ساخته شد. | | `billboard.created` | بیلبود شما با موفقیت ساخته شد. |
@ -93,7 +93,7 @@ All of these are live in the `advertising` service:
| `billboard.credit_low` | اعتبار پین رو به اتمامه! | | `billboard.credit_low` | اعتبار پین رو به اتمامه! |
| `billboard.credit_exhausted` | اعتبار پین شما به پایان رسید! | | `billboard.credit_exhausted` | اعتبار پین شما به پایان رسید! |
Every code above sends the ad's `uuid` as `click_object_id`, so a Every action_code above sends the ad's `uuid` as `click_object_id_value`, so a
`url_template` of `https://app.gooyal.ir/ads/{object_id}` (or your app's `url_template` of `https://app.gooyal.ir/ads/{object_id}` (or your app's
real equivalent) resolves correctly for all twelve. They're still separate real equivalent) resolves correctly for all twelve. They're still separate
codes rather than one shared one, on purpose: `billboard.pin_expiring` and codes rather than one shared one, on purpose: `billboard.pin_expiring` and
@ -105,7 +105,7 @@ change, just a new admin row.
### Escrow deals ### Escrow deals
| Code | Trigger | | Action code | Trigger |
|---|---| |---|---|
| `escrow.request_deal` | Buyer paid, seller needs to review | | `escrow.request_deal` | Buyer paid, seller needs to review |
| `escrow.cancel_deal` | Buyer canceled before seller responded | | `escrow.cancel_deal` | Buyer canceled before seller responded |
@ -123,7 +123,7 @@ change, just a new admin row.
| `escrow.reject_judge` | Dispute resolved for the seller | | `escrow.reject_judge` | Dispute resolved for the seller |
| `escrow.timeout` | Deal expired without a response | | `escrow.timeout` | Deal expired without a response |
All fifteen send the escrow deal's `uuid` as `click_object_id`. Same All fifteen send the escrow deal's `uuid` as `click_object_id_value`. Same
one-code-per-situation reasoning as billboards — they all currently resolve one-code-per-situation reasoning as billboards — they all currently resolve
to the same escrow-detail destination, but that's an admin config choice, to the same escrow-detail destination, but that's an admin config choice,
not a hard-coded one, so it's free to diverge later. not a hard-coded one, so it's free to diverge later.

View file

@ -54,27 +54,29 @@ An admin action (`BulkPushMessageAdmin`, `apps/push_notifications/admin.py`) tri
| Col | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | | Col | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|
| Field | `user_uuid` | `title` | `message` | `priority` | `extras` (JSON string) | `click_url` | `code` | `click_object_id` | | 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. `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 ## The click action mechanism
Added in migration `0004_pushmessage_click_url`, extended in `0005` with Added in migration `0004_pushmessage_click_url`, extended in `0005` with
`code`/`click_object_id` and the `NotificationLink` model. The design `code`/`click_object_id` and the `NotificationLink` model, then renamed in
constraint throughout: **most notifications aren't clickable**, so this had `0006` to `action_code`/`click_object_id_value`. The design constraint
to be fully optional at every layer, and had to work identically for both throughout: **most notifications aren't clickable**, so this had to be fully
the single and bulk paths without a second implementation. 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`: Two ways to set a destination on a `PushMessage`:
1. **`click_url`** — a raw URL the caller already knows. Always wins if set. 1. **`click_url`** — a raw URL the caller already knows. Always wins if set.
2. **`code` + `click_object_id`** — the preferred path. `code` is a documented, 2. **`action_code` + `click_object_id_value`** — the preferred path.
per-notification-type string (e.g. `billboard.approved`, `escrow.timeout` — `action_code` is a documented, per-notification-type string (e.g.
see [notification-click-actions.md](notification-click-actions.md) for the `billboard.approved`, `escrow.timeout` — see
full catalog); `click_object_id` is the id of the thing the notification is [notification-click-actions.md](notification-click-actions.md) for the
about (an ad's or escrow deal's uuid, typically). Resolved at send time full catalog); `click_object_id_value` is the id of the thing the
against `NotificationLink`, an admin-managed table notification is about (an ad's or escrow deal's uuid, typically).
(`code`, `url_template`, `is_active`) — `url_template` supports a 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 `{object_id}` placeholder. This is what moved the actual destination
strings out of Python and into the Django admin, so a routing-scheme 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. change is a config edit, not a deploy across every producer service.
@ -85,18 +87,18 @@ Two ways to set a destination on a `PushMessage`:
GOTIFY_CLICK_EXTRA_KEY = "client::notification" # Gotify's own reserved namespace GOTIFY_CLICK_EXTRA_KEY = "client::notification" # Gotify's own reserved namespace
click_url = models.URLField(max_length=1000, null=True, blank=True) click_url = models.URLField(max_length=1000, null=True, blank=True)
code = models.SlugField(max_length=100, null=True, blank=True, db_index=True) action_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) click_object_id_value = models.CharField(max_length=255, null=True, blank=True)
def resolve_click_url(self): def resolve_click_url(self):
if self.click_url: if self.click_url:
return self.click_url return self.click_url
if not self.code: if not self.action_code:
return None return None
link = NotificationLink.objects.filter(code=self.code, is_active=True).first() link = NotificationLink.objects.filter(action_code=self.action_code, is_active=True).first()
if not link: if not link:
return None return None
return link.resolve(self.click_object_id) return link.resolve(self.click_object_id_value)
def get_gotify_extras(self): def get_gotify_extras(self):
extras = dict(self.extras or {}) extras = dict(self.extras or {})
@ -109,10 +111,10 @@ def get_gotify_extras(self):
``` ```
Design decisions worth knowing if you touch this: Design decisions worth knowing if you touch this:
- **An unresolvable 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 code before anyone's configured it in the admin; nothing breaks, the notification just isn't clickable yet. - **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.
- **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. - **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 `code` all go through one function. - **`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 code catalog they need to fill in. - **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 ## Email flow
@ -140,7 +142,8 @@ This service is an OAuth2 **resource server** (`django-oauth-toolkit`), not an O
## Notable migrations ## Notable migrations
- `0004_pushmessage_click_url` — adds `click_url`. - `0004_pushmessage_click_url` — adds `click_url`.
- `0005_notificationlink_pushmessage_click_object_id_and_more` — adds `NotificationLink`, `PushMessage.code`, `PushMessage.click_object_id`. See "The click action mechanism" above. - `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) ## Known technical debt (implementation-level)

View file

@ -2,8 +2,8 @@
> **Update**: this doc captures the initial implementation pass. Destinations > **Update**: this doc captures the initial implementation pass. Destinations
> are no longer built from a hard-coded `FRONTEND_BASE_URL` — producers now > are no longer built from a hard-coded `FRONTEND_BASE_URL` — producers now
> send a `code` + `click_object_id`, resolved against the admin-managed > send an `action_code` + `click_object_id_value`, resolved against the
> `NotificationLink` table. See [implementation.md](implementation.md#the-click-action-mechanism) > admin-managed `NotificationLink` table. See [implementation.md](implementation.md#the-click-action-mechanism)
> for the current mechanism and [frontend-notification-click-actions.md](frontend-notification-click-actions.md) > for the current mechanism and [frontend-notification-click-actions.md](frontend-notification-click-actions.md)
> for the current code catalog. The parts of this doc about `click_url` itself, > for the current code catalog. The parts of this doc about `click_url` itself,
> optionality, and the escrow/billboard notification catalog are still accurate. > optionality, and the escrow/billboard notification catalog are still accurate.