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.template.response import TemplateResponse
from .models import PushUser, PushMessage, BulkPushMessage
from .models import PushUser, PushMessage, BulkPushMessage, NotificationLink
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.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
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):
# Gotify's reserved extras namespace for client-side actions. Official
# clients (Android/iOS/web) open this URL when the notification is tapped:
@ -65,17 +89,38 @@ class PushMessage(BaseModel):
priority = models.IntegerField(default=5)
extras = models.JSONField(default=dict)
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)
bulk = models.ForeignKey("BulkPushMessage", null=True, blank=True, on_delete=models.PROTECT)
def get_gotify_extras(self):
"""Merge 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 {})
def resolve_click_url(self):
"""An explicit click_url always wins; otherwise resolve via `code`
against NotificationLink. Returns None (not an error) if `code` isn't
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:
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["click"] = {"url": self.click_url}
notification_extra["click"] = {"url": click_url}
extras[self.GOTIFY_CLICK_EXTRA_KEY] = notification_extra
return extras
@ -115,7 +160,9 @@ class BulkPushMessage(BaseModel):
message=data[user_uuid].get('message'),
priority=data[user_uuid].get('priority', 5),
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()
self.success_count += 1
@ -146,6 +193,8 @@ class BulkPushMessage(BaseModel):
else:
extras = {}
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] = {
"title" : title,
@ -153,5 +202,7 @@ class BulkPushMessage(BaseModel):
"priority" : priority,
"extras" : extras,
"click_url": click_url or None,
"code": code or None,
"click_object_id": click_object_id or None,
}
return push_dict

View file

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