feature/notification-actions #1

Merged
Ghasemi merged 5 commits from feature/notification-actions into master 2026-08-23 03:42:40 -04:00
4 changed files with 107 additions and 8 deletions
Showing only changes of commit 707407bb52 - Show all commits

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")