feat: replace email flows with in-app notifications

Add recipient notification detail and filtering, a queued admin composer, and contextual automatic titles. Retire email delivery, obsolete auth routes, newsletter data, and related templates and settings.
This commit is contained in:
2026-09-26 12:23:10 +03:30
parent b706a2d10a
commit 3ec28638d1
54 changed files with 787 additions and 4843 deletions

View File

@@ -9,10 +9,10 @@ class NotificationSchema(Schema):
type: str
title: str
message: str
has_body: bool = False
level: str
created_at: datetime | str
is_seen: bool
delete_on_seen: bool
action_url: str | None = None
entity_type: str | None = None
entity_id: int | str | None = None
@@ -25,6 +25,10 @@ class NotificationListSchema(Schema):
notifications: list[NotificationSchema]
class NotificationDetailSchema(NotificationSchema):
body_markdown: str | None = None
class NotificationSeenIn(Schema):
id: str

View File

@@ -9,6 +9,7 @@ from django.views import View
from ninja import Router
from apps.notifications.api.schemas import (
NotificationDetailSchema,
NotificationDeleteResponseSchema,
NotificationListSchema,
NotificationMarkAllReadResponseSchema,
@@ -54,7 +55,9 @@ def _validate_stream_token(token: str | None) -> str:
@notifications_router.get("/", response=NotificationListSchema, auth=jwt_auth)
def list_notifications(request, limit: int | None = None, offset: int = 0, type: str | None = None):
def list_notifications(request, limit: int | None = None, offset: int = 0, type: str | None = None, status: str = "all"):
if status not in {"all", "unread", "read"}:
return JsonResponse({"error": "Invalid status filter."}, status=400)
user_id = str(request.auth.id)
safe_limit = min(_safe_int(limit, settings.NOTIFICATION_DEFAULT_PAGE_SIZE), settings.NOTIFICATION_MAX_PAGE_SIZE)
notifications, total_count = RedisNotificationStore.list(
@@ -62,11 +65,12 @@ def list_notifications(request, limit: int | None = None, offset: int = 0, type:
limit=safe_limit,
offset=_safe_int(offset, 0),
type_filter=type,
read_filter=status,
)
return {
"count": total_count,
"unread_count": RedisNotificationStore.unread_count(user_id),
"notifications": notifications,
"notifications": [RedisNotificationStore.summary(item) for item in notifications],
}
@@ -78,18 +82,6 @@ def mark_notification_seen(request, data: NotificationSeenIn):
return 200, {"marked_read": True, **payload}
@notifications_router.delete("/{notif_id}", response={200: NotificationDeleteResponseSchema, 404: ErrorSchema}, auth=jwt_auth)
def delete_notification(request, notif_id: str):
deleted = RedisNotificationStore.delete(str(request.auth.id), notif_id)
if not deleted:
return 404, {"error": "Notification not found."}
return 200, {
"deleted": True,
"notification_id": notif_id,
"unread_count": RedisNotificationStore.unread_count(str(request.auth.id)),
}
@notifications_router.post("/mark-all-read", response=NotificationMarkAllReadResponseSchema, auth=jwt_auth)
def mark_all_notifications_read(request, type: str | None = None):
updated = RedisNotificationStore.mark_all_seen(str(request.auth.id), type_filter=type)
@@ -106,6 +98,26 @@ def get_notification_stream_token(request):
}
@notifications_router.get("/{notif_id}", response={200: NotificationDetailSchema, 404: ErrorSchema}, auth=jwt_auth)
def get_notification(request, notif_id: str):
notification = RedisNotificationStore.get(str(request.auth.id), notif_id)
if notification is None:
return 404, {"error": "Notification not found."}
return 200, {**notification, "has_body": bool(notification.get("body_markdown"))}
@notifications_router.delete("/{notif_id}", response={200: NotificationDeleteResponseSchema, 404: ErrorSchema}, auth=jwt_auth)
def delete_notification(request, notif_id: str):
deleted = RedisNotificationStore.delete(str(request.auth.id), notif_id)
if not deleted:
return 404, {"error": "Notification not found."}
return 200, {
"deleted": True,
"notification_id": notif_id,
"unread_count": RedisNotificationStore.unread_count(str(request.auth.id)),
}
class NotificationStreamView(View):
def _build_stream(self, user_id: str) -> Iterator[str]:
pubsub = RedisNotificationStore.get_pubsub()
@@ -118,7 +130,7 @@ class NotificationStreamView(View):
)
unread_count = RedisNotificationStore.unread_count(user_id)
yield f"retry: {settings.NOTIFICATION_SSE_RETRY_MS}\n\n"
yield _format_sse_event("connected", {"notifications": initial_notifications, "unread_count": unread_count})
yield _format_sse_event("connected", {"notifications": [RedisNotificationStore.summary(item) for item in initial_notifications], "unread_count": unread_count})
yield _format_sse_event("unread_count", {"unread_count": unread_count})
pubsub.subscribe(channel)

View File

@@ -50,16 +50,22 @@ class RedisNotificationStore:
"type": payload.get("type") or "notification",
"title": payload.get("title") or "",
"message": payload.get("message") or "",
"body_markdown": payload.get("body_markdown") or None,
"level": payload.get("level") or "info",
"created_at": _isoformat_datetime(payload.get("created_at")),
"is_seen": bool(payload.get("is_seen", False)),
"delete_on_seen": bool(payload.get("delete_on_seen", False)),
"action_url": payload.get("action_url"),
"entity_type": payload.get("entity_type"),
"entity_id": payload.get("entity_id"),
"meta": payload.get("meta") or {},
}
@staticmethod
def summary(data: dict) -> dict:
result = {**data, "has_body": bool(data.get("body_markdown"))}
result.pop("body_markdown", None)
return result
@classmethod
def _publish_event(cls, user_id: str, event: str, data: dict) -> None:
if not settings.NOTIFICATIONS_ENABLED:
@@ -96,7 +102,7 @@ class RedisNotificationStore:
pipe.execute()
unread_count = cls.unread_count(user_id)
cls._publish_event(user_id, "notification", {"notification": data, "unread_count": unread_count})
cls._publish_event(user_id, "notification", {"notification": cls.summary(data), "unread_count": unread_count})
cls._publish_event(user_id, "unread_count", {"unread_count": unread_count})
return data
@@ -108,6 +114,7 @@ class RedisNotificationStore:
limit: int | None = None,
offset: int = 0,
type_filter: str | None = None,
read_filter: str = "all",
paginate: bool = True,
) -> tuple[list[dict], int]:
ids_key = cls._ids_key(user_id)
@@ -134,6 +141,10 @@ class RedisNotificationStore:
continue
if type_filter and data.get("type") != type_filter:
continue
if read_filter == "unread" and data["is_seen"]:
continue
if read_filter == "read" and not data["is_seen"]:
continue
items.append(data)
if cleanup_ids:
@@ -182,17 +193,6 @@ class RedisNotificationStore:
data = cls.get(user_id, notif_id)
if not data:
return None
if data.get("delete_on_seen"):
deleted = cls.delete(user_id, notif_id)
if deleted:
return {
"notification_id": notif_id,
"deleted": True,
"notification": None,
"unread_count": cls.unread_count(user_id),
}
return None
if not data.get("is_seen"):
data["is_seen"] = True
redis_client.hset(
@@ -204,7 +204,7 @@ class RedisNotificationStore:
payload = {
"notification_id": notif_id,
"deleted": False,
"notification": data,
"notification": cls.summary(data),
"unread_count": unread_count,
}
cls._publish_event(user_id, "notification_seen", payload)
@@ -228,16 +228,12 @@ class RedisNotificationStore:
continue
if type_filter and data.get("type") != type_filter:
continue
if data.get("delete_on_seen"):
pipe.zrem(cls._ids_key(user_id), notif_id)
pipe.hdel(cls._data_key(user_id), notif_id)
else:
data["is_seen"] = True
pipe.hset(
cls._data_key(user_id),
notif_id,
json.dumps(data, ensure_ascii=False, default=str),
)
data["is_seen"] = True
pipe.hset(
cls._data_key(user_id),
notif_id,
json.dumps(data, ensure_ascii=False, default=str),
)
updated += 1
if updated:
pipe.execute()

View File

@@ -1,8 +1,9 @@
import logging
from celery import shared_task
from django.contrib.auth import get_user_model
from apps.notifications.services import RedisNotificationStore
from apps.notifications.services import RedisNotificationStore, notify_user
logger = logging.getLogger(__name__)
@@ -12,3 +13,19 @@ def cleanup_notification_retention():
removed = RedisNotificationStore.cleanup_expired()
logger.info("Cleaned up %s expired notifications", removed)
return removed
@shared_task
def send_manual_notification(user_ids: list[int], title: str, digest: str, body_markdown: str | None = None):
users = get_user_model().objects.filter(pk__in=user_ids, is_active=True, is_deleted=False)
sent = 0
for user_id in users.values_list("pk", flat=True).iterator():
notify_user(user_id, {
"type": "manual",
"title": title,
"message": digest,
"body_markdown": body_markdown,
"level": "info",
})
sent += 1
return sent

View File

View File

@@ -0,0 +1,170 @@
import json
from types import SimpleNamespace
from unittest import mock
from django.contrib import admin
from django.test import RequestFactory, SimpleTestCase, TestCase
from apps.notifications.api.views import get_notification, list_notifications
from apps.notifications.services import RedisNotificationStore
from apps.notifications.tasks import send_manual_notification
from apps.users.admin import ManualNotificationForm, UserAdmin
from apps.users.models import User
from core.authentication import create_jwt_token
class NotificationStoreTests(SimpleTestCase):
def test_summary_hides_full_body_and_advertises_it(self):
notification = RedisNotificationStore._normalize_notification({
"id": "one", "title": "Title", "message": "Digest", "body_markdown": "# Full body",
})
summary = RedisNotificationStore.summary(notification)
self.assertTrue(summary["has_body"])
self.assertNotIn("body_markdown", summary)
self.assertEqual(notification["body_markdown"], "# Full body")
@mock.patch("apps.notifications.services.redis_client")
def test_read_filter_applies_before_pagination(self, redis_client):
redis_client.zrevrange.return_value = ["one", "two", "three"]
redis_client.pipeline.return_value.execute.return_value = [
json.dumps({"id": "one", "is_seen": False}),
json.dumps({"id": "two", "is_seen": True}),
json.dumps({"id": "three", "is_seen": False}),
]
unread, count = RedisNotificationStore.list("user", limit=1, offset=1, read_filter="unread")
self.assertEqual(count, 2)
self.assertEqual([item["id"] for item in unread], ["three"])
read, count = RedisNotificationStore.list("user", read_filter="read")
self.assertEqual(count, 1)
self.assertEqual(read[0]["id"], "two")
@mock.patch("apps.notifications.api.views.RedisNotificationStore")
def test_detail_is_scoped_to_authenticated_recipient(self, store):
store.get.side_effect = lambda user_id, _: {"id": "one", "body_markdown": "# Secret"} if user_id == "1" else None
owner = SimpleNamespace(auth=SimpleNamespace(id=1))
other = SimpleNamespace(auth=SimpleNamespace(id=2))
self.assertEqual(get_notification(owner, "one")[0], 200)
self.assertEqual(get_notification(other, "one")[0], 404)
store.get.assert_any_call("2", "one")
@mock.patch("apps.notifications.api.views.RedisNotificationStore")
def test_list_returns_summaries_and_rejects_unknown_status(self, store):
request = SimpleNamespace(auth=SimpleNamespace(id=1))
store.list.return_value = ([{"id": "one", "body_markdown": "# Secret"}], 1)
store.summary.side_effect = RedisNotificationStore.summary
store.unread_count.return_value = 1
result = list_notifications(request, status="unread")
self.assertNotIn("body_markdown", result["notifications"][0])
self.assertEqual(store.list.call_args.kwargs["read_filter"], "unread")
self.assertEqual(list_notifications(request, status="invalid").status_code, 400)
@mock.patch.object(RedisNotificationStore, "_publish_event")
@mock.patch.object(RedisNotificationStore, "unread_count", return_value=0)
@mock.patch.object(RedisNotificationStore, "get")
@mock.patch("apps.notifications.services.redis_client")
def test_marking_old_ephemeral_notification_read_keeps_it_visible(self, redis_client, get, _count, _publish):
get.return_value = {"id": "one", "is_seen": False, "delete_on_seen": True, "body_markdown": "# Body"}
result = RedisNotificationStore.mark_seen("user", "one")
self.assertFalse(result["deleted"])
self.assertTrue(result["notification"]["is_seen"])
redis_client.hset.assert_called_once()
redis_client.pipeline.assert_not_called()
class ManualNotificationTests(TestCase):
def test_form_requires_title_and_plain_digest_but_allows_optional_body(self):
self.assertTrue(ManualNotificationForm({"title": "Title", "digest": "Intro"}).is_valid())
self.assertFalse(ManualNotificationForm({"title": "", "digest": "Intro"}).is_valid())
@mock.patch("apps.notifications.tasks.notify_user")
def test_task_sends_only_to_selected_active_users(self, notify_user):
active = User.objects.create_user(username="active", mobile="09190000001", password="password")
inactive = User.objects.create_user(username="inactive", mobile="09190000002", password="password", is_active=False)
sent = send_manual_notification.run([active.pk, inactive.pk], "Title", "Digest", "# Body")
self.assertEqual(sent, 1)
notify_user.assert_called_once_with(active.pk, {
"type": "manual", "title": "Title", "message": "Digest", "body_markdown": "# Body", "level": "info",
})
@mock.patch("apps.users.admin.send_manual_notification.delay")
def test_admin_action_queues_valid_composer_for_selected_users(self, delay):
staff = User.objects.create_superuser(username="staff", mobile="09190000003", password="password")
recipient = User.objects.create_user(username="recipient", mobile="09190000004", password="password")
request = RequestFactory().post("/admin/users/user/", {
"confirm_send": "1", "title": "Title", "digest": "Digest", "body_markdown": "**Body**",
})
request.user = staff
model_admin = UserAdmin(User, admin.site)
with mock.patch.object(model_admin, "message_user"):
model_admin.send_notification(request, User.objects.filter(pk=recipient.pk))
delay.assert_called_once_with([recipient.pk], "Title", "Digest", "**Body**")
def test_account_email_identity_is_true_when_an_email_is_supplied(self):
user = User.objects.create_user(username="emailuser", mobile="09190000005", password="password")
self.assertFalse(user.is_email_verified)
user.email = "person@example.com"
user.save(update_fields=["email"])
user.refresh_from_db()
self.assertTrue(user.is_email_verified)
@mock.patch("apps.users.admin.send_manual_notification.delay")
def test_admin_changelist_uses_the_composer_before_queuing(self, delay):
staff = User.objects.create_superuser(username="adminstaff", mobile="09190000008", password="password")
recipient = User.objects.create_user(username="target", mobile="09190000009", password="password")
self.client.force_login(staff)
action_data = {"action": "send_notification", "_selected_action": str(recipient.pk), "index": "0"}
path = "/admin/users/user/"
preview = self.client.post(path, action_data)
self.assertEqual(preview.status_code, 200)
self.assertContains(preview, "توضیح کوتاه")
self.assertFalse(delay.called)
result = self.client.post(path, {
**action_data, "confirm_send": "1", "title": "Title", "digest": "Digest", "body_markdown": "# Body",
})
self.assertIn(result.status_code, (200, 302))
delay.assert_called_once_with([recipient.pk], "Title", "Digest", "# Body")
class NotificationAPIIntegrationTests(TestCase):
@mock.patch("apps.notifications.api.views.RedisNotificationStore.get")
def test_detail_requires_auth_and_returns_body_only_to_recipient(self, get):
owner = User.objects.create_user(username="owner", mobile="09190000006", password="password")
other = User.objects.create_user(username="other", mobile="09190000007", password="password")
detail = RedisNotificationStore._normalize_notification({
"id": "one", "title": "Title", "message": "Digest", "body_markdown": "# Private",
"action_url": "/events/workshop",
})
get.side_effect = lambda user_id, _: detail if user_id == str(owner.pk) else None
path = "/api/notifications/one"
self.assertEqual(self.client.get(path).status_code, 401)
owner_response = self.client.get(path, HTTP_AUTHORIZATION=f"Bearer {create_jwt_token(owner)}")
self.assertEqual(owner_response.status_code, 200)
self.assertEqual(owner_response.json()["body_markdown"], "# Private")
self.assertEqual(owner_response.json()["action_url"], "/events/workshop")
other_response = self.client.get(path, HTTP_AUTHORIZATION=f"Bearer {create_jwt_token(other)}")
self.assertEqual(other_response.status_code, 404)
@mock.patch("apps.notifications.api.views.RedisNotificationStore.unread_count", return_value=1)
@mock.patch("apps.notifications.api.views.RedisNotificationStore.list")
def test_list_excludes_full_body_and_uses_read_filter(self, list_notifications_store, _count):
user = User.objects.create_user(username="reader", mobile="09190000010", password="password")
full = RedisNotificationStore._normalize_notification({
"id": "one", "title": "Title", "message": "Digest", "body_markdown": "# Private",
})
list_notifications_store.return_value = ([full], 1)
response = self.client.get("/api/notifications/?status=unread", HTTP_AUTHORIZATION=f"Bearer {create_jwt_token(user)}")
self.assertEqual(response.status_code, 200)
item = response.json()["notifications"][0]
self.assertTrue(item["has_body"])
self.assertNotIn("body_markdown", item)
self.assertEqual(list_notifications_store.call_args.kwargs["read_filter"], "unread")
@mock.patch("apps.notifications.api.views.RedisNotificationStore.unread_count", return_value=0)
@mock.patch("apps.notifications.api.views.RedisNotificationStore.delete")
def test_delete_only_removes_the_authenticated_users_notification(self, delete, _count):
owner = User.objects.create_user(username="delete_owner", mobile="09190000011", password="password")
other = User.objects.create_user(username="delete_other", mobile="09190000012", password="password")
delete.side_effect = lambda user_id, _: user_id == str(owner.pk)
path = "/api/notifications/one"
self.assertEqual(self.client.delete(path, HTTP_AUTHORIZATION=f"Bearer {create_jwt_token(other)}").status_code, 404)
self.assertEqual(self.client.delete(path, HTTP_AUTHORIZATION=f"Bearer {create_jwt_token(owner)}").status_code, 200)