Read notifications for opened chat

This commit is contained in:
lizerk 2026-04-02 11:26:09 +03:00
parent 9989c98af4
commit 0a71d6a3c9
7 changed files with 219 additions and 3 deletions

View File

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

View File

@ -0,0 +1,6 @@
from .chat_notifications import ChatNotificationReadResult, ChatNotificationReadService
__all__ = [
'ChatNotificationReadResult',
'ChatNotificationReadService',
]

View File

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

View File

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

View File

@ -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):
"""Попытка открыть несуществующий чат возвращает ошибку."""

View File

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

View File

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