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

@@ -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)