Compare commits
No commits in common. "48a764caa7fe5f6284ba4be59761f55419679999" and "9a010aa97b2af095372671075b70f9085ba48dfb" have entirely different histories.
48a764caa7
...
9a010aa97b
11 changed files with 5 additions and 784 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -2,7 +2,3 @@
|
||||||
.env
|
.env
|
||||||
media
|
media
|
||||||
/clients/
|
/clients/
|
||||||
|
|
||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
*$py.class
|
|
||||||
|
|
|
||||||
|
|
@ -6,19 +6,12 @@ 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, NotificationLink
|
from .models import PushUser, PushMessage, BulkPushMessage
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
# Generated by Django 5.2.6 on 2026-08-22 05:29
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('push_notifications', '0003_bulkpushmessage_pushmessage_bulk'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='pushmessage',
|
|
||||||
name='click_url',
|
|
||||||
field=models.URLField(blank=True, max_length=1000, null=True),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
# 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,36 +44,7 @@ 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
|
|
||||||
# clients (Android/iOS/web) open this URL when the notification is tapped:
|
|
||||||
# https://gotify.net/docs/pushmsg#extras
|
|
||||||
GOTIFY_CLICK_EXTRA_KEY = "client::notification"
|
|
||||||
|
|
||||||
class StateChoices(models.IntegerChoices):
|
class StateChoices(models.IntegerChoices):
|
||||||
INIT = 0, _('init')
|
INIT = 0, _('init')
|
||||||
START = 1, _('Start')
|
START = 1, _('Start')
|
||||||
|
|
@ -88,42 +59,10 @@ class PushMessage(BaseModel):
|
||||||
message = models.TextField()
|
message = models.TextField()
|
||||||
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)
|
|
||||||
# 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 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": click_url}
|
|
||||||
extras[self.GOTIFY_CLICK_EXTRA_KEY] = notification_extra
|
|
||||||
return extras
|
|
||||||
|
|
||||||
def send_push(self):
|
def send_push(self):
|
||||||
from apps.push_notifications.tasks import send_push_notification
|
from apps.push_notifications.tasks import send_push_notification
|
||||||
send_push_notification.delay(str(self.uuid))
|
send_push_notification.delay(str(self.uuid))
|
||||||
|
|
@ -159,10 +98,7 @@ class BulkPushMessage(BaseModel):
|
||||||
title=data[user_uuid].get('title'),
|
title=data[user_uuid].get('title'),
|
||||||
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,
|
|
||||||
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
|
||||||
|
|
@ -189,20 +125,14 @@ class BulkPushMessage(BaseModel):
|
||||||
priority = int(float(str(sh.cell(rowx=row, colx=3).value).strip()))
|
priority = int(float(str(sh.cell(rowx=row, colx=3).value).strip()))
|
||||||
extras_str = sh.cell(rowx=row, colx=4).value.strip()
|
extras_str = sh.cell(rowx=row, colx=4).value.strip()
|
||||||
if extras_str:
|
if extras_str:
|
||||||
extras = json.loads(extras_str)
|
extras = json.loads(extras)
|
||||||
else:
|
else:
|
||||||
extras = {}
|
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] = {
|
push_dict[user_uuid] = {
|
||||||
"title" : title,
|
"title" : title,
|
||||||
"message" : message,
|
"message" : message,
|
||||||
"priority" : priority,
|
"priority" : priority,
|
||||||
"extras" : extras,
|
"extras" : extras,
|
||||||
"click_url": click_url or None,
|
|
||||||
"code": code or None,
|
|
||||||
"click_object_id": click_object_id or None,
|
|
||||||
}
|
}
|
||||||
return push_dict
|
return push_dict
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,6 @@ class PushMessageSerializer(serializers.ModelSerializer):
|
||||||
"title",
|
"title",
|
||||||
"message",
|
"message",
|
||||||
"priority",
|
"priority",
|
||||||
"extras",
|
"extras"
|
||||||
"click_url",
|
|
||||||
"code",
|
|
||||||
"click_object_id",
|
|
||||||
)
|
)
|
||||||
read_only_fields = ("push_user","application")
|
read_only_fields = ("push_user","application")
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ def send_push_notification(push_message_uuid):
|
||||||
title=push_message.title,
|
title=push_message.title,
|
||||||
message=push_message.message,
|
message=push_message.message,
|
||||||
priority=push_message.priority,
|
priority=push_message.priority,
|
||||||
extras=push_message.get_gotify_extras())
|
extras=push_message.extras)
|
||||||
push_message.state = push_message.StateChoices.DONE
|
push_message.state = push_message.StateChoices.DONE
|
||||||
push_message.save()
|
push_message.save()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
# Frontend integration guide: notification click actions
|
|
||||||
|
|
||||||
**Audience**: the team building the Gooyal client app(s) that receive Gotify
|
|
||||||
push notifications. This is everything you need to start building tap-to-navigate,
|
|
||||||
independent of the backend repos.
|
|
||||||
|
|
||||||
## The one thing to build
|
|
||||||
|
|
||||||
When a push notification is tapped, read a URL out of the notification's
|
|
||||||
payload and navigate to it. That's the entire client-side contract — one
|
|
||||||
handler, used by every notification type below, present or future.
|
|
||||||
|
|
||||||
## Where the URL lives
|
|
||||||
|
|
||||||
Gotify delivers a JSON payload per message. The URL — when the notification
|
|
||||||
is clickable at all — is under a reserved key, per
|
|
||||||
[Gotify's own convention](https://gotify.net/docs/pushmsg#extras):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"title": "بیلبورد شما تایید شد.",
|
|
||||||
"message": "بیلبورد گربه (تست) تایید شد.",
|
|
||||||
"priority": 5,
|
|
||||||
"extras": {
|
|
||||||
"ads::ad::approve": true,
|
|
||||||
"ad_uuid": "8fb638c2-e6a4-4baf-aefe-83dab78fb5bd",
|
|
||||||
"client::notification": {
|
|
||||||
"click": { "url": "https://app.gooyal.ir/ads/8fb638c2-e6a4-4baf-aefe-83dab78fb5bd" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Pull `extras["client::notification"]["click"]["url"]`. If it's present, tapping
|
|
||||||
navigates there. **If it's absent, do nothing on tap** — see next section.
|
|
||||||
|
|
||||||
## `click_url` is optional — most notifications are not clickable
|
|
||||||
|
|
||||||
Do not assume every notification carries a URL. The backend field this comes
|
|
||||||
from is explicitly optional (nullable end-to-end, not just "sometimes empty
|
|
||||||
string") specifically because not every notification type has a sensible
|
|
||||||
destination. Any notification without a `client::notification.click.url` key
|
|
||||||
should render and behave exactly as a non-interactive notification — no
|
|
||||||
error, no dead tap target, just no navigation.
|
|
||||||
|
|
||||||
You may also see other keys under `extras` alongside (or instead of)
|
|
||||||
`client::notification` — e.g. chat currently sends `conversation_uuid` and
|
|
||||||
`post_id` directly, not yet through this mechanism (see "Not yet migrated"
|
|
||||||
below). Ignore keys you don't recognize; don't treat an unfamiliar key as an
|
|
||||||
error.
|
|
||||||
|
|
||||||
## URLs are admin-configured, not hard-coded — nothing for you to build here
|
|
||||||
|
|
||||||
The actual destination string is no longer computed in backend code. Each
|
|
||||||
notification type has a **code**, and the notifications service resolves
|
|
||||||
that code against an admin-managed table (`NotificationLink`: `code`,
|
|
||||||
`url_template`, `is_active`) at send time. `url_template` supports a
|
|
||||||
`{object_id}` placeholder, e.g. `https://app.gooyal.ir/ads/{object_id}` or
|
|
||||||
`gooyal://ads/{object_id}` — whichever scheme your app actually uses.
|
|
||||||
|
|
||||||
This means: **your routing scheme is a config decision, not a code change**.
|
|
||||||
Whoever manages the notifications service's admin panel enters one row per
|
|
||||||
code below with your actual URL format, and every notification of that type
|
|
||||||
immediately starts carrying it. If a code has no row yet (or its row is
|
|
||||||
marked inactive), that notification simply has no `click_url` — same as any
|
|
||||||
other non-clickable notification, no error.
|
|
||||||
|
|
||||||
Practically, this doesn't change anything about what you build — you still
|
|
||||||
just read `extras["client::notification"]["click"]["url"]` and navigate if
|
|
||||||
it's present. It changes who's responsible for the URL *string itself*: not
|
|
||||||
a backend deploy, just an admin panel entry. If you want a different value
|
|
||||||
than what's currently configured for any code, ask whoever owns that panel
|
|
||||||
to update it.
|
|
||||||
|
|
||||||
## What's clickable today, by code
|
|
||||||
|
|
||||||
All of these are live in the `advertising` service:
|
|
||||||
|
|
||||||
### Billboards & content
|
|
||||||
|
|
||||||
| Code | Notification text (fa) |
|
|
||||||
|---|---|
|
|
||||||
| `billboard.viewed` | شخصی شروع به مشاهده بیلبورد شما کرد. |
|
|
||||||
| `billboard.created` | بیلبود شما با موفقیت ساخته شد. |
|
|
||||||
| `billboard.approved` | بیلبورد شما تایید شد. |
|
|
||||||
| `billboard.rejected` | بیلبود شما رد شد. |
|
|
||||||
| `billboard.commented` | یک نفر برای بیلبورد شما نظر گذاشت! |
|
|
||||||
| `billboard.replied` | یک نفر به نظر شما پاسخ داد! |
|
|
||||||
| `billboard.content_bought` | درآمد جدید دارید! یک نفر محتوای شما را خرید. |
|
|
||||||
| `billboard.content_supported` | یک نفر از محتوای شما حمایت کرد! |
|
|
||||||
| `billboard.pin_expiring` | زمان پین رو به اتمامه! |
|
|
||||||
| `billboard.pin_expired` | پین شما منقضی شد! |
|
|
||||||
| `billboard.credit_low` | اعتبار پین رو به اتمامه! |
|
|
||||||
| `billboard.credit_exhausted` | اعتبار پین شما به پایان رسید! |
|
|
||||||
|
|
||||||
Every code above sends the ad's `uuid` as `click_object_id`, so a
|
|
||||||
`url_template` of `https://app.gooyal.ir/ads/{object_id}` (or your app's
|
|
||||||
real equivalent) resolves correctly for all twelve. They're still separate
|
|
||||||
codes rather than one shared one, on purpose: `billboard.pin_expiring` and
|
|
||||||
`billboard.credit_low` are genuinely different situations (time running out
|
|
||||||
vs. budget running out) even though they'd point at the same screen today —
|
|
||||||
giving each its own code means that can diverge later (e.g. deep-linking
|
|
||||||
straight to an "extend time" vs. "top up credit" action) without any code
|
|
||||||
change, just a new admin row.
|
|
||||||
|
|
||||||
### Escrow deals
|
|
||||||
|
|
||||||
| Code | Trigger |
|
|
||||||
|---|---|
|
|
||||||
| `escrow.request_deal` | Buyer paid, seller needs to review |
|
|
||||||
| `escrow.cancel_deal` | Buyer canceled before seller responded |
|
|
||||||
| `escrow.reject_deal` | Seller declined the deal |
|
|
||||||
| `escrow.approve_deal` | Seller confirmed the deal |
|
|
||||||
| `escrow.cancel_deal_by_seller` | Seller canceled an active deal |
|
|
||||||
| `escrow.request_cancel` | Buyer requested cancellation |
|
|
||||||
| `escrow.approve_cancel` | Seller accepted the cancellation |
|
|
||||||
| `escrow.reject_cancel` | Seller declined the cancellation |
|
|
||||||
| `escrow.confirm_by_seller` | Seller marked it delivered |
|
|
||||||
| `escrow.confirm_by_buyer` | Buyer confirmed receipt — deal completed |
|
|
||||||
| `escrow.request_judge` | A dispute was opened |
|
|
||||||
| `escrow.cancel_judge` | Buyer withdrew their dispute |
|
|
||||||
| `escrow.approve_judge` | Dispute resolved for the buyer |
|
|
||||||
| `escrow.reject_judge` | Dispute resolved for the seller |
|
|
||||||
| `escrow.timeout` | Deal expired without a response |
|
|
||||||
|
|
||||||
All fifteen send the escrow deal's `uuid` as `click_object_id`. Same
|
|
||||||
one-code-per-situation reasoning as billboards — they all currently resolve
|
|
||||||
to the same escrow-detail destination, but that's an admin config choice,
|
|
||||||
not a hard-coded one, so it's free to diverge later.
|
|
||||||
|
|
||||||
## Not yet migrated to this mechanism
|
|
||||||
|
|
||||||
- **chat**: sends `extras = {"conversation_uuid": ..., "post_id": ...}` directly, without a code or `click_url`. If your client already has custom handling for these two keys (built before this mechanism existed), it keeps working — this doc doesn't change that. Let us know if you'd rather chat send a code too.
|
|
||||||
- **promotions**: integrated with the notifications service but not currently sending any notification with real content (`extras={}` today) — nothing to build against yet.
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
# Notifications Service — Implementation Guide
|
|
||||||
|
|
||||||
Technical walkthrough of how the service is actually built, for anyone
|
|
||||||
maintaining or extending it. For what the service *does* and how to
|
|
||||||
integrate with it, see [service-overview.md](service-overview.md).
|
|
||||||
|
|
||||||
## Code layout
|
|
||||||
|
|
||||||
```
|
|
||||||
apps/
|
|
||||||
core/ root URL ("/") — a login-gated placeholder home view, nothing else
|
|
||||||
users/ local User model, mirrored by uuid from the accounts service
|
|
||||||
gooyal_oauth2/ OAuth2 resource-server wiring: token validator, permission class,
|
|
||||||
get_application() helper
|
|
||||||
push_notifications/ push domain: PushUser, PushMessage, BulkPushMessage
|
|
||||||
emails/ email domain: Email, EmailQueue
|
|
||||||
utils/
|
|
||||||
clients/gotify.py the only code that talks to the Gotify HTTP API
|
|
||||||
models.py BaseModel (uuid pk, created_at/updated_at)
|
|
||||||
gotify_rest_api_client/ generated OpenAPI client for Gotify (do not hand-edit)
|
|
||||||
main/
|
|
||||||
settings.py, celery.py, urls.py, wsgi.py/asgi.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Push notification flow
|
|
||||||
|
|
||||||
### Single push
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /push/application/<user_uuid>/application/
|
|
||||||
↓ ApplicationPushUserViewSet.perform_create() (apps/push_notifications/views.py)
|
|
||||||
1. resolve target User by user_uuid
|
|
||||||
2. resolve/create their PushUser (Gotify identity) — PushUser.objects.submit(user)
|
|
||||||
3. resolve the calling Application from the OAuth2 token
|
|
||||||
4. serializer.save(push_user=..., application=...) → PushMessage row
|
|
||||||
5. instance.send_push() → send_push_notification.delay(uuid) (Celery)
|
|
||||||
↓ apps/push_notifications/tasks.py
|
|
||||||
gotify.send_notif(token=push_user.application_token,
|
|
||||||
title, message, priority,
|
|
||||||
extras=push_message.get_gotify_extras())
|
|
||||||
push_message.state = DONE
|
|
||||||
```
|
|
||||||
|
|
||||||
`PushUser.objects.submit(user)` provisions three things in Gotify on first use, all via `utils/clients/gotify.py`, and persists the resulting tokens on `PushUser`:
|
|
||||||
1. `submit_user` — a Gotify *user* named after the Gooyal `user_uuid` (admin-token auth)
|
|
||||||
2. `submit_client` — a Gotify *client*, whose token becomes `PushUser.client_token` (basic-auth as the just-created user)
|
|
||||||
3. `submit_application` — a Gotify *application* under that client, whose token becomes `PushUser.application_token` — **this is the token push messages are actually sent with**
|
|
||||||
|
|
||||||
### Bulk push
|
|
||||||
|
|
||||||
An admin action (`BulkPushMessageAdmin`, `apps/push_notifications/admin.py`) triggers `BulkPushMessage.bulk_push_task()` → Celery `bulk_push` task → `BulkPushMessage.push_to_all(extract_data())`.
|
|
||||||
|
|
||||||
`extract_data()` reads a legacy `.xls` file (via `xlrd` — only `.xls`, not `.xlsx`, since `xlrd` dropped xlsx support at 2.0) with columns:
|
|
||||||
|
|
||||||
| Col | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|
||||||
| Field | `user_uuid` | `title` | `message` | `priority` | `extras` (JSON string) | `click_url` | `code` | `click_object_id` |
|
|
||||||
|
|
||||||
`push_to_all()` looks up each row's `PushUser` by `user_id`; missing users are counted as failures rather than raising, so one bad row doesn't abort the batch. Each successful row creates a `PushMessage` and calls `send_push()` — it goes through the exact same Celery task and Gotify call as a single push, so there's no separate bulk-specific delivery code path to keep in sync.
|
|
||||||
|
|
||||||
## The click action mechanism
|
|
||||||
|
|
||||||
Added in migration `0004_pushmessage_click_url`, extended in `0005` with
|
|
||||||
`code`/`click_object_id` and the `NotificationLink` model. The design
|
|
||||||
constraint throughout: **most notifications aren't clickable**, so this had
|
|
||||||
to be fully optional at every layer, and had to work identically for both
|
|
||||||
the single and bulk paths without a second implementation.
|
|
||||||
|
|
||||||
Two ways to set a destination on a `PushMessage`:
|
|
||||||
1. **`click_url`** — a raw URL the caller already knows. Always wins if set.
|
|
||||||
2. **`code` + `click_object_id`** — the preferred path. `code` is a documented,
|
|
||||||
per-notification-type string (e.g. `billboard.approved`, `escrow.timeout` —
|
|
||||||
see [notification-click-actions.md](notification-click-actions.md) for the
|
|
||||||
full catalog); `click_object_id` is the id of the thing the notification is
|
|
||||||
about (an ad's or escrow deal's uuid, typically). Resolved at send time
|
|
||||||
against `NotificationLink`, an admin-managed table
|
|
||||||
(`code`, `url_template`, `is_active`) — `url_template` supports a
|
|
||||||
`{object_id}` placeholder. This is what moved the actual destination
|
|
||||||
strings out of Python and into the Django admin, so a routing-scheme
|
|
||||||
change is a config edit, not a deploy across every producer service.
|
|
||||||
|
|
||||||
`apps/push_notifications/models.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
GOTIFY_CLICK_EXTRA_KEY = "client::notification" # Gotify's own reserved namespace
|
|
||||||
|
|
||||||
click_url = models.URLField(max_length=1000, null=True, blank=True)
|
|
||||||
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)
|
|
||||||
|
|
||||||
def resolve_click_url(self):
|
|
||||||
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):
|
|
||||||
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": click_url}
|
|
||||||
extras[self.GOTIFY_CLICK_EXTRA_KEY] = notification_extra
|
|
||||||
return extras
|
|
||||||
```
|
|
||||||
|
|
||||||
Design decisions worth knowing if you touch this:
|
|
||||||
- **An unresolvable code is not an error** — no matching `NotificationLink`, or one marked `is_active=False`, just means no click action, same as a message with nothing set at all. A producer can start sending a new code before anyone's configured it in the admin; nothing breaks, the notification just isn't clickable yet.
|
|
||||||
- **The merge is additive**: any other `extras` keys a producer already sends (e.g. chat's `conversation_uuid`/`post_id`) pass through untouched — `get_gotify_extras()` only ever adds the `client::notification` key, never removes others.
|
|
||||||
- **`get_gotify_extras()` is the single place resolution happens** — `tasks.py` calls it instead of reading `push_message.extras`/`click_url` directly, so the single-push path, the bulk-push path, and both `click_url` and `code` all go through one function.
|
|
||||||
- **The admin table is deliberately not seeded with rows by any migration.** Populating it is an ops/product decision (which is the entire point of moving it out of code) — see [frontend-notification-click-actions.md](frontend-notification-click-actions.md) for the current code catalog they need to fill in.
|
|
||||||
|
|
||||||
## Email flow
|
|
||||||
|
|
||||||
Two paths, both in `apps/emails/`:
|
|
||||||
|
|
||||||
- **Immediate**: `Email.send()` → `send_email` Celery task → `Email._send_email()` → `django.core.mail.send_mail()`.
|
|
||||||
- **Queued digest**: `Email.objects.create(..., queue=some_queue)` — `EmailQueue` rows are checked every 10 seconds by `send_email_for_queued_events` (registered via `celery_app.conf.beat_schedule` directly in `apps/emails/tasks.py`, not through the `CELERY_BEAT_SCHEDULE` Django setting other Gooyal services use — see the overview doc's known-gaps section). A queue only actually sends once it's been at least 10 minutes since its `last_sent`, batching every `Email` created since then into one message.
|
|
||||||
|
|
||||||
**`_send_email()` currently hard-codes the recipient** to a literal test address instead of `self.user`'s email — this means the immediate-send path doesn't actually reach the intended recipient today. Flagging this here since it's the kind of thing that's easy to assume "must already work" when reading the flow.
|
|
||||||
|
|
||||||
## Auth internals
|
|
||||||
|
|
||||||
This service is an OAuth2 **resource server** (`django-oauth-toolkit`), not an OAuth2 provider — it doesn't issue tokens, it validates ones issued by the Gooyal accounts service via introspection (`OAUTH2_PROVIDER['RESOURCE_SERVER_INTROSPECTION_URL']`, using this service's own `CLIENT_ID`/`CLIENT_SECRET` as the introspection credentials).
|
|
||||||
|
|
||||||
`apps.gooyal_oauth2.rest_framework.IsAuthenticatedOrTokenMatchesOASRequirements` is the permission class every producer-facing endpoint uses. It accepts either:
|
|
||||||
- a plain authenticated (non-OAuth2) request, **or**
|
|
||||||
- an OAuth2 token whose scopes satisfy the view's `required_alternate_scopes`
|
|
||||||
|
|
||||||
`apps.gooyal_oauth2.utils.get_application(request)` pulls the calling `Application` off `request.auth.application` — this is how a `PushMessage`/`Email` row knows which producer service created it, without trusting a client-supplied field.
|
|
||||||
|
|
||||||
## Testing status
|
|
||||||
|
|
||||||
`apps/push_notifications/tests.py` and `apps/emails/tests.py` are both empty stubs — `python manage.py test` reports 0 tests. Everything described in this doc and in [notification-click-actions.md](notification-click-actions.md) was verified manually (unit-level checks via `manage.py shell`, and live round-trips against the local Gotify 2.6.3 container), not via an automated suite. If you're adding tests, `push_notifications` is the higher-value target — it's the domain three other services actively depend on.
|
|
||||||
|
|
||||||
## Notable migrations
|
|
||||||
|
|
||||||
- `0004_pushmessage_click_url` — adds `click_url`.
|
|
||||||
- `0005_notificationlink_pushmessage_click_object_id_and_more` — adds `NotificationLink`, `PushMessage.code`, `PushMessage.click_object_id`. See "The click action mechanism" above.
|
|
||||||
|
|
||||||
## Known technical debt (implementation-level)
|
|
||||||
|
|
||||||
- `apps/push_notifications/admin.py` has a half-built `PushMessageAdmin` custom form class (`PushMessageForm`, a `custom-action2` URL) that is **never registered** — `admin.site.register(PushMessage)` uses the plain default `ModelAdmin` instead. Dead code, safe to ignore or remove.
|
|
||||||
- Duplicate `app_name = 'push_notifications'` across `application_urls.py`/`user_urls.py` (see overview doc).
|
|
||||||
- Bulk import is locked to legacy `.xls` by the `xlrd` dependency; there's no `.xlsx` path.
|
|
||||||
|
|
@ -1,244 +0,0 @@
|
||||||
# Notification click actions
|
|
||||||
|
|
||||||
> **Update**: this doc captures the initial implementation pass. Destinations
|
|
||||||
> are no longer built from a hard-coded `FRONTEND_BASE_URL` — producers now
|
|
||||||
> send a `code` + `click_object_id`, resolved against the admin-managed
|
|
||||||
> `NotificationLink` table. See [implementation.md](implementation.md#the-click-action-mechanism)
|
|
||||||
> for the current mechanism and [frontend-notification-click-actions.md](frontend-notification-click-actions.md)
|
|
||||||
> for the current code catalog. The parts of this doc about `click_url` itself,
|
|
||||||
> optionality, and the escrow/billboard notification catalog are still accurate.
|
|
||||||
|
|
||||||
How a tapped push notification carries a destination, why that job splits across
|
|
||||||
several repos, and what still needs building outside this service.
|
|
||||||
|
|
||||||
## Your question, answered: frontend or backend?
|
|
||||||
|
|
||||||
Both, split cleanly:
|
|
||||||
|
|
||||||
- **Choosing the destination is a backend job.** A producer service (chat,
|
|
||||||
promotions, advertising, ...) decides which screen a tap should open and
|
|
||||||
hands that URL to this notifications service. That's what's implemented
|
|
||||||
below.
|
|
||||||
- **Acting on the tap is unavoidably client-side.** Only the process that
|
|
||||||
owns the tap event and the navigation stack — the Gooyal mobile/web app —
|
|
||||||
can respond to it. No backend service can intercept a notification tap;
|
|
||||||
Gotify's job ends the moment the message is delivered.
|
|
||||||
|
|
||||||
Not every notification is clickable, and `click_url` is a **non-required**
|
|
||||||
field end to end — see [Optionality](#optionality-not-every-notification-is-clickable).
|
|
||||||
|
|
||||||
## 1. How push flows across Gooyal today
|
|
||||||
|
|
||||||
Three other services vendor a generated client for this one
|
|
||||||
(`gooyal_notifications_client`) and call into it over OAuth2
|
|
||||||
client-credentials. Gotify then holds the message until the user's device
|
|
||||||
fetches it.
|
|
||||||
|
|
||||||
```
|
|
||||||
chat / promotions / advertising
|
|
||||||
│ POST /push/application/<user_uuid>/application/
|
|
||||||
▼
|
|
||||||
notifications service (this repo)
|
|
||||||
│ create_message, extras: client::notification.click.url
|
|
||||||
▼
|
|
||||||
Gotify 2.6.3
|
|
||||||
│ push delivery
|
|
||||||
▼
|
|
||||||
Gooyal client app (repo not found in this workspace)
|
|
||||||
│ tap → must read extras and navigate
|
|
||||||
▼
|
|
||||||
? destination screen
|
|
||||||
```
|
|
||||||
|
|
||||||
### What each producer sends today
|
|
||||||
|
|
||||||
| Service | Call site | Extras sent | Status |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **chat** | `apps/chat/events/publishers/push.py:40` | Bespoke keys: `{"conversation_uuid", "post_id"}` — not Gotify's own convention | live, fires on every chat message |
|
|
||||||
| **promotions** | `apps/promotions/models.py:544` | `extras={}` | wired, unused |
|
|
||||||
| **advertising** | `apps/users/models.py:91` | — | call commented out entirely |
|
|
||||||
|
|
||||||
None of the three passed a click URL before this change. Chat's bespoke
|
|
||||||
`conversation_uuid`/`post_id` scheme implies some client already parses
|
|
||||||
custom keys per feature — every new use case would otherwise need its own
|
|
||||||
key and its own client-side handler. Standardizing on Gotify's own
|
|
||||||
`client::notification.click.url` convention means the client only needs one
|
|
||||||
tap handler instead of one per feature.
|
|
||||||
|
|
||||||
## 2. What changed in this repo (`notifications`)
|
|
||||||
|
|
||||||
Branch: `feature/notification-actions`.
|
|
||||||
|
|
||||||
Added a `click_url` field to `PushMessage` and one merge method that folds
|
|
||||||
it into Gotify's reserved `client::notification` extras namespace — the key
|
|
||||||
official Gotify clients already read on tap
|
|
||||||
([gotify.net/docs/pushmsg#extras](https://gotify.net/docs/pushmsg#extras)).
|
|
||||||
Both message-creation paths funnel through this one method, so there is
|
|
||||||
exactly one place that builds the payload Gotify receives.
|
|
||||||
|
|
||||||
`apps/push_notifications/models.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# reserved extras namespace — official clients open this url on tap
|
|
||||||
GOTIFY_CLICK_EXTRA_KEY = "client::notification"
|
|
||||||
|
|
||||||
click_url = models.URLField(max_length=1000, null=True, blank=True)
|
|
||||||
|
|
||||||
def get_gotify_extras(self):
|
|
||||||
extras = dict(self.extras or {})
|
|
||||||
if self.click_url:
|
|
||||||
notification_extra = dict(extras.get(self.GOTIFY_CLICK_EXTRA_KEY) or {})
|
|
||||||
notification_extra["click"] = {"url": self.click_url}
|
|
||||||
extras[self.GOTIFY_CLICK_EXTRA_KEY] = notification_extra
|
|
||||||
return extras
|
|
||||||
```
|
|
||||||
|
|
||||||
Any existing keys a producer already sends — chat's `conversation_uuid`,
|
|
||||||
for instance — pass through untouched. `click_url` is additive, never a
|
|
||||||
replacement.
|
|
||||||
|
|
||||||
### Optionality: not every notification is clickable
|
|
||||||
|
|
||||||
`click_url` is `null=True, blank=True` on the model, which makes it
|
|
||||||
`required=False` / `allow_null=True` / `allow_blank=True` on the DRF
|
|
||||||
serializer automatically — confirmed directly:
|
|
||||||
|
|
||||||
```python
|
|
||||||
>>> from apps.push_notifications.serializers import PushMessageSerializer
|
|
||||||
>>> PushMessageSerializer().get_fields()['click_url'].required
|
|
||||||
False
|
|
||||||
>>> PushMessageSerializer().get_fields()['click_url'].allow_null
|
|
||||||
True
|
|
||||||
```
|
|
||||||
|
|
||||||
When it's left unset, `get_gotify_extras()` returns `self.extras` completely
|
|
||||||
untouched — no `client::notification` key is added, so non-clickable
|
|
||||||
notifications are delivered exactly as before.
|
|
||||||
|
|
||||||
### Both message-creation paths were wired through it
|
|
||||||
|
|
||||||
| Path | Entry point | Change |
|
|
||||||
|---|---|---|
|
|
||||||
| **Single push** — used by chat / promotions / advertising | `POST /push/application/<user_uuid>/application/` | Added `click_url` to `PushMessageSerializer` — optional, alongside `title`/`message`/`extras` |
|
|
||||||
| **Bulk push** — admin-triggered Excel import | `BulkPushMessage.extract_data()` | Reads an optional 6th spreadsheet column as `click_url`; threaded through `push_to_all()` |
|
|
||||||
|
|
||||||
Bulk spreadsheet columns:
|
|
||||||
|
|
||||||
| Col | 0 | 1 | 2 | 3 | 4 | 5 (new) |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| Field | user_uuid | title | message | priority | extras (JSON) | click_url |
|
|
||||||
|
|
||||||
While extending `extract_data()` for the new column, fixed a pre-existing
|
|
||||||
bug on the same line: the extras cell was parsed with `json.loads(extras)`
|
|
||||||
— a name that was never assigned — instead of `extras_str`. Any bulk row
|
|
||||||
with a populated extras cell would have raised `NameError` before reaching
|
|
||||||
Gotify at all.
|
|
||||||
|
|
||||||
### Request/response example
|
|
||||||
|
|
||||||
Request:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"title": "Order shipped",
|
|
||||||
"message": "Tap to view your order",
|
|
||||||
"priority": 5,
|
|
||||||
"extras": {},
|
|
||||||
"click_url": "https://gooyal.ir/orders/123"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Echoed back by a live local Gotify 2.6.3 container (sent alongside a
|
|
||||||
simulated chat-style `conversation_uuid` extra, to prove coexistence):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"title": "Order shipped",
|
|
||||||
"message": "Tap to view your order",
|
|
||||||
"extras": {
|
|
||||||
"conversation_uuid": "abc",
|
|
||||||
"client::notification": {
|
|
||||||
"click": { "url": "https://gooyal.ir/orders/123" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Migration
|
|
||||||
|
|
||||||
`apps/push_notifications/migrations/0004_pushmessage_click_url.py` — adds
|
|
||||||
`click_url` to `pushmessage`. Applied cleanly against the local Postgres
|
|
||||||
container.
|
|
||||||
|
|
||||||
### Verified
|
|
||||||
|
|
||||||
| Check | Result |
|
|
||||||
|---|---|
|
|
||||||
| Migration applied to live local Postgres | ✅ applied clean |
|
|
||||||
| `get_gotify_extras()`: click_url only | ✅ merges correctly |
|
|
||||||
| `get_gotify_extras()`: click_url + pre-existing custom extras | ✅ both keys coexist |
|
|
||||||
| `get_gotify_extras()`: no click_url set | ✅ extras left untouched |
|
|
||||||
| Live round trip against local Gotify 2.6.3 container | ✅ accepted & echoed back correctly |
|
|
||||||
| Bulk path: mocked spreadsheet rows with/without a click_url cell | ✅ parses correctly, incl. the extras_str fix |
|
|
||||||
| Serializer optionality (`required=False`, `allow_null=True`, `allow_blank=True`) | ✅ confirmed |
|
|
||||||
|
|
||||||
## 3. Producer services updated to pass `click_url` through
|
|
||||||
|
|
||||||
Each of the three services that call into this notifications service vendors
|
|
||||||
its own thin wrapper around the generated client. Added an optional
|
|
||||||
`click_url=None` parameter to each wrapper so producers can opt in without
|
|
||||||
being forced to — no existing call site was changed, so this is fully
|
|
||||||
backward compatible.
|
|
||||||
|
|
||||||
Workflow used in every repo: `git checkout master` → `git pull origin
|
|
||||||
master` → `git checkout -b feature/notification-client-update` → edit.
|
|
||||||
|
|
||||||
| Service | Branch | File changed | Function | Tests |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| **chat** | `feature/notification-client-update` | `utils/clients/notifications_client.py` | `push_user()` / `_PushBody` | 47/47 passed (`pytest`) |
|
|
||||||
| **promotions** | `feature/notification-client-update` | `utils/clients/notifications_client.py` | `notifications_push_user()` | no existing test suite for this file |
|
|
||||||
| **advertising** | `feature/notification-client-update` | `utils/clients/notifications_client.py` | `notifications_push_user()` | `apps.campaigns`/`apps.stores` suites: same failures with and without the change (pre-existing, tied to a real staging OAuth call returning `invalid_scope` — unrelated to this edit) |
|
|
||||||
|
|
||||||
None of these commits were made — changes are sitting on each repo's
|
|
||||||
`feature/notification-client-update` branch, uncommitted, pending your
|
|
||||||
review.
|
|
||||||
|
|
||||||
**Not done, and out of scope for this pass:** wiring an actual `click_url`
|
|
||||||
value into chat's message-push call site, promotions' or advertising's push
|
|
||||||
calls. Which notifications should be clickable, and where they should
|
|
||||||
navigate, is a product decision for each feature — this pass only makes the
|
|
||||||
plumbing available.
|
|
||||||
|
|
||||||
## 4. Chat service: notification implementation review
|
|
||||||
|
|
||||||
- **Location**: `apps/chat/events/publishers/push.py`, `PushPublisher.publish()`.
|
|
||||||
Pushes every recipient in a conversation (except the sender) on
|
|
||||||
`MessageSentEvent`, with `extras={"conversation_uuid": ..., "post_id": ...}`.
|
|
||||||
Per-recipient failures are caught and logged, not raised — one broken
|
|
||||||
recipient doesn't block the rest.
|
|
||||||
- **Is it in master?** Yes. Commit `0d262c6` ("Send push notifications on new
|
|
||||||
chat messages") is on `master`, and local `master` matches
|
|
||||||
`origin/master` exactly (`9f8b723f...`). Nothing related is stuck on an
|
|
||||||
unmerged feature branch — `feature/notification` is a stale, unrelated
|
|
||||||
branch (conversation-details work), not the source of this feature.
|
|
||||||
- **Does it work?** `python -m pytest apps/chat/tests/test_push_publisher.py`
|
|
||||||
→ **4/4 passed**. Full suite (`pytest`) → **47/47 passed**.
|
|
||||||
`python manage.py check` → no issues.
|
|
||||||
- **One local-only gap found**: `NOTIFICATIONS_BASE_PUBLIC_URL` is not set in
|
|
||||||
the local dev `.env` (it defaults to `None` in `main/settings.py:277`),
|
|
||||||
even though `.env.example`/`env.sample` both document it
|
|
||||||
(`https://notifications-staging.gooyal.ir`). Pushes would fail locally
|
|
||||||
until that's added — this looks like an omission in the local `.env`, not
|
|
||||||
a code issue. Left as-is since it's your local working file.
|
|
||||||
|
|
||||||
## 5. What's left, outside this repo
|
|
||||||
|
|
||||||
1. **Producer call sites don't pass `click_url` yet.** The plumbing exists
|
|
||||||
(§3); deciding which notifications should be clickable and what URL each
|
|
||||||
should carry is a per-feature product decision.
|
|
||||||
2. **The receiving client needs a tap handler.** Checked every sibling
|
|
||||||
Gooyal repo in this workspace (`reservation-front`, `winofy-backend`,
|
|
||||||
`crm_backend`, etc.) — none of them handle Gotify push display or taps,
|
|
||||||
so the actual mobile/web app isn't in this workspace. Can't confirm
|
|
||||||
whether it already has a handler for chat's `conversation_uuid`/`post_id`
|
|
||||||
keys that could be extended, or has none at all.
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
# Notifications Service — Overview
|
|
||||||
|
|
||||||
## What it is
|
|
||||||
|
|
||||||
A Django/DRF microservice in the Gooyal/Winsoo ecosystem that delivers **push
|
|
||||||
notifications** (via a self-hosted [Gotify](https://gotify.net) server) and
|
|
||||||
**email** on behalf of other backend services. It does not originate any
|
|
||||||
notifications itself — every message is submitted by a producer service
|
|
||||||
(chat, promotions, advertising, ...) over an authenticated API call.
|
|
||||||
|
|
||||||
```
|
|
||||||
chat / promotions / advertising / ...
|
|
||||||
│ OAuth2 client-credentials
|
|
||||||
▼
|
|
||||||
notifications service (this repo)
|
|
||||||
│ │
|
|
||||||
▼ ▼
|
|
||||||
Gotify server SMTP server
|
|
||||||
(push delivery) (email delivery)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tech stack
|
|
||||||
|
|
||||||
| Layer | Choice |
|
|
||||||
|---|---|
|
|
||||||
| Framework | Django 5.2 + Django REST Framework |
|
|
||||||
| Auth | `django-oauth-toolkit` — this service is an OAuth2 **resource server**, validating caller tokens by introspection against the Gooyal accounts service |
|
|
||||||
| Push backend | Gotify **2.6.3** (pinned — see local dev setup below), driven through a generated client (`gotify_rest_api_client/`) |
|
|
||||||
| Async | Celery + Celery Beat, Redis as broker/result backend |
|
|
||||||
| DB | PostgreSQL |
|
|
||||||
| Email | Django's SMTP backend |
|
|
||||||
| Bulk import | `xlrd` (legacy `.xls` only) |
|
|
||||||
| Docs | drf-spectacular (OpenAPI/Swagger at `/swagger/`) |
|
|
||||||
|
|
||||||
## Capabilities
|
|
||||||
|
|
||||||
### 1. Push notifications
|
|
||||||
- **Single push**: `POST /push/application/<user_uuid>/application/` — a producer service pushes one message to one user.
|
|
||||||
- **Bulk push**: an admin-triggered Excel upload (`BulkPushMessage`) fans out a push per row to many users at once.
|
|
||||||
- **Click actions**: an optional `click_url` on either path is delivered to the client as Gotify's own `client::notification.click.url` extra. See [notification-click-actions.md](notification-click-actions.md) and [frontend-notification-click-actions.md](frontend-notification-click-actions.md) for the full mechanism and integration contract.
|
|
||||||
|
|
||||||
### 2. Email
|
|
||||||
- **Single email**: `POST /email/application/<user_uuid>/` — sent immediately via Celery.
|
|
||||||
- **Queued digest**: emails can be attached to an `EmailQueue`; a periodic task (`send_email_for_queued_events`) batches queued emails per queue and sends a single digest, throttled to once per 10 minutes per queue.
|
|
||||||
|
|
||||||
## How a producer service integrates
|
|
||||||
|
|
||||||
Every producer vendors a generated OpenAPI client (`gooyal_notifications_client`, built from this service's own swagger spec) and wraps it in a small `utils/clients/notifications_client.py`:
|
|
||||||
|
|
||||||
1. Obtains an access token via the OAuth2 **client-credentials** grant against the Gooyal accounts service, using its own `CLIENT_ID`/`CLIENT_SECRET` (cached until near expiry).
|
|
||||||
2. Calls `push_application_application_create` (or `email_application_application_create`) against `NOTIFICATIONS_BASE_PUBLIC_URL`, scoped to the target `user_uuid`.
|
|
||||||
3. This service resolves the calling application from the token (`apps.gooyal_oauth2.utils.get_application`) and records it against the created `PushMessage`/`Email` row — the producer is never sent as free-form data, it's derived from the authenticated client.
|
|
||||||
|
|
||||||
Endpoints are scope-gated per action (`notifications.application.push:submit_message`, `notifications.application.email:submit_email`, `notifications.push:get_client_token`) via `IsAuthenticatedOrTokenMatchesOASRequirements`.
|
|
||||||
|
|
||||||
As of this writing, three services integrate: **chat** (live, sends on every new message), **promotions** (wired, currently sends empty extras), **advertising** (the only service actually using `click_url` today — see the implementation doc).
|
|
||||||
|
|
||||||
## Domain model
|
|
||||||
|
|
||||||
| Model | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `PushUser` | Per-user Gotify identity — owns a Gotify user account, a Gotify client token, and an application token. Created lazily on first push (`PushUser.objects.submit(user)`). |
|
|
||||||
| `PushMessage` | One push notification: title, message, priority, `extras` (free-form JSON), `click_url` (optional), delivery state. |
|
|
||||||
| `BulkPushMessage` | An uploaded spreadsheet of push messages plus success/failure counters and state. |
|
|
||||||
| `Email` | One email: title, message, `extras`, optional `queue`, delivery state. |
|
|
||||||
| `EmailQueue` | A named digest queue; batches `Email` rows created since it last sent. |
|
|
||||||
|
|
||||||
## Configuration (environment variables)
|
|
||||||
|
|
||||||
| Variable | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `DEBUG` | Django debug flag |
|
|
||||||
| `DB_NAME` / `DB_USER` / `DB_PASSWORD` / `DB_HOST` / `DB_PORT` | Postgres connection |
|
|
||||||
| `REDIS_BASE_URL` | Celery broker/result backend + cache |
|
|
||||||
| `GOTIFY_BASE_PUBLIC_URL` | Base URL of the Gotify server |
|
|
||||||
| `GOTIFY_ADMIN_CLIENT_TOKEN` | Admin token used to provision Gotify users/clients/applications |
|
|
||||||
| `BASE_OAUTH2_PROVIDER_PUBLIC_URL` / `BASE_OAUTH2_PROVIDER_PRIVATE_URL` | Gooyal accounts service, for issuing and introspecting tokens |
|
|
||||||
| `CLIENT_ID` / `CLIENT_SECRET` | This service's own OAuth2 introspection credentials |
|
|
||||||
| `SCOPES` | OAuth2 scopes this service requests |
|
|
||||||
| `EMAIL_HOST` / `EMAIL_PORT` / `EMAIL_HOST_USER` / `EMAIL_HOST_PASSWORD` / `EMAIL_USE_TLS` / `EMAIL_USE_SSL` / `EMAIL_TIMEOUT` | SMTP config |
|
|
||||||
|
|
||||||
## Local development
|
|
||||||
|
|
||||||
Three Docker containers back local dev (see `.env`):
|
|
||||||
|
|
||||||
| Container | Image | Host port |
|
|
||||||
|---|---|---|
|
|
||||||
| `notif_postgres` | `postgres:16` | 5433 |
|
|
||||||
| `notif_redis` | `redis:7-alpine` | 6380 |
|
|
||||||
| `notif_gotify` | `gotify/server:2.6.3` | 8888 |
|
|
||||||
|
|
||||||
**The Gotify image must stay pinned to `2.6.3`** — later versions (v3+) issue longer tokens that don't fit this service's `client_token`/`application_token` `CharField(max_length=32)` columns.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker start notif_postgres notif_redis notif_gotify
|
|
||||||
python manage.py migrate
|
|
||||||
python manage.py runserver
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
- `Dockerfile` — Debian-based image, installs `requirements.txt`.
|
|
||||||
- `run.sh` — waits for Postgres, runs migrations, serves via `gunicorn main.wsgi:application`.
|
|
||||||
- `celery.sh` — runs a combined worker + beat process (`celery -A main worker -B`).
|
|
||||||
|
|
||||||
## Known gaps
|
|
||||||
|
|
||||||
- **No automated tests** in `apps/push_notifications` or `apps/emails` (both `tests.py` are stubs).
|
|
||||||
- **`Email._send_email()` hard-codes the recipient** (`xdshia49@gmail.com`) instead of `self.user`'s real email — looks like a debugging leftover, not wired to actual user emails yet.
|
|
||||||
- **Celery Beat is registered two different ways**: `apps.emails.tasks` assigns `celery_app.conf.beat_schedule` directly at import time, separate from the more conventional `CELERY_BEAT_SCHEDULE` Django setting used elsewhere in the Gooyal codebase (e.g. `advertising`). Both work, but it's an inconsistency worth normalizing.
|
|
||||||
- **URL namespace collision**: `apps/push_notifications/application_urls.py` and `user_urls.py` both set `app_name = 'push_notifications'`, producing a `urls.W005` warning on every `manage.py check`. Harmless (URLs still resolve) but ambiguous for reverse lookups.
|
|
||||||
Loading…
Add table
Reference in a new issue