Merge pull request 'feature/notification-actions' (#1) from feature/notification-actions into master
Reviewed-on: #1
This commit is contained in:
commit
48a764caa7
11 changed files with 784 additions and 5 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -2,3 +2,7 @@
|
|||
.env
|
||||
media
|
||||
/clients/
|
||||
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# Generated by Django 5.2.6 on 2026-08-22 10:59
|
||||
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('push_notifications', '0004_pushmessage_click_url'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='NotificationLink',
|
||||
fields=[
|
||||
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True, db_index=True)),
|
||||
('code', models.SlugField(max_length=100, unique=True)),
|
||||
('description', models.CharField(blank=True, max_length=255)),
|
||||
('url_template', models.CharField(help_text='Use {object_id} as a placeholder, e.g. https://app.gooyal.ir/ads/{object_id}', max_length=1000)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='pushmessage',
|
||||
name='click_object_id',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='pushmessage',
|
||||
name='code',
|
||||
field=models.SlugField(blank=True, max_length=100, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -44,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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
134
docs/frontend-notification-click-actions.md
Normal file
134
docs/frontend-notification-click-actions.md
Normal 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
149
docs/implementation.md
Normal 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.
|
||||
244
docs/notification-click-actions.md
Normal file
244
docs/notification-click-actions.md
Normal 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
111
docs/service-overview.md
Normal 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.
|
||||
Loading…
Add table
Reference in a new issue