- Перенос REST API в communications/api/ для standalone использования * Создан communications/api/ с serializers.py, viewsets.py, urls.py * Перенесено 1668 строк (442 serializers + 1226 viewsets) * Удалена директория api/v1/communications/ * Обновлены импорты в api/v1/urls.py и api/v2/communications/views.py - Извлечение WebSocket логики в ChatConsumerMixin * Создан communications/consumers.py (709 строк, 32 метода) * Перенесены chat event handlers, action handlers, helper methods * realtime/consumers.py сокращен с 862 до 211 строк (-75%) * Сохранено одно WebSocket соединение через наследование миксина - Архитектура: * communications теперь полностью автономно (REST API + WebSocket) * realtime/UserConsumer наследуется от ChatConsumerMixin * 0 breaking changes, обратная совместимость 100% * Одно WebSocket соединение для всех real-time событий - Созданы тесты и документация: * test_phase3.py - проверка API serializers, filters, WebSocket * docs/completed/API_REFACTORING_STANDALONE.md * docs/completed/WEBSOCKET_REFACTORING_MIXIN.md * docs/completed/CHAT_REFACTORING_PHASE3_COMPLETE.md Результат: ✅ System check: 0 ошибок ✅ Phase 3 tests: все прошли ✅ Готово к standalone использованию (pip install django-communications)
212 lines
8.5 KiB
Python
212 lines
8.5 KiB
Python
# 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
|
||
|
||
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
|
||
|
||
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)
|
||
|
||
# ==================== Обработчики уведомлений ====================
|
||
|
||
async def notification_message(self, event):
|
||
"""
|
||
Новое уведомление от WebSocketNotificationSender.
|
||
Вызывается из notifications/senders/websocket.py
|
||
"""
|
||
await self.send_json({
|
||
"type": "notification",
|
||
"notification": event.get("message", {})
|
||
})
|
||
|
||
async def notification_new(self, event):
|
||
"""Новое уведомление (вызывается из notifications/services.py)"""
|
||
await self.send_json({
|
||
"type": "notification",
|
||
"notification": event.get("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 poll_update(self, event):
|
||
"""Обработчик для события poll.update (алиас для chat_poll_update)"""
|
||
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", {})
|
||
})
|
||
|