Merge branch 'develop'
This commit is contained in:
commit
962fa13b6e
@ -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):
|
||||
|
||||
6
backend/communications/services/__init__.py
Normal file
6
backend/communications/services/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
from .chat_notifications import ChatNotificationReadResult, ChatNotificationReadService
|
||||
|
||||
__all__ = [
|
||||
'ChatNotificationReadResult',
|
||||
'ChatNotificationReadService',
|
||||
]
|
||||
60
backend/communications/services/chat_notifications.py
Normal file
60
backend/communications/services/chat_notifications.py
Normal 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,
|
||||
)
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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):
|
||||
"""Попытка открыть несуществующий чат возвращает ошибку."""
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user