FEAT(notifications): admin-managed code -> URL mapping for click actions

The click_url a producer sends had to be built in that producer's own
Python code (utils/deep_links.py in advertising), so changing the URL
scheme meant a code deploy. Adds NotificationLink: an admin-editable
(code, url_template, is_active) row per notification type, with
url_template supporting a {object_id} placeholder.

PushMessage gains `code` and `click_object_id` — a producer can send either
a raw click_url (unchanged, still wins if both are given) or a code + the
referenced object's id, and resolve_click_url() looks up the template at
send time. An unset or not-yet-configured/inactive code resolves to no
click action rather than an error, consistent with "not every notification
is clickable." Wired through both the single-push API (serializer) and the
bulk Excel path (two new optional columns).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Ali Asadi 2026-08-22 14:35:55 +03:30
parent 354bbc26cf
commit 707407bb52
4 changed files with 107 additions and 8 deletions

View file

@ -6,12 +6,19 @@ from django.contrib.admin.options import TO_FIELD_VAR
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from .models import PushUser, PushMessage, BulkPushMessage from .models import PushUser, PushMessage, BulkPushMessage, NotificationLink
admin.site.register(PushUser) admin.site.register(PushUser)
@admin.register(NotificationLink)
class NotificationLinkAdmin(admin.ModelAdmin):
list_display = ['code', 'url_template', 'is_active', 'description']
list_filter = ['is_active']
search_fields = ['code', 'description']
from django import forms from django import forms
from django.shortcuts import render, redirect from django.shortcuts import render, redirect

View file

@ -0,0 +1,39 @@
# Generated by Django 5.2.6 on 2026-08-22 10:59
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('push_notifications', '0004_pushmessage_click_url'),
]
operations = [
migrations.CreateModel(
name='NotificationLink',
fields=[
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True, db_index=True)),
('code', models.SlugField(max_length=100, unique=True)),
('description', models.CharField(blank=True, max_length=255)),
('url_template', models.CharField(help_text='Use {object_id} as a placeholder, e.g. https://app.gooyal.ir/ads/{object_id}', max_length=1000)),
('is_active', models.BooleanField(default=True)),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='pushmessage',
name='click_object_id',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='pushmessage',
name='code',
field=models.SlugField(blank=True, max_length=100, null=True),
),
]

View file

@ -44,6 +44,30 @@ class PushUser(BaseModel):
return return
class NotificationLink(BaseModel):
"""
Admin-managed code -> URL mapping for notification click actions. Producer
services send a `code` (documented per notification type) instead of a raw
URL, so where a tap should navigate is a config change here, not a code
deploy across every producer service.
"""
code = models.SlugField(max_length=100, unique=True, db_index=True)
description = models.CharField(max_length=255, blank=True)
url_template = models.CharField(
max_length=1000,
help_text="Use {object_id} as a placeholder, e.g. https://app.gooyal.ir/ads/{object_id}",
)
is_active = models.BooleanField(default=True)
def __str__(self):
return self.code
def resolve(self, object_id=None):
if object_id and '{object_id}' in self.url_template:
return self.url_template.format(object_id=object_id)
return self.url_template
class PushMessage(BaseModel): class PushMessage(BaseModel):
# Gotify's reserved extras namespace for client-side actions. Official # Gotify's reserved extras namespace for client-side actions. Official
# clients (Android/iOS/web) open this URL when the notification is tapped: # clients (Android/iOS/web) open this URL when the notification is tapped:
@ -65,17 +89,38 @@ 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,
# resolved against NotificationLink at send time (see get_gotify_extras).
# click_object_id is substituted into that code's {object_id} placeholder.
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)
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 get_gotify_extras(self): def resolve_click_url(self):
"""Merge click_url into extras using Gotify's click-action convention, """An explicit click_url always wins; otherwise resolve via `code`
without disturbing any other extras keys the caller already set.""" against NotificationLink. Returns None (not an error) if `code` isn't
extras = dict(self.extras or {}) set, or isn't configured/active yet — not every notification is
clickable, and a not-yet-configured code shouldn't block delivery."""
if self.click_url: if self.click_url:
return self.click_url
if not self.code:
return None
link = NotificationLink.objects.filter(code=self.code, is_active=True).first()
if not link:
return None
return link.resolve(self.click_object_id)
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."""
extras = dict(self.extras or {})
click_url = self.resolve_click_url()
if click_url:
notification_extra = dict(extras.get(self.GOTIFY_CLICK_EXTRA_KEY) or {}) notification_extra = dict(extras.get(self.GOTIFY_CLICK_EXTRA_KEY) or {})
notification_extra["click"] = {"url": self.click_url} notification_extra["click"] = {"url": click_url}
extras[self.GOTIFY_CLICK_EXTRA_KEY] = notification_extra extras[self.GOTIFY_CLICK_EXTRA_KEY] = notification_extra
return extras return extras
@ -115,7 +160,9 @@ class BulkPushMessage(BaseModel):
message=data[user_uuid].get('message'), message=data[user_uuid].get('message'),
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,
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
@ -146,6 +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 ""
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 ""
push_dict[user_uuid] = { push_dict[user_uuid] = {
"title" : title, "title" : title,
@ -153,5 +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,
"code": code or None,
"click_object_id": click_object_id or None,
} }
return push_dict return push_dict

View file

@ -22,6 +22,8 @@ class PushMessageSerializer(serializers.ModelSerializer):
"message", "message",
"priority", "priority",
"extras", "extras",
"click_url" "click_url",
"code",
"click_object_id",
) )
read_only_fields = ("push_user","application") read_only_fields = ("push_user","application")