From 0a71d6a3c903dca14b0c8293efefbba6b8c2451b Mon Sep 17 00:00:00 2001 From: lizerk Date: Thu, 2 Apr 2026 11:26:09 +0300 Subject: [PATCH] Read notifications for opened chat --- backend/communications/consumers.py | 8 +++ backend/communications/services/__init__.py | 6 ++ .../services/chat_notifications.py | 60 ++++++++++++++++ backend/realtime/consumers.py | 25 ++++++- backend/tests/realtime/test_user_consumer.py | 50 +++++++++++++ .../tests/realtime/test_websocket_events.py | 71 +++++++++++++++++++ frontend/src/components/AppShell.tsx | 2 +- 7 files changed, 219 insertions(+), 3 deletions(-) create mode 100644 backend/communications/services/__init__.py create mode 100644 backend/communications/services/chat_notifications.py diff --git a/backend/communications/consumers.py b/backend/communications/consumers.py index 66358733..7b2a1ba2 100644 --- a/backend/communications/consumers.py +++ b/backend/communications/consumers.py @@ -106,6 +106,14 @@ class ChatConsumerMixin: await self.send_json({"type": "chat_opened", "chat_id": chat_id}) + chat_notification_read_service = getattr( + self, + 'chat_notification_read_service', + None, + ) + if chat_notification_read_service is not None: + await self._mark_chat_notifications_as_read(chat_id) + logger.info(f"[ChatMixin] User {self.user.id} opened chat {chat_id}") async def _handle_close_chat(self, content): diff --git a/backend/communications/services/__init__.py b/backend/communications/services/__init__.py new file mode 100644 index 00000000..37172d59 --- /dev/null +++ b/backend/communications/services/__init__.py @@ -0,0 +1,6 @@ +from .chat_notifications import ChatNotificationReadResult, ChatNotificationReadService + +__all__ = [ + 'ChatNotificationReadResult', + 'ChatNotificationReadService', +] diff --git a/backend/communications/services/chat_notifications.py b/backend/communications/services/chat_notifications.py new file mode 100644 index 00000000..4798c3e0 --- /dev/null +++ b/backend/communications/services/chat_notifications.py @@ -0,0 +1,60 @@ +from dataclasses import dataclass + +from django.db import transaction +from django.utils import timezone + +from notifications.models import Notification +from notifications.realtime import send_notifications_read_all_event + +from communications.notifications.config import NotificationVerbs + + +@dataclass(frozen=True) +class ChatNotificationReadResult: + notification_ids: list[int] + count: int + + +class ChatNotificationReadService: + """Use case for reading notifications related to a specific chat.""" + + chat_related_verbs = ( + NotificationVerbs.NEW_MESSAGE, + NotificationVerbs.ANNOUNCEMENT, + NotificationVerbs.MENTION, + NotificationVerbs.REPLY, + NotificationVerbs.ADDED_TO_CHAT, + ) + + def mark_chat_notifications_as_read( + self, + *, + user, + chat_id: int, + ) -> ChatNotificationReadResult: + queryset = self._get_unread_queryset(user_id=user.id, chat_id=chat_id) + notification_ids = list(queryset.values_list('id', flat=True)) + + if not notification_ids: + return ChatNotificationReadResult(notification_ids=[], count=0) + + read_at = timezone.now() + count = queryset.update(unread=False, timestamp_read=read_at) + + transaction.on_commit( + lambda: send_notifications_read_all_event(user.id, notification_ids) + ) + + return ChatNotificationReadResult( + notification_ids=notification_ids, + count=count, + ) + + def _get_unread_queryset(self, *, user_id: int, chat_id: int): + return Notification.objects.filter( + recipient_id=user_id, + unread=True, + deleted=False, + verb__in=self.chat_related_verbs, + data__chat_id=chat_id, + ) diff --git a/backend/realtime/consumers.py b/backend/realtime/consumers.py index 0f545cc4..54affc03 100644 --- a/backend/realtime/consumers.py +++ b/backend/realtime/consumers.py @@ -14,11 +14,13 @@ WebSocket Consumer для пользователя. import asyncio import logging +from channels.db import database_sync_to_async from channels.generic.websocket import AsyncJsonWebsocketConsumer from django.contrib.auth.models import AnonymousUser from django.utils import timezone from communications.consumers import ChatConsumerMixin +from communications.services import ChatNotificationReadService logger = logging.getLogger(__name__) @@ -42,6 +44,16 @@ class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer): self.active_chat_id = None # ID открытого чата self.subscribed_chats = set() # Чаты, на которые подписан self.user = None + self.chat_notification_read_service = ChatNotificationReadService() + + @database_sync_to_async + def _mark_chat_notifications_as_read(self, chat_id): + return ( + self.chat_notification_read_service.mark_chat_notifications_as_read( + user=self.user, + chat_id=chat_id, + ) + ) async def connect(self): """Подключение пользователя - подписка на все его чаты и каналы""" @@ -220,8 +232,16 @@ class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer): notification = event.get("message", {}) if self._should_suppress_active_chat_notification(notification): + notification_chat_id = self._extract_notification_chat_id( + notification + ) + if notification_chat_id is not None: + await self._mark_chat_notifications_as_read( + notification_chat_id + ) logger.info( - "[UserWS] Suppressed notification for active chat %s in session %s", + "[UserWS] Suppressed notification for active chat %s " + "in session %s", self.active_chat_id, self.channel_name, ) @@ -237,7 +257,8 @@ class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer): if self._should_suppress_active_chat_notification(notification): logger.info( - "[UserWS] Suppressed notification_new for active chat %s in session %s", + "[UserWS] Suppressed notification_new for active chat %s " + "in session %s", self.active_chat_id, self.channel_name, ) diff --git a/backend/tests/realtime/test_user_consumer.py b/backend/tests/realtime/test_user_consumer.py index 637d1457..a98cb97e 100644 --- a/backend/tests/realtime/test_user_consumer.py +++ b/backend/tests/realtime/test_user_consumer.py @@ -15,6 +15,7 @@ from channels.db import database_sync_to_async from django.contrib.auth.models import AnonymousUser from communications.models import Chat, ChatMembership +from notifications.models import Notification pytestmark = pytest.mark.django_db(transaction=True) @@ -100,6 +101,55 @@ class TestUserConsumerChatManagement: assert response["chat_id"] == test_chat.id await communicator.disconnect() + + async def test_open_chat_marks_related_notifications_as_read(self, ws_communicator, user, test_chat): + """Открытие конкретного чата читает только связанные с ним уведомления.""" + related_notification = await database_sync_to_async(Notification.objects.create)( + recipient=user, + verb='chat_new_message', + description='Chat notification', + action_url=f'/messages/{test_chat.id}', + data={'chat_id': test_chat.id, 'message_id': 10}, + ) + unrelated_notification = await database_sync_to_async(Notification.objects.create)( + recipient=user, + verb='chat_new_message', + description='Other chat notification', + action_url='/messages/999', + data={'chat_id': 999, 'message_id': 11}, + ) + + communicator = await ws_communicator(user=user) + + connected, _ = await communicator.connect() + assert connected + + await communicator.send_json_to({ + 'action': 'open_chat', + 'chat_id': test_chat.id, + 'load_history': False, + }) + + response = await communicator.receive_json_from(timeout=5) + assert response['type'] == 'chat_opened' + assert response['chat_id'] == test_chat.id + + read_all_event = await communicator.receive_json_from(timeout=5) + assert read_all_event['type'] == 'notifications_read_all' + assert related_notification.id in read_all_event['notification_ids'] + assert unrelated_notification.id not in read_all_event['notification_ids'] + + count_event = await communicator.receive_json_from(timeout=5) + assert count_event['type'] == 'unread_count' + assert count_event['count'] == 1 + + related_notification = await database_sync_to_async(Notification.objects.get)(id=related_notification.id) + unrelated_notification = await database_sync_to_async(Notification.objects.get)(id=unrelated_notification.id) + + assert related_notification.unread is False + assert unrelated_notification.unread is True + + await communicator.disconnect() async def test_open_nonexistent_chat(self, ws_communicator, user): """Попытка открыть несуществующий чат возвращает ошибку.""" diff --git a/backend/tests/realtime/test_websocket_events.py b/backend/tests/realtime/test_websocket_events.py index c4deb3f0..b4c30a49 100644 --- a/backend/tests/realtime/test_websocket_events.py +++ b/backend/tests/realtime/test_websocket_events.py @@ -15,6 +15,9 @@ """ import pytest from channels.layers import get_channel_layer +from channels.db import database_sync_to_async + +from notifications.models import Notification pytestmark = pytest.mark.django_db(transaction=True) @@ -624,6 +627,74 @@ class TestNotificationStateEvents: except asyncio.CancelledError: pass + @pytest.mark.asyncio + async def test_suppressed_active_chat_notification_is_marked_read( + self, + ws_communicator, + user, + test_chat, + ): + """Подавленное уведомление активного чата должно сразу считаться прочитанным.""" + comm = await ws_communicator(user=user) + + connected, _ = await comm.connect() + assert connected + + await comm.send_json_to( + { + 'action': 'open_chat', + 'chat_id': test_chat.id, + 'load_history': False, + } + ) + + response = await comm.receive_json_from(timeout=5) + assert response['type'] == 'chat_opened' + + notification = await database_sync_to_async(Notification.objects.create)( + recipient=user, + verb='chat_new_message', + description='Suppressed notification', + action_url=f'/messages/{test_chat.id}', + data={'chat_id': test_chat.id, 'message_id': 777}, + ) + + channel_layer = get_channel_layer() + payload = { + 'type': 'notification', + 'id': notification.id, + 'verb': 'chat_new_message', + 'description': 'Suppressed notification', + 'action_url': f'/messages/{test_chat.id}', + 'timestamp': '2026-04-02T12:00:00Z', + 'unread': True, + 'data': { + 'chat_id': test_chat.id, + 'message_id': 777, + }, + } + + await channel_layer.group_send( + f'user_{user.id}', + { + 'type': 'notification_message', + 'message': payload, + } + ) + + read_all_event = await comm.receive_json_from(timeout=5) + assert read_all_event['type'] == 'notifications_read_all' + assert notification.id in read_all_event['notification_ids'] + + count_event = await comm.receive_json_from(timeout=5) + assert count_event['type'] == 'unread_count' + assert count_event['count'] == 0 + + notification = await database_sync_to_async(Notification.objects.get)(id=notification.id) + assert notification.unread is False + + await comm.disconnect() + @pytest.mark.asyncio async def test_notification_read_sync_between_sessions(self, ws_communicator, user): comm1 = await ws_communicator(user=user) diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 067ef3b5..fd8530b8 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -272,7 +272,7 @@ function LeftNavContent({ onNavigate }: LeftNavContentProps) { const handleNavClick = async (category?: string) => { // Помечаем уведомления категории как прочитанные - if (category && categoryCounts[category] > 0) { + if (category && category !== "Сообщения" && categoryCounts[category] > 0) { await markCategoryAsRead(category); }