feature/notification-actions #1
4 changed files with 107 additions and 8 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue