""" WebSocket consumer для реал-тайм синхронизации доски """ import json import asyncio import logging from channels.generic.websocket import AsyncWebsocketConsumer from channels.db import database_sync_to_async from django.contrib.auth.models import AnonymousUser from redis.exceptions import BusyLoadingError, ConnectionError as RedisConnectionError logger = logging.getLogger(__name__) class BoardConsumer(AsyncWebsocketConsumer): """Consumer для синхронизации доски между пользователями""" async def connect(self): """Подключение к WebSocket""" self.board_id = self.scope['url_route']['kwargs']['board_id'] self.room_group_name = f'board_{self.board_id}' self.user = self.scope.get('user', AnonymousUser()) # Логируем информацию о подключении для отладки query_string = self.scope.get('query_string', b'').decode() logger.info(f'[BoardConsumer] Попытка подключения: board_id={self.board_id}, user={self.user}, is_anonymous={self.user.is_anonymous}, query_string={query_string}') # Проверяем авторизацию if self.user.is_anonymous: logger.warning(f'[BoardConsumer] Отклонено: пользователь не авторизован для доски {self.board_id}') await self.close(code=4001) # Unauthorized return # Присоединяемся к группе доски с обработкой ошибок Redis max_retries = 5 retry_delay = 1 for attempt in range(max_retries): try: await self.channel_layer.group_add( self.room_group_name, self.channel_name ) break except (BusyLoadingError, RedisConnectionError) as e: if attempt < max_retries - 1: logger.warning(f'Redis загружается, повторная попытка {attempt + 1}/{max_retries}: {e}') await asyncio.sleep(retry_delay * (attempt + 1)) else: logger.error(f'Не удалось подключиться к Redis после {max_retries} попыток: {e}') await self.close() return except Exception as e: logger.error(f'Ошибка при подключении к channel layer: {e}', exc_info=True) await self.close() return await self.accept() logger.info(f'[BoardConsumer] Пользователь {self.user.email} подключился к доске {self.board_id}') # Отправляем всем что пользователь присоединился try: await self.channel_layer.group_send( self.room_group_name, { 'type': 'user_joined', 'user': { 'id': self.user.id, 'name': f'{self.user.first_name} {self.user.last_name}'.strip() or self.user.email, } } ) except (BusyLoadingError, RedisConnectionError) as e: logger.warning(f'Redis недоступен при отправке сообщения user_joined: {e}') async def disconnect(self, close_code): """Отключение от WebSocket""" if hasattr(self, 'room_group_name'): # Проверяем, что пользователь авторизован перед отправкой сообщений user = getattr(self, 'user', None) if user and not user.is_anonymous: try: # Отправляем всем что пользователь отключился await self.channel_layer.group_send( self.room_group_name, { 'type': 'user_left', 'user': { 'id': user.id, 'name': f'{user.first_name} {user.last_name}'.strip() or user.email, } } ) except (BusyLoadingError, RedisConnectionError) as e: logger.warning(f'Redis недоступен при отправке сообщения user_left: {e}') except Exception as e: logger.warning(f'Ошибка при отправке сообщения user_left: {e}') try: # Покидаем группу await self.channel_layer.group_discard( self.room_group_name, self.channel_name ) except (BusyLoadingError, RedisConnectionError) as e: logger.warning(f'Redis недоступен при покидании группы: {e}') except Exception as e: logger.warning(f'Ошибка при покидании группы: {e}') # Логируем отключение с проверкой пользователя if user and not user.is_anonymous: logger.info(f'[BoardConsumer] Пользователь {user.email} отключился от доски {self.board_id}') else: logger.info(f'[BoardConsumer] Анонимный пользователь отключился от доски {self.board_id}') async def receive(self, text_data): """Получение сообщения от клиента""" # Проверяем, что пользователь авторизован if not hasattr(self, 'user') or not self.user or self.user.is_anonymous: logger.warning('[BoardConsumer] Попытка отправить сообщение неавторизованным пользователем') return try: data = json.loads(text_data) message_type = data.get('type') if message_type == 'draw': board_data = data.get('data', {}) elements = board_data.get('elements', []) files = board_data.get('files', {}) # Логируем размер данных size_kb = len(text_data) / 1024 user_email = getattr(self.user, 'email', 'unknown') print(f'[BoardConsumer] Получено от {user_email}: {len(elements)} элементов, {len(files)} файлов, размер: {size_kb:.2f} KB') if len(files) > 0: print(f'[BoardConsumer] Files: {list(files.keys())}') # Отправляем изменения всем кроме отправителя try: await self.channel_layer.group_send( self.room_group_name, { 'type': 'board_update', 'data': board_data, 'user_id': self.user.id, } ) except (BusyLoadingError, RedisConnectionError) as e: logger.warning(f'Redis недоступен при отправке board_update: {e}') elif message_type == 'clear': # Очистка доски try: await self.channel_layer.group_send( self.room_group_name, { 'type': 'board_clear', 'user_id': self.user.id, } ) except (BusyLoadingError, RedisConnectionError) as e: logger.warning(f'Redis недоступен при отправке board_clear: {e}') elif message_type == 'undo': # Отменить действие try: await self.channel_layer.group_send( self.room_group_name, { 'type': 'board_undo', 'user_id': self.user.id, } ) except (BusyLoadingError, RedisConnectionError) as e: logger.warning(f'Redis недоступен при отправке board_undo: {e}') except json.JSONDecodeError: print('[BoardConsumer] Ошибка декодирования JSON') async def board_update(self, event): """Отправка обновления доски клиенту""" # Проверяем, что пользователь авторизован if not hasattr(self, 'user') or not self.user or self.user.is_anonymous: return # Не отправляем обратно отправителю if event.get('user_id') != self.user.id: board_data = event['data'] elements = board_data.get('elements', []) files = board_data.get('files', {}) message = json.dumps({ 'type': 'draw', 'data': board_data, }) size_kb = len(message) / 1024 user_email = getattr(self.user, 'email', 'unknown') print(f'[BoardConsumer] Отправка {user_email}: {len(elements)} элементов, {len(files)} файлов, размер: {size_kb:.2f} KB') await self.send(text_data=message) async def board_clear(self, event): """Очистка доски""" if not hasattr(self, 'user') or not self.user or self.user.is_anonymous: return if event.get('user_id') != self.user.id: await self.send(text_data=json.dumps({ 'type': 'clear', })) async def board_undo(self, event): """Отменить действие""" if not hasattr(self, 'user') or not self.user or self.user.is_anonymous: return if event.get('user_id') != self.user.id: await self.send(text_data=json.dumps({ 'type': 'undo', })) async def user_joined(self, event): """Уведомление о присоединении пользователя""" if not hasattr(self, 'user') or not self.user or self.user.is_anonymous: return if event['user']['id'] != self.user.id: await self.send(text_data=json.dumps({ 'type': 'user_joined', 'user': event['user'], })) async def user_left(self, event): """Уведомление об отключении пользователя""" if not hasattr(self, 'user') or not self.user or self.user.is_anonymous: return if event['user']['id'] != self.user.id: await self.send(text_data=json.dumps({ 'type': 'user_left', 'user': event['user'], }))