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:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
0
apps/notifications/tests/__init__.py
Normal file
0
apps/notifications/tests/__init__.py
Normal file
170
apps/notifications/tests/test_notifications.py
Normal file
170
apps/notifications/tests/test_notifications.py
Normal 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)
|
||||
Reference in New Issue
Block a user