Compare commits

..

No commits in common. "c919eef00fe53f12e75cb4e682190b863bd4b57d" and "48a764caa7fe5f6284ba4be59761f55419679999" have entirely different histories.

7 changed files with 67 additions and 100 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 = ['action_code', 'url_template', 'is_active', 'description'] list_display = ['code', 'url_template', 'is_active', 'description']
list_filter = ['is_active'] list_filter = ['is_active']
search_fields = ['action_code', 'description'] search_fields = ['code', 'description']
from django import forms from django import forms

View file

@ -1,28 +0,0 @@
# 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 action_code -> URL mapping for notification click actions. Admin-managed code -> URL mapping for notification click actions. Producer
Producer services send an `action_code` (documented per notification services send a `code` (documented per notification type) instead of a raw
type) instead of a raw URL, so where a tap should navigate is a config URL, so where a tap should navigate is a config change here, not a code
change here, not a code deploy across every producer service. deploy across every producer service.
""" """
action_code = models.SlugField(max_length=100, unique=True, db_index=True) 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.action_code return self.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,30 +89,28 @@ 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 # Alternative to a raw click_url: a documented per-notification-type code,
# action_code, resolved against NotificationLink at send time (see # resolved against NotificationLink at send time (see get_gotify_extras).
# get_gotify_extras). click_object_id_value is substituted into that # click_object_id is substituted into that code's {object_id} placeholder.
# action_code's {object_id} placeholder. 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)
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 """An explicit click_url always wins; otherwise resolve via `code`
`action_code` against NotificationLink. Returns None (not an error) against NotificationLink. Returns None (not an error) if `code` isn't
if `action_code` isn't set, or isn't configured/active yet — not set, or isn't configured/active yet — not every notification is
every notification is clickable, and a not-yet-configured clickable, and a not-yet-configured code shouldn't block delivery."""
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.action_code: if not self.code:
return None return None
link = NotificationLink.objects.filter(action_code=self.action_code, is_active=True).first() link = NotificationLink.objects.filter(code=self.code, is_active=True).first()
if not link: if not link:
return None return None
return link.resolve(self.click_object_id_value) return link.resolve(self.click_object_id)
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
@ -163,8 +161,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,
action_code=data[user_uuid].get("action_code") or None, code=data[user_uuid].get("code") or None,
click_object_id_value=data[user_uuid].get("click_object_id_value") or None) click_object_id=data[user_uuid].get("click_object_id") or None)
push_message.send_push() push_message.send_push()
self.success_count += 1 self.success_count += 1
@ -195,8 +193,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 ""
action_code = str(sh.cell(rowx=row, colx=6).value).strip() if sh.ncols > 6 else "" code = str(sh.cell(rowx=row, colx=6).value).strip() if sh.ncols > 6 else ""
click_object_id_value = str(sh.cell(rowx=row, colx=7).value).strip() if sh.ncols > 7 else "" click_object_id = 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,
@ -204,7 +202,7 @@ class BulkPushMessage(BaseModel):
"priority" : priority, "priority" : priority,
"extras" : extras, "extras" : extras,
"click_url": click_url or None, "click_url": click_url or None,
"action_code": action_code or None, "code": code or None,
"click_object_id_value": click_object_id_value or None, "click_object_id": click_object_id 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",
"action_code", "code",
"click_object_id_value", "click_object_id",
) )
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 an **action_code**, and the notifications service notification type has a **code**, and the notifications service resolves
resolves that action_code against an admin-managed table (`NotificationLink`: that code against an admin-managed table (`NotificationLink`: `code`,
`action_code`, `url_template`, `is_active`) at send time. `url_template` `url_template`, `is_active`) at send time. `url_template` supports a
supports a `{object_id}` placeholder, e.g. `https://app.gooyal.ir/ads/{object_id}` `{object_id}` placeholder, e.g. `https://app.gooyal.ir/ads/{object_id}` or
or `gooyal://ads/{object_id}` — whichever scheme your app actually uses. `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
action_code below with your actual URL format, and every notification of code below with your actual URL format, and every notification of that type
that type immediately starts carrying it. If an action_code has no row yet immediately starts carrying it. If a code has no row yet (or its row is
(or its row is marked inactive), that notification simply has no marked inactive), that notification simply has no `click_url` — same as any
`click_url` — same as any other non-clickable notification, no error. 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 action_code, ask whoever owns that than what's currently configured for any code, ask whoever owns that panel
panel to update it. to update it.
## What's clickable today, by action_code ## What's clickable today, by code
All of these are live in the `advertising` service: All of these are live in the `advertising` service:
### Billboards & content ### Billboards & content
| Action code | Notification text (fa) | | 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 action_code above sends the ad's `uuid` as `click_object_id_value`, so a Every code above sends the ad's `uuid` as `click_object_id`, 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
| Action code | Trigger | | 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_value`. Same All fifteen send the escrow deal's `uuid` as `click_object_id`. 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,29 +54,27 @@ 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` | `action_code` | `click_object_id_value` | | Field | `user_uuid` | `title` | `message` | `priority` | `extras` (JSON string) | `click_url` | `code` | `click_object_id` |
`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, then renamed in `code`/`click_object_id` and the `NotificationLink` model. The design
`0006` to `action_code`/`click_object_id_value`. The design constraint constraint throughout: **most notifications aren't clickable**, so this had
throughout: **most notifications aren't clickable**, so this had to be fully to be fully optional at every layer, and had to work identically for both
optional at every layer, and had to work identically for both the single the single and bulk paths without a second implementation.
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. **`action_code` + `click_object_id_value`** — the preferred path. 2. **`code` + `click_object_id`** — the preferred path. `code` is a documented,
`action_code` is a documented, per-notification-type string (e.g. per-notification-type string (e.g. `billboard.approved`, `escrow.timeout` —
`billboard.approved`, `escrow.timeout` — see see [notification-click-actions.md](notification-click-actions.md) for the
[notification-click-actions.md](notification-click-actions.md) for the full catalog); `click_object_id` is the id of the thing the notification is
full catalog); `click_object_id_value` is the id of the thing the about (an ad's or escrow deal's uuid, typically). Resolved at send time
notification is about (an ad's or escrow deal's uuid, typically). against `NotificationLink`, an admin-managed table
Resolved at send time against `NotificationLink`, an admin-managed table (`code`, `url_template`, `is_active`) — `url_template` supports a
(`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.
@ -87,18 +85,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)
action_code = models.SlugField(max_length=100, null=True, blank=True, db_index=True) 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) click_object_id = 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.action_code: if not self.code:
return None return None
link = NotificationLink.objects.filter(action_code=self.action_code, is_active=True).first() link = NotificationLink.objects.filter(code=self.code, is_active=True).first()
if not link: if not link:
return None return None
return link.resolve(self.click_object_id_value) return link.resolve(self.click_object_id)
def get_gotify_extras(self): def get_gotify_extras(self):
extras = dict(self.extras or {}) extras = dict(self.extras or {})
@ -111,10 +109,10 @@ def get_gotify_extras(self):
``` ```
Design decisions worth knowing if you touch this: 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. - **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.
- **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 `action_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 `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. - **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.
## Email flow ## Email flow
@ -142,8 +140,7 @@ 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`. - `0005_notificationlink_pushmessage_click_object_id_and_more` — adds `NotificationLink`, `PushMessage.code`, `PushMessage.click_object_id`. See "The click action mechanism" above.
- `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 an `action_code` + `click_object_id_value`, resolved against the > send a `code` + `click_object_id`, resolved against the admin-managed
> admin-managed `NotificationLink` table. See [implementation.md](implementation.md#the-click-action-mechanism) > `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.