diff --git a/apps/push_notifications/admin.py b/apps/push_notifications/admin.py index 145b537..1af021d 100644 --- a/apps/push_notifications/admin.py +++ b/apps/push_notifications/admin.py @@ -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 diff --git a/apps/push_notifications/migrations/0005_notificationlink_pushmessage_click_object_id_and_more.py b/apps/push_notifications/migrations/0005_notificationlink_pushmessage_click_object_id_and_more.py new file mode 100644 index 0000000..00a2640 --- /dev/null +++ b/apps/push_notifications/migrations/0005_notificationlink_pushmessage_click_object_id_and_more.py @@ -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), + ), + ] diff --git a/apps/push_notifications/models.py b/apps/push_notifications/models.py index b176b43..07dcadf 100644 --- a/apps/push_notifications/models.py +++ b/apps/push_notifications/models.py @@ -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 diff --git a/apps/push_notifications/serializers.py b/apps/push_notifications/serializers.py index 5601759..1723f1d 100644 --- a/apps/push_notifications/serializers.py +++ b/apps/push_notifications/serializers.py @@ -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")