Merge pull request 'feature/notification-actions' (#1) from feature/notification-actions into master

Reviewed-on: #1
This commit is contained in:
Ghasemi 2026-08-23 03:42:38 -04:00
commit 48a764caa7
11 changed files with 784 additions and 5 deletions

4
.gitignore vendored
View file

@ -2,3 +2,7 @@
.env
media
/clients/
__pycache__/
*.py[cod]
*$py.class

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,18 @@
# 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),
),
]

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,7 +44,36 @@ 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:
# https://gotify.net/docs/pushmsg#extras
GOTIFY_CLICK_EXTRA_KEY = "client::notification"
class StateChoices(models.IntegerChoices):
INIT = 0, _('init')
START = 1, _('Start')
@ -59,10 +88,42 @@ class PushMessage(BaseModel):
message = models.TextField()
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 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):
from apps.push_notifications.tasks import send_push_notification
send_push_notification.delay(str(self.uuid))
@ -98,7 +159,10 @@ class BulkPushMessage(BaseModel):
title=data[user_uuid].get('title'),
message=data[user_uuid].get('message'),
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()
self.success_count += 1
@ -125,14 +189,20 @@ class BulkPushMessage(BaseModel):
priority = int(float(str(sh.cell(rowx=row, colx=3).value).strip()))
extras_str = sh.cell(rowx=row, colx=4).value.strip()
if extras_str:
extras = json.loads(extras)
extras = json.loads(extras_str)
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,
"message" : message,
"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

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

View file

@ -10,7 +10,7 @@ def send_push_notification(push_message_uuid):
title=push_message.title,
message=push_message.message,
priority=push_message.priority,
extras=push_message.extras)
extras=push_message.get_gotify_extras())
push_message.state = push_message.StateChoices.DONE
push_message.save()

View file

@ -0,0 +1,134 @@
# 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.

149
docs/implementation.md Normal file
View file

@ -0,0 +1,149 @@
# 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.

View file

@ -0,0 +1,244 @@
# 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.

111
docs/service-overview.md Normal file
View file

@ -0,0 +1,111 @@
# 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.