diff --git a/backend/api/v1/communications/serializers.py b/backend/api/v1/communications/serializers.py index ac893ab6..45d2e546 100644 --- a/backend/api/v1/communications/serializers.py +++ b/backend/api/v1/communications/serializers.py @@ -8,7 +8,7 @@ from communications.models import ( MessageReaction, Poll, PollOption, PollVote, ChatMembership, ChatUserSettings ) -from communications.consumers import serialize_message +from communications.serialization import serialize_message Employee = get_user_model() diff --git a/backend/api/v1/communications/views.py b/backend/api/v1/communications/views.py index 2342cc4e..247083aa 100644 --- a/backend/api/v1/communications/views.py +++ b/backend/api/v1/communications/views.py @@ -32,7 +32,7 @@ from communications.models import ( Poll, PollOption, PollVote, ChatMembership, ChatUserSettings, ChatReadState ) -from communications.consumers import serialize_message +from communications.serialization import serialize_message from communications.views import user_can_access_chat, _coerce_ts from .serializers import ( ChatListSerializer, ChatDetailSerializer, diff --git a/backend/communications/consumers.py b/backend/communications/consumers.py deleted file mode 100644 index 6899a098..00000000 --- a/backend/communications/consumers.py +++ /dev/null @@ -1,857 +0,0 @@ -# communications/consumers.py -import asyncio -from channels.db import database_sync_to_async -from channels.generic.websocket import AsyncJsonWebsocketConsumer -from django.contrib.auth.models import AnonymousUser -from django.db.models import Q -from django.urls import reverse -from django.utils import timezone - -from .models import Chat, ChatReadState, Message, MessageReaction - - -def serialize_message(m: Message) -> dict: - """Сериализация сообщения с поддержкой новых полей""" - author = m.author - author_name = author.get_full_name() or author.username - avatar = "" - try: - if getattr(author, "avatar", None) and author.avatar: - avatar = author.avatar.url - except Exception: - avatar = "" - - # Новые поля - data = { - "id": m.id, - "content": m.content, - "author_id": author.id if author else None, - "author_name": author_name, - "author_url": reverse("employees:employee_detail", args=[author.id]) if author else "", - "avatar": avatar, - "created": m.created_at.strftime("%d.%m.%Y %H:%M"), - "created_ts": int(m.created_at.timestamp() * 1000), - - # Новые поля - "is_edited": m.is_edited, - "edited_at": m.edited_at.isoformat() if m.edited_at else None, - "is_deleted": m.is_deleted, - "is_pinned": m.is_pinned, - "is_forwarded": m.is_forwarded, - "is_system": m.is_system, - "has_attachments": m.has_attachments, - } - - # Информация о пересылке (используем forward_metadata) - if m.is_forwarded: - try: - metadata = m.forward_metadata - forwarded_data = { - "author_id": metadata.original_author.id if metadata.original_author else None, - "author_name": ( - metadata.original_author.get_full_name() if metadata.original_author - else metadata.original_author.username if metadata.original_author - else "Неизвестно" - ), - "message_id": metadata.original_message_id if metadata.original_message else None, - } - - # Добавляем дату оригинального сообщения - if metadata.original_created_at: - forwarded_data["created_at"] = ( - metadata.original_created_at.strftime("%d.%m.%Y %H:%M") - ) - forwarded_data["created_ts"] = int( - metadata.original_created_at.timestamp() * 1000 - ) - - # Добавляем название исходного чата - if metadata.original_chat_name: - forwarded_data["chat_name"] = metadata.original_chat_name - - data["forwarded_from"] = forwarded_data - except Exception: - # Если метаданных нет, просто не добавляем информацию о пересылке - pass - - # Реакции - сериализуем из связанной модели MessageReaction - reactions_summary = {} - for reaction in m.reactions.select_related('user'): - emoji = reaction.emoji - if emoji not in reactions_summary: - reactions_summary[emoji] = { - 'count': 0, - 'users': [], - 'user_names': [] - } - reactions_summary[emoji]['count'] += 1 - reactions_summary[emoji]['users'].append(reaction.user_id) - reactions_summary[emoji]['user_names'].append( - reaction.user.get_full_name() or reaction.user.username - ) - data["reactions_summary"] = reactions_summary - - # Вложения - всегда включаем поле attachments - attachments = [] - if m.has_attachments: - for att in m.attachments.all(): - attachments.append({ - "id": att.id, - "file_name": att.file_name, - "file_type": att.file_type, - "file_url": att.file.url, - "file_size": att.file_size, - "mime_type": att.mime_type, - "width": att.width, # Размеры для CSS aspect-ratio - "height": att.height, - "thumbnail": ( - att.thumbnail.url - if getattr(att, "thumbnail", None) - else None - ), - }) - data["attachments"] = attachments - - # Голосование - if hasattr(m, 'poll'): - poll = m.poll - poll_data = { - "id": poll.id, - "question": poll.question, - "is_anonymous": poll.is_anonymous, - "is_multiple_choice": poll.is_multiple_choice, - "is_quiz": poll.is_quiz, - "is_closed": poll.is_closed, - "closes_at": poll.closes_at.isoformat() if poll.closes_at else None, - "total_voters": poll.total_voters, - "options": [] - } - for option in poll.options.all(): - poll_data["options"].append({ - "id": option.id, - "text": option.text, - "position": option.position, - "vote_count": option.vote_count, - "percentage": 0 # Будет пересчитан на клиенте - }) - data["poll"] = poll_data - - # Ответ на сообщение - if m.reply_to_id: - try: - reply_msg = m.reply_to if hasattr(m, 'reply_to') else None - if not reply_msg: - from communications.models import Message as Msg - reply_msg = Msg.objects.select_related('author').get( - pk=m.reply_to_id - ) - - data["reply_to"] = { - "id": reply_msg.id, - "content": ( - reply_msg.content[:100] if reply_msg.content else "" - ), - "author_name": ( - reply_msg.author.get_full_name() - if reply_msg.author - else "Неизвестный" - ) - } - print(f"[DEBUG] serialize_message: added reply_to " - f"id={reply_msg.id}, author={data['reply_to']['author_name']}") - except Exception as e: - # Если не удалось загрузить reply_to, просто пропускаем - print(f"[DEBUG] serialize_message: failed to load reply_to: {e}") - pass - - - # Информация о пересылке - if m.is_forwarded and hasattr(m, 'forward_info'): - fw = m.forward_info - data["forward_info"] = { - "original_author": fw.preserved_author_name, - "forward_count": fw.forward_count, - } - - return data - - -class ChatConsumer(AsyncJsonWebsocketConsumer): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.ping_task = None - self.ping_interval = 20 # Ping каждые 20 секунд - - async def connect(self): - user = self.scope.get("user") - if not user or isinstance(user, AnonymousUser) or not user.is_authenticated: - await self.close(code=4401); return - - self.chat_id = int(self.scope["url_route"]["kwargs"]["chat_id"]) - self.group_name = f"chat_{self.chat_id}" - - chat = await self._get_chat(self.chat_id) - if not await self._user_can_access(chat, user): - await self.close(code=4403); return - - self.chat = chat - await self.channel_layer.group_add(self.group_name, self.channel_name) - await self.accept() - - # Отправляем начальную историю сообщений - await self._send_initial_messages() - - # помечаем как прочитанное «до текущего» - await self._mark_read(self.chat, user) - - # Запускаем ping цикл для keepalive - self.ping_task = asyncio.create_task(self._ping_loop()) - - async def disconnect(self, code): - # Останавливаем ping цикл - if self.ping_task: - self.ping_task.cancel() - try: - await self.ping_task - except asyncio.CancelledError: - pass - - if hasattr(self, "group_name"): - await self.channel_layer.group_discard(self.group_name, self.channel_name) - # Сбрасываем статус "набирает текст" - if hasattr(self, 'chat'): - await self._set_typing_status(self.chat, self.scope["user"], False) - - 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"Ping loop error: {e}") - - async def receive_json(self, content, **kwargs): - """Обработка входящих сообщений и действий""" - if not isinstance(content, dict): - return - - action = content.get("action", "send_message") - user = self.scope["user"] - - print(f"[DEBUG] receive_json: action={action}, user={user.id}, content={content}") - - # Обработка разных действий - if action == "send_message": - await self._handle_send_message(content, user) - elif action == "edit_message": - await self._handle_edit_message(content, user) - elif action == "delete_message": - await self._handle_delete_message(content, user) - elif action == "add_reaction": - await self._handle_add_reaction(content, user) - elif action == "remove_reaction": - await self._handle_remove_reaction(content, user) - elif action == "typing": - await self._handle_typing(content, user) - elif action == "stop_typing": - await self._handle_stop_typing(user) - - async def _handle_send_message(self, content, user): - """Отправка нового сообщения""" - text = str(content.get("content") or content.get("text") or "").strip() - if not text: - return - - # Проверка прав на отправку сообщений - if self.chat.type == "announcement": - # Только создатель может писать в объявление - if self.chat.created_by_id != user.id: - await self.send_json({ - "type": "error", - "error": "Только автор может публиковать в это объявление" - }) - return - else: - # В других типах проверяем can_send_messages - has_permission = await self._check_send_permission(user) - if not has_permission: - await self.send_json({ - "type": "error", - "error": "У вас нет прав для отправки сообщений" - }) - return - - # Проверка на ответ (reply) - reply_to_id = content.get("reply_to_id") - - # Запрет на reply в чатах где can_reply=False - if reply_to_id and not self.chat.can_reply: - await self.send_json({ - "type": "error", - "error": "В этом чате запрещены ответы на сообщения" - }) - return - - msg = await self._create_message(self.chat, user, text, reply_to_id) - await self._mark_read(self.chat, user) - - # Сбрасываем статус "набирает" - await self._set_typing_status(self.chat, user, False) - - payload = await self._serialize_message_full(msg) - await self.channel_layer.group_send( - self.group_name, - {"type": "chat.message", "chat_id": self.chat_id, "payload": payload}, - ) - - async def _handle_edit_message(self, content, user): - """Редактирование сообщения""" - message_id = content.get("message_id") - new_content = content.get("content", "").strip() - - if not message_id or not new_content: - return - - # Запрет на редактирование в объявлениях - if self.chat.type == "announcement": - await self.send_json({ - "type": "error", - "error": "Редактирование запрещено в объявлениях" - }) - return - - success = await self._edit_message(message_id, user, new_content) - if success: - msg = await self._get_message(message_id) - payload = await self._serialize_message_full(msg) - - await self.channel_layer.group_send( - self.group_name, - { - "type": "chat.message_edited", - "chat_id": self.chat_id, - "payload": payload - }, - ) - - async def _handle_delete_message(self, content, user): - """Удаление сообщения""" - import logging - logger = logging.getLogger(__name__) - - message_id = content.get("message_id") - logger.info( - "[WS_DELETE] Received delete request: message_id=%s, user=%s", - message_id, user.username - ) - - if not message_id: - logger.warning("[WS_DELETE] No message_id provided") - return - - # Запрет на удаление в объявлениях - if self.chat.type == "announcement": - logger.warning( - "[WS_DELETE] Delete blocked: announcement chat" - ) - await self.send_json({ - "type": "error", - "error": "Удаление запрещено в объявлениях" - }) - return - - logger.info("[WS_DELETE] Calling _delete_message") - success = await self._delete_message(message_id, user) - logger.info("[WS_DELETE] Delete result: %s", success) - - if success: - logger.info( - "[WS_DELETE] Broadcasting to group: %s", self.group_name - ) - await self.channel_layer.group_send( - self.group_name, - { - "type": "chat.message_deleted", - "chat_id": self.chat_id, - "message_id": message_id - }, - ) - logger.info( - "[WS_DELETE] Broadcast sent for message_id=%s", message_id - ) - - async def _handle_add_reaction(self, content, user): - """Добавление реакции""" - message_id = content.get("message_id") - emoji = content.get("emoji", "").strip() - - if not message_id or not emoji: - return - - reactions = await self._add_reaction(message_id, user, emoji) - if reactions is not None: - await self.channel_layer.group_send( - self.group_name, - { - "type": "chat.reaction_added", - "chat_id": self.chat_id, - "message_id": message_id, - "user_id": user.id, - "emoji": emoji, - "reactions_summary": reactions - }, - ) - - async def _handle_remove_reaction(self, content, user): - """Удаление реакции""" - message_id = content.get("message_id") - emoji = content.get("emoji", "").strip() - - if not message_id or not emoji: - return - - reactions = await self._remove_reaction(message_id, user, emoji) - if reactions is not None: - await self.channel_layer.group_send( - self.group_name, - { - "type": "chat.reaction_removed", - "chat_id": self.chat_id, - "message_id": message_id, - "user_id": user.id, - "emoji": emoji, - "reactions_summary": reactions - }, - ) - - async def _handle_typing(self, content, user): - """Индикатор набора текста""" - await self._set_typing_status(self.chat, user, True) - - await self.channel_layer.group_send( - self.group_name, - { - "type": "chat.user_typing", - "chat_id": self.chat_id, - "user_id": user.id, - "user_name": user.get_full_name() or user.username - }, - ) - - async def _handle_stop_typing(self, user): - """Остановка индикатора набора""" - await self._set_typing_status(self.chat, user, False) - - await self.channel_layer.group_send( - self.group_name, - { - "type": "chat.user_stopped_typing", - "chat_id": self.chat_id, - "user_id": user.id - }, - ) - - # Обработчики событий от group_send - async def chat_message(self, event): - """Новое сообщение""" - await self.send_json({ - "type": "message", - **event["payload"] - }) - - async def chat_message_edited(self, event): - """Сообщение отредактировано""" - await self.send_json({ - "type": "message_edited", - "message": event["payload"] - }) - - async def chat_message_deleted(self, event): - """Сообщение удалено""" - import logging - logger = logging.getLogger(__name__) - - logger.info( - "[WS_DELETE] chat_message_deleted handler called: %s", - event - ) - - await self.send_json({ - "type": "message_deleted", - "message_id": event["message_id"] - }) - - logger.info( - "[WS_DELETE] Sent to client: message_id=%s", - event["message_id"] - ) - - async def poll_update(self, event): - """Обновление результатов голосования""" - await self.send_json({ - "type": "poll_update", - **event["payload"] - }) - - async def chat_reaction_added(self, event): - """Реакция добавлена""" - logger.info(f"[Consumer] chat_reaction_added called: message_id={event.get('message_id')}, emoji={event.get('emoji')}") - await self.send_json({ - "type": "reaction_added", - "message_id": event["message_id"], - "user_id": event.get("user_id"), - "emoji": event.get("emoji"), - "reactions_summary": event.get("reactions_summary", {}) - }) - logger.info(f"[Consumer] reaction_added sent to client") - - async def chat_reaction_removed(self, event): - """Реакция удалена""" - logger.info(f"[Consumer] chat_reaction_removed called: message_id={event.get('message_id')}, emoji={event.get('emoji')}") - await self.send_json({ - "type": "reaction_removed", - "message_id": event["message_id"], - "user_id": event.get("user_id"), - "emoji": event.get("emoji"), - "reactions_summary": event.get("reactions_summary", {}) - }) - logger.info(f"[Consumer] reaction_removed sent to client") - - async def chat_user_typing(self, event): - """Пользователь набирает текст""" - # Не отправляем самому себе - if event["user_id"] != self.scope["user"].id: - await self.send_json({ - "type": "user_typing", - "user_id": event["user_id"], - "user_name": event["user_name"] - }) - - async def chat_user_stopped_typing(self, event): - """Пользователь закончил набор""" - if event["user_id"] != self.scope["user"].id: - await self.send_json({ - "type": "user_stopped_typing", - "user_id": event["user_id"] - }) - - # --- DB helpers --- - @database_sync_to_async - def _get_chat(self, chat_id: int) -> Chat: - return ( - Chat.objects.select_related("department") - .prefetch_related("participants") - .get(pk=chat_id) - ) - - @database_sync_to_async - def _check_send_permission(self, user) -> bool: - """Проверка права на отправку сообщений""" - from .models import ChatMembership - membership = ChatMembership.objects.filter( - chat=self.chat, user=user - ).first() - if membership: - return membership.can_send_messages - return True # По умолчанию можно писать - - @database_sync_to_async - def _user_can_access(self, chat: Chat, user) -> bool: - if chat.type == "global": - return True - if chat.type == "department": - dept = chat.department - if not dept: - return False - return ( - user.is_staff - or dept.head_id == user.id - or dept.active_employees.filter(id=user.id).exists() - ) - if chat.type == "private": - return chat.participants.filter(id=user.id).exists() - if chat.type in ["group", "channel", "announcement"]: - # Проверка через ChatMembership - from communications.models import ChatMembership - return ChatMembership.objects.filter( - chat=chat, user=user - ).exists() - return False - - @database_sync_to_async - def _create_message(self, chat: Chat, user, text: str, reply_to_id=None) -> Message: - print(f"[DEBUG] Creating message: chat={chat.id}, user={user.id}, text='{text[:50]}...'") - msg = Message.objects.create( - chat=chat, - author=user, - content=text[:2000], - reply_to_id=reply_to_id - ) - print(f"[DEBUG] Message created: id={msg.id}") - return msg - - @database_sync_to_async - def _get_message(self, message_id: int) -> Message: - return Message.objects.select_related( - 'author', 'reply_to', 'reply_to__author', - 'forward_metadata', 'forward_metadata__original_author', 'poll' - ).prefetch_related( - 'attachments', 'reactions', 'reactions__user', 'poll__options' - ).get(pk=message_id) - - @database_sync_to_async - def _serialize_message_full(self, msg: Message) -> dict: - return serialize_message(msg) - - @database_sync_to_async - def _edit_message(self, message_id: int, user, new_content: str) -> bool: - from .models import MessageEditHistory - - try: - msg = Message.objects.get(pk=message_id, author=user) - - # Сохраняем старый контент в историю редактирования - MessageEditHistory.objects.create( - message=msg, - previous_content=msg.content, - edited_by=user - ) - - msg.content = new_content - msg.is_edited = True - msg.edited_at = timezone.now() - msg.save() - return True - except Message.DoesNotExist: - return False - - @database_sync_to_async - def _delete_message(self, message_id: int, user) -> bool: - import logging - logger = logging.getLogger(__name__) - - try: - logger.info( - "[WS_DELETE_DB] Looking for message: id=%s, author=%s", - message_id, user.username - ) - msg = Message.objects.get(pk=message_id, author=user) - logger.info( - "[WS_DELETE_DB] Message found: chat_id=%s", - msg.chat_id - ) - - msg.is_deleted = True - msg.deleted_at = timezone.now() - msg.deleted_by = user - msg.save() - - logger.info( - "[WS_DELETE_DB] Message marked as deleted: %s", - message_id - ) - return True - except Message.DoesNotExist: - logger.warning( - "[WS_DELETE_DB] Message not found or wrong author: %s", - message_id - ) - return False - - @database_sync_to_async - def _add_reaction(self, message_id: int, user, emoji: str): - """Добавить реакцию (новая реализация с MessageReaction)""" - try: - msg = Message.objects.get(pk=message_id) - - # Создаём или обновляем реакцию - reaction, created = MessageReaction.objects.update_or_create( - message=msg, - user=user, - defaults={'emoji': emoji} - ) - - # Возвращаем сводку по всем реакциям - return msg.get_reactions_summary() - except Message.DoesNotExist: - return None - - @database_sync_to_async - def _remove_reaction(self, message_id: int, user, emoji: str): - """Удалить реакцию (новая реализация с MessageReaction)""" - try: - msg = Message.objects.get(pk=message_id) - - # Удаляем реакцию пользователя (если указан emoji - только этот, иначе все) - if emoji: - MessageReaction.objects.filter( - message=msg, - user=user, - emoji=emoji - ).delete() - else: - MessageReaction.objects.filter( - message=msg, - user=user - ).delete() - - # Возвращаем обновлённую сводку - return msg.get_reactions_summary() - except Message.DoesNotExist: - return None - - @database_sync_to_async - def _set_typing_status(self, chat: Chat, user, is_typing: bool): - ChatReadState.objects.update_or_create( - chat=chat, - user=user, - defaults={ - 'is_typing': is_typing, - 'typing_updated_at': timezone.now() if is_typing else None - } - ) - - async def _send_initial_messages(self): - """Отправить начальную историю сообщений при подключении""" - messages = await self._get_initial_messages(self.chat_id, limit=50) - - # Отправляем все сообщения одним пакетом - await self.send_json({ - "type": "initial_messages", - "messages": messages - }) - - @database_sync_to_async - def _get_initial_messages(self, chat_id: int, limit: int = 50): - """Получить последние N сообщений из чата""" - messages = Message.objects.filter( - chat_id=chat_id - ).select_related( - 'author' - ).prefetch_related( - 'attachments', - 'reactions__user' - ).order_by('-created_at')[:limit] - - # Возвращаем в прямом порядке (старые -> новые) - return [serialize_message(msg) for msg in reversed(list(messages))] - - @database_sync_to_async - def _mark_read(self, chat: Chat, user): - last = chat.messages.order_by("-created_at").first() - ts = last.created_at if last else timezone.now() - ChatReadState.objects.update_or_create( - chat=chat, user=user, defaults={"last_read_at": ts} - ) - - -class ChatListConsumer(AsyncJsonWebsocketConsumer): - """ - Список чатов подписывается на все chat_{id} пользователя, и каждое - новое сообщение получает как компактное обновление для карточки. - """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.ping_task = None - self.ping_interval = 20 # Ping каждые 20 секунд - - async def connect(self): - user = self.scope.get("user") - if not user or isinstance(user, AnonymousUser) or not user.is_authenticated: - await self.close(code=4401); return - - self.group_names = [] - chat_ids = await self._get_available_chat_ids(user) - for cid in chat_ids: - g = f"chat_{cid}" - await self.channel_layer.group_add(g, self.channel_name) - self.group_names.append(g) - - await self.accept() - - # Запускаем ping цикл для keepalive - self.ping_task = asyncio.create_task(self._ping_loop()) - - async def disconnect(self, code): - # Останавливаем ping цикл - if self.ping_task: - self.ping_task.cancel() - try: - await self.ping_task - except asyncio.CancelledError: - pass - - for g in getattr(self, "group_names", []): - await self.channel_layer.group_discard(g, self.channel_name) - - 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"Ping loop error: {e}") - - async def chat_message(self, event): - # Проксируем компактное событие в UI списка - await self.send_json( - { - "type": "list_update", - "chat_id": event.get("chat_id"), - "message": event.get("payload") - } - ) - - async def chat_message_edited(self, event): - """Сообщение отредактировано - обновляем в списке если это последнее""" - await self.send_json({ - "type": "message_edited", - "chat_id": event.get("chat_id"), - "message": event.get("payload") - }) - - async def chat_user_typing(self, event): - """Индикатор набора текста в списке чатов""" - await self.send_json({ - "type": "user_typing", - "chat_id": event.get("chat_id"), - "user_id": event.get("user_id"), - "user_name": event.get("user_name") - }) - - async def chat_user_stopped_typing(self, event): - """Остановка индикатора набора текста""" - await self.send_json({ - "type": "user_stopped_typing", - "chat_id": event.get("chat_id"), - "user_id": event.get("user_id") - }) - - async def chat_reaction_added(self, event): - """Реакция добавлена (игнорируем в списке чатов)""" - pass - - async def chat_reaction_removed(self, event): - """Реакция удалена (игнорируем в списке чатов)""" - pass - - async def chat_poll_update(self, event): - """Обновление голосования (игнорируем в списке чатов)""" - pass - - @database_sync_to_async - def _get_available_chat_ids(self, user): - departments = user.departments.all() - return list( - Chat.objects.filter( - Q(type="global") - | Q(type="department", department__in=departments) - | Q(type="private", participants=user) - ).values_list("id", flat=True) - ) diff --git a/backend/communications/serialization.py b/backend/communications/serialization.py new file mode 100644 index 00000000..05540eab --- /dev/null +++ b/backend/communications/serialization.py @@ -0,0 +1,179 @@ +# communications/serialization.py +""" +Утилиты сериализации для модуля communications. +Вынесены из consumers.py для переиспользования в API, WebSocket и других местах. +""" +from django.urls import reverse + + +def serialize_message(m) -> dict: + """ + Сериализация сообщения с поддержкой всех полей. + + Поддерживаемые фичи: + - Базовые поля (контент, автор, дата) + - Редактирование и удаление + - Пересылка сообщений (forward) + - Реакции + - Вложения (attachments) + - Голосования (polls) + - Ответы на сообщения (reply_to) + """ + author = m.author + author_name = author.get_full_name() or author.username + avatar = "" + try: + if getattr(author, "avatar", None) and author.avatar: + avatar = author.avatar.url + except Exception: + avatar = "" + + # Базовые поля + data = { + "id": m.id, + "content": m.content, + "author_id": author.id if author else None, + "author_name": author_name, + "author_url": reverse("employees:employee_detail", args=[author.id]) if author else "", + "avatar": avatar, + "created": m.created_at.strftime("%d.%m.%Y %H:%M"), + "created_ts": int(m.created_at.timestamp() * 1000), + + # Статусные поля + "is_edited": m.is_edited, + "edited_at": m.edited_at.isoformat() if m.edited_at else None, + "is_deleted": m.is_deleted, + "is_pinned": m.is_pinned, + "is_forwarded": m.is_forwarded, + "is_system": m.is_system, + "has_attachments": m.has_attachments, + } + + # Информация о пересылке (используем forward_metadata) + if m.is_forwarded: + try: + metadata = m.forward_metadata + forwarded_data = { + "author_id": metadata.original_author.id if metadata.original_author else None, + "author_name": ( + metadata.original_author.get_full_name() if metadata.original_author + else metadata.original_author.username if metadata.original_author + else "Неизвестно" + ), + "message_id": metadata.original_message_id if metadata.original_message else None, + } + + # Добавляем дату оригинального сообщения + if metadata.original_created_at: + forwarded_data["created_at"] = ( + metadata.original_created_at.strftime("%d.%m.%Y %H:%M") + ) + forwarded_data["created_ts"] = int( + metadata.original_created_at.timestamp() * 1000 + ) + + # Добавляем название исходного чата + if metadata.original_chat_name: + forwarded_data["chat_name"] = metadata.original_chat_name + + data["forwarded_from"] = forwarded_data + except Exception: + # Если метаданных нет, просто не добавляем информацию о пересылке + pass + + # Реакции - сериализуем из связанной модели MessageReaction + reactions_summary = {} + for reaction in m.reactions.select_related('user'): + emoji = reaction.emoji + if emoji not in reactions_summary: + reactions_summary[emoji] = { + 'count': 0, + 'users': [], + 'user_names': [] + } + reactions_summary[emoji]['count'] += 1 + reactions_summary[emoji]['users'].append(reaction.user_id) + reactions_summary[emoji]['user_names'].append( + reaction.user.get_full_name() or reaction.user.username + ) + data["reactions_summary"] = reactions_summary + + # Вложения - всегда включаем поле attachments + attachments = [] + if m.has_attachments: + for att in m.attachments.all(): + attachments.append({ + "id": att.id, + "file_name": att.file_name, + "file_type": att.file_type, + "file_url": att.file.url, + "file_size": att.file_size, + "mime_type": att.mime_type, + "width": att.width, # Размеры для CSS aspect-ratio + "height": att.height, + "thumbnail": ( + att.thumbnail.url + if getattr(att, "thumbnail", None) + else None + ), + }) + data["attachments"] = attachments + + # Голосование + if hasattr(m, 'poll'): + poll = m.poll + poll_data = { + "id": poll.id, + "question": poll.question, + "is_anonymous": poll.is_anonymous, + "is_multiple_choice": poll.is_multiple_choice, + "is_quiz": poll.is_quiz, + "is_closed": poll.is_closed, + "closes_at": poll.closes_at.isoformat() if poll.closes_at else None, + "total_voters": poll.total_voters, + "options": [] + } + for option in poll.options.all(): + poll_data["options"].append({ + "id": option.id, + "text": option.text, + "position": option.position, + "vote_count": option.vote_count, + "percentage": 0 # Будет пересчитан на клиенте + }) + data["poll"] = poll_data + + # Ответ на сообщение + if m.reply_to_id: + try: + reply_msg = m.reply_to if hasattr(m, 'reply_to') else None + if not reply_msg: + from communications.models import Message as Msg + reply_msg = Msg.objects.select_related('author').get( + pk=m.reply_to_id + ) + + data["reply_to"] = { + "id": reply_msg.id, + "content": ( + reply_msg.content[:100] if reply_msg.content else "" + ), + "author_name": ( + reply_msg.author.get_full_name() + if reply_msg.author + else "Неизвестный" + ) + } + except Exception as e: + # Если не удалось загрузить reply_to, просто пропускаем + pass + + # Информация о пересылке (legacy) + if m.is_forwarded and hasattr(m, 'forward_info'): + fw = m.forward_info + data["forward_info"] = { + "original_author": fw.preserved_author_name, + "forward_count": fw.forward_count, + } + + return data diff --git a/backend/employees/management/commands/seed_test_data.py b/backend/employees/management/commands/seed_test_data.py new file mode 100644 index 00000000..eac5a95a --- /dev/null +++ b/backend/employees/management/commands/seed_test_data.py @@ -0,0 +1,241 @@ +""" +Management команда для заполнения БД тестовыми данными +Использование: python manage.py seed_test_data [--admin-username USERNAME] +""" +from django.core.management.base import BaseCommand +from django.contrib.auth import get_user_model +from django.db import transaction +from employees.models import Department, Position, Employee, Skill +from datetime import datetime, timedelta +import random + +User = get_user_model() + + +class Command(BaseCommand): + help = 'Заполняет БД тестовыми данными и создает администратора' + + def add_arguments(self, parser): + parser.add_argument( + '--admin-email', + type=str, + default='igor@example.com', + help='Email администратора (используется как логин)' + ) + parser.add_argument( + '--admin-phone', + type=str, + default='+79991234567', + help='Телефон администратора' + ) + parser.add_argument( + '--clear', + action='store_true', + help='Очистить существующие данные перед заполнением' + ) + + @transaction.atomic + def handle(self, *args, **options): + admin_email = options['admin_email'] + admin_phone = options['admin_phone'] + clear_data = options.get('clear', False) + + if clear_data: + self.stdout.write(self.style.WARNING('Очистка существующих данных...')) + Employee.objects.all().delete() + Position.objects.all().delete() + Department.objects.all().delete() + Skill.objects.all().delete() + + # 1. Создаем/обновляем администратора + self.stdout.write(self.style.SUCCESS(f'\n1. Создание администратора: {admin_email}')) + + admin_user, created = User.objects.update_or_create( + email=admin_email, + defaults={ + 'phone_number': admin_phone, + 'is_staff': True, + 'is_superuser': True, + 'is_active': True, + 'email_verified': True, + 'first_name': 'Игорь', + 'last_name': 'Администратор', + 'patronymic': 'Владимирович', + } + ) + + if created: + admin_user.set_password('admin123') + admin_user.save() + self.stdout.write(self.style.SUCCESS( + f'✓ Создан новый администратор: {admin_email}\n' + f' Пароль: admin123' + )) + else: + # Обновляем права у существующего + admin_user.is_staff = True + admin_user.is_superuser = True + admin_user.is_active = True + admin_user.email_verified = True + admin_user.save() + self.stdout.write(self.style.WARNING( + f'✓ Администратор {admin_email} обновлен' + )) + + # Убираем права администратора у всех остальных + User.objects.exclude(email=admin_email).update( + is_staff=False, + is_superuser=False + ) + self.stdout.write(self.style.SUCCESS( + f'✓ {admin_email} - единственный администратор\n' + )) + + # 2. Создаем отделы + self.stdout.write(self.style.SUCCESS('2. Создание отделов')) + departments = [] + dept_names = [ + 'IT-отдел', + 'Отдел разработки', + 'Отдел поддержки', + 'HR-отдел', + 'Финансовый отдел', + 'Отдел продаж', + ] + + for name in dept_names: + dept, created = Department.objects.get_or_create( + name=name, + defaults={'description': f'Описание {name}'} + ) + departments.append(dept) + status = '✓ Создан' if created else '- Существует' + self.stdout.write(f' {status}: {name}') + + # 3. Создаем должности + self.stdout.write(self.style.SUCCESS('\n3. Создание должностей')) + positions = [] + position_names = [ + 'Директор', + 'Руководитель отдела', + 'Ведущий разработчик', + 'Разработчик', + 'Junior разработчик', + 'Системный администратор', + 'Специалист техподдержки', + 'HR-менеджер', + 'Бухгалтер', + 'Менеджер по продажам', + ] + + for name in position_names: + pos, created = Position.objects.get_or_create(name=name) + positions.append(pos) + status = '✓ Создан' if created else '- Существует' + self.stdout.write(f' {status}: {name}') + + # 4. Создаем навыки + self.stdout.write(self.style.SUCCESS('\n4. Создание навыков')) + skills = [] + skill_names = [ + 'Python', 'Django', 'JavaScript', 'React', 'PostgreSQL', + 'Docker', 'Git', 'REST API', 'WebSocket', 'Redis', + 'Celery', 'LDAP', 'Linux', 'HTML/CSS', 'SCSS', + ] + + for name in skill_names: + skill, created = Skill.objects.get_or_create(name=name) + skills.append(skill) + status = '✓ Создан' if created else '- Существует' + self.stdout.write(f' {status}: {name}') + + # 5. Создаем сотрудников + self.stdout.write(self.style.SUCCESS('\n5. Создание тестовых сотрудников')) + + # Обновляем администратора - добавляем должность + admin_user.position = positions[0] # Директор + admin_user.birth_date = datetime(1990, 1, 1).date() + admin_user.save() + + # Добавляем администратора в IT-отдел как руководителя + from employees.models import EmployeeDepartment + EmployeeDepartment.objects.get_or_create( + employee=admin_user, + department=departments[0], + defaults={'is_active': True} + ) + departments[0].head = admin_user + departments[0].save() + + # Добавляем навыки администратору + admin_user.skills.set(random.sample(skills, min(5, len(skills)))) + self.stdout.write(f' ✓ Обновлен профиль администратора') + + # Тестовые сотрудники + test_employees = [ + ('Анна', 'Иванова', 'Петровна', 'anna.ivanova', '+79991234501'), + ('Петр', 'Сидоров', 'Иванович', 'petr.sidorov', '+79991234502'), + ('Мария', 'Петрова', 'Сергеевна', 'maria.petrova', '+79991234503'), + ('Алексей', 'Смирнов', 'Александрович', 'alex.smirnov', '+79991234504'), + ('Елена', 'Козлова', 'Дмитриевна', 'elena.kozlova', '+79991234505'), + ('Дмитрий', 'Морозов', 'Павлович', 'dmitry.morozov', '+79991234506'), + ('Ольга', 'Новикова', 'Андреевна', 'olga.novikova', '+79991234507'), + ('Сергей', 'Волков', 'Николаевич', 'sergey.volkov', '+79991234508'), + ('Наталья', 'Соколова', 'Викторовна', 'natalia.sokolova', '+79991234509'), + ('Андрей', 'Лебедев', 'Михайлович', 'andrey.lebedev', '+79991234510'), + ] + + created_count = 0 + for first_name, last_name, patronymic, username, phone in test_employees: + email = f'{username}@example.com' + + # Создаем User + user, user_created = User.objects.get_or_create( + email=email, + defaults={ + 'phone_number': phone, + 'first_name': first_name, + 'last_name': last_name, + 'patronymic': patronymic, + 'is_active': True, + 'email_verified': True, + 'birth_date': datetime(1985 + random.randint(0, 20), random.randint(1, 12), random.randint(1, 28)).date(), + 'position': random.choice(positions[2:]), + } + ) + if user_created: + user.set_password('test123') + # Добавляем случайные навыки + user.skills.set(random.sample(skills, random.randint(3, 7))) + user.save() + + # Добавляем в случайный отдел + dept = random.choice(departments) + EmployeeDepartment.objects.create( + employee=user, + department=dept, + is_active=True + ) + + created_count += 1 + self.stdout.write(f' ✓ {first_name} {last_name}') + + # Итоговая статистика + self.stdout.write(self.style.SUCCESS('\n' + '='*60)) + self.stdout.write(self.style.SUCCESS('✓ Заполнение БД завершено!')) + self.stdout.write(self.style.SUCCESS('='*60)) + self.stdout.write(f'\nСоздано объектов:') + self.stdout.write(f' • Отделов: {Department.objects.count()}') + self.stdout.write(f' • Должностей: {Position.objects.count()}') + self.stdout.write(f' • Навыков: {Skill.objects.count()}') + self.stdout.write(f' • Пользователей: {User.objects.count()}') + + self.stdout.write(self.style.SUCCESS(f'\n🔐 Администратор:')) + self.stdout.write(f' Email (логин): {admin_email}') + if created: + self.stdout.write(f' Пароль: admin123') + + self.stdout.write(self.style.WARNING(f'\n👥 Тестовые пользователи:')) + self.stdout.write(f' Логин: @example.com') + self.stdout.write(f' Пароль для всех: test123') + self.stdout.write('') diff --git a/backend/realtime/consumers.py b/backend/realtime/consumers.py index 1b1ffbbe..a40b46c5 100644 --- a/backend/realtime/consumers.py +++ b/backend/realtime/consumers.py @@ -17,7 +17,7 @@ from django.db.models import Q from django.utils import timezone from communications.models import Chat, ChatMembership, ChatReadState, Message, MessageReaction -from communications.consumers import serialize_message +from communications.serialization import serialize_message logger = logging.getLogger(__name__)