Corp/backend/realtime/consumers.py

339 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# realtime/consumers.py
"""
WebSocket Consumer для пользователя.
Универсальное WebSocket соединение для всех real-time операций:
- Чаты и сообщения (через ChatConsumerMixin)
- Уведомления
- Обновления бейджей
- Онлайн-статус
- Закупки и другие real-time события
История:
- 11 марта 2026: Извлечен ChatConsumerMixin в communications.consumers
"""
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__)
class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer):
"""
WebSocket Consumer для пользователя.
Управляет всеми real-time обновлениями:
- Чаты: обновления списка (бейдж, карточки), сообщения в активном чате
- Реакции, редактирование, удаление сообщений
- Индикатор "печатает..."
- Уведомления
- Бейджи в sidebar
- Онлайн-статус
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.ping_task = None
self.ping_interval = 20 # Ping каждые 20 секунд
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):
"""Подключение пользователя - подписка на все его чаты и каналы"""
self.user = self.scope.get("user")
if (
not self.user
or isinstance(self.user, AnonymousUser)
or not self.user.is_authenticated
):
await self.close(code=4401)
return
# Получаем все чаты пользователя
chat_ids = await self._get_available_chat_ids(self.user)
# Подписываемся на группы всех чатов
for chat_id in chat_ids:
group_name = f"chat_{chat_id}"
await self.channel_layer.group_add(group_name, self.channel_name)
self.subscribed_chats.add(chat_id)
# Подписываемся на личный канал пользователя (для уведомлений)
user_channel = f"user_{self.user.id}"
await self.channel_layer.group_add(user_channel, self.channel_name)
# Подписываемся на канал уведомлений
notifications_channel = f"notifications_{self.user.id}"
await self.channel_layer.group_add(
notifications_channel, self.channel_name
)
await self.accept()
# Запускаем ping цикл для keepalive
self.ping_task = asyncio.create_task(self._ping_loop())
print(
f"[UserWS] User {self.user.id} connected, subscribed to {
len(self.subscribed_chats)
} chats"
)
async def disconnect(self, code):
"""Отключение - отписка от всех групп"""
# Останавливаем ping цикл
if self.ping_task:
self.ping_task.cancel()
try:
await self.ping_task
except asyncio.CancelledError:
pass
# Сбрасываем статус "набирает текст" если был активный чат
if self.active_chat_id and self.user:
chat = await self._get_chat(self.active_chat_id)
if chat:
await self._set_typing_status(chat, self.user, False)
# Отписываемся от всех групп чатов
for chat_id in self.subscribed_chats:
group_name = f"chat_{chat_id}"
await self.channel_layer.group_discard(
group_name, self.channel_name
)
# Отписываемся от личных каналов
if self.user:
user_channel = f"user_{self.user.id}"
await self.channel_layer.group_discard(
user_channel, self.channel_name
)
notifications_channel = f"notifications_{self.user.id}"
await self.channel_layer.group_discard(
notifications_channel, self.channel_name
)
print(
f"[UserWS] User {
self.user.id if self.user else 'unknown'
} disconnected"
)
async def _ping_loop(self):
"""Отправка ping каждые N секунд для keepalive"""
try:
while True:
await asyncio.sleep(self.ping_interval)
await self.send_json(
{"type": "ping", "timestamp": timezone.now().isoformat()}
)
except asyncio.CancelledError:
pass
except Exception as e:
print(f"[UserWS] Ping loop error: {e}")
async def receive_json(self, content, **kwargs):
"""Обработка входящих сообщений от клиента"""
if not isinstance(content, dict):
return
action = content.get("action", "")
print(
f"[UserWS] receive_json: action={action}, user={
self.user.id
}, active_chat={self.active_chat_id}"
)
# Управление активным чатом
if action == "open_chat":
await self._handle_open_chat(content)
elif action == "close_chat":
await self._handle_close_chat(content)
# Работа с сообщениями (только для активного чата)
elif action == "send_message":
await self._handle_send_message(content)
elif action == "edit_message":
await self._handle_edit_message(content)
elif action == "delete_message":
await self._handle_delete_message(content)
# Реакции
elif action == "add_reaction":
await self._handle_add_reaction(content)
elif action == "remove_reaction":
await self._handle_remove_reaction(content)
# Индикатор печати
elif action == "typing":
await self._handle_typing(content)
elif action == "stop_typing":
await self._handle_stop_typing()
# Отметка прочитанного
elif action == "mark_read":
await self._handle_mark_read(content)
# Голосования
elif action == "vote_poll":
await self._handle_vote_poll(content)
@staticmethod
def _extract_notification_chat_id(notification):
"""Достаёт chat_id из payload уведомления, если он есть."""
if not isinstance(notification, dict):
return None
data = notification.get("data")
if not isinstance(data, dict):
return None
chat_id = data.get("chat_id")
try:
return int(chat_id)
except (TypeError, ValueError):
return None
def _should_suppress_active_chat_notification(self, notification):
"""Не показываем уведомление о сообщении в уже открытом чате."""
if self.active_chat_id is None:
return False
notification_chat_id = self._extract_notification_chat_id(notification)
return notification_chat_id == self.active_chat_id
# ==================== Обработчики уведомлений ====================
async def notification_message(self, event):
"""
Новое уведомление от WebSocketNotificationSender.
Вызывается из notifications/senders/websocket.py
"""
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",
self.active_chat_id,
self.channel_name,
)
return
await self.send_json(
{"type": "notification", "notification": notification}
)
async def notification_new(self, event):
"""Новое уведомление (вызывается из notifications/services.py)"""
notification = event.get("notification", {})
if self._should_suppress_active_chat_notification(notification):
logger.info(
"[UserWS] Suppressed notification_new for active chat %s "
"in session %s",
self.active_chat_id,
self.channel_name,
)
return
await self.send_json(
{
"type": "notification",
"notification": notification,
}
)
async def notification_count_update(self, event):
"""
Обновление счетчика уведомлений.
Вызывается из notifications/services.py
"""
await self.send_json(
{"type": "unread_count", "count": event.get("count", 0)}
)
async def notification_read(self, event):
"""Синхронизация прочтения одного уведомления между сессиями."""
await self.send_json(
{
"type": "notification_read",
"notification_id": event.get("notification_id"),
"unread_count": event.get("unread_count"),
}
)
async def notifications_read_all(self, event):
"""Синхронизация массового прочтения уведомлений между сессиями."""
await self.send_json(
{
"type": "notifications_read_all",
"notification_ids": event.get("notification_ids", []),
"category": event.get("category"),
"unread_count": event.get("unread_count"),
}
)
# ==================== Обработка событий закупок ====================
async def poll_update(self, event):
"""Обработчик для события poll.update (алиас для chat_poll_update)"""
await self.chat_poll_update(event)
async def poll_update_sync(self, event):
"""Персональная синхронизация выбора в голосовании между сессиями."""
await self.chat_poll_update(event)
async def procurement_update(self, event):
"""
Обновление заявки на закупку.
Вызывается из procurement/signals.py
"""
await self.send_json(
{
"type": "procurement_update",
"event": event.get("event", "request_updated"),
"data": event.get("data", {}),
}
)
async def task_board_update(self, event):
"""
Обновление доски задач.
Вызывается из tasks/realtime.py
"""
await self.send_json(
{
"type": "task_board_update",
"event": event.get("event", "updated"),
"data": event.get("data", {}),
}
)