diff --git a/backend/communications/api/serializers.py b/backend/communications/api/serializers.py index b4245c0c..317aca97 100644 --- a/backend/communications/api/serializers.py +++ b/backend/communications/api/serializers.py @@ -50,15 +50,59 @@ def _get_last_message(obj): return ( obj.messages.filter(is_deleted=False) - .select_related("author") + .select_related("author", "poll") + .prefetch_related("attachments") .order_by("-created_at") .first() ) +def _get_last_message_preview(message): + content = (message.content or "").strip() + if content: + return content[:100], "text" + + poll = getattr(message, "poll", None) + if poll is not None: + question = (poll.question or "").strip() + preview = f"Голосование: {question}" if question else "Голосование" + return preview[:100], "poll" + + attachments = list(message.attachments.all()) + if len(attachments) > 1: + return f"Вложения: {len(attachments)}", "attachments" + if attachments: + attachment_type = attachments[0].file_type + labels = { + "image": "Фото", + "video": "Видео", + "audio": "Аудио", + "file": "Файл", + } + return labels.get(attachment_type, "Файл"), attachment_type or "file" + + return "Сообщение", "message" + + +def _serialize_last_message(message): + preview_text, preview_type = _get_last_message_preview(message) + return { + "id": message.id, + "content": (message.content or "")[:100], + "preview_text": preview_text, + "preview_type": preview_type, + "author_name": ( + message.author.get_full_name() if message.author else "Unknown" + ), + "created_at": message.created_at.isoformat(), + } + + class ChatLastMessageSerializer(serializers.Serializer): id = serializers.IntegerField() - content = serializers.CharField() + content = serializers.CharField(allow_blank=True) + preview_text = serializers.CharField() + preview_type = serializers.CharField() author_name = serializers.CharField() created_at = serializers.DateTimeField() @@ -173,14 +217,7 @@ class ChatListSerializer(serializers.ModelSerializer): """Последнее сообщение в чате (использует prefetch если доступен)""" last_msg = _get_last_message(obj) if last_msg: - return { - "id": last_msg.id, - "content": last_msg.content[:100], - "author_name": last_msg.author.get_full_name() - if last_msg.author - else "Unknown", - "created_at": last_msg.created_at.isoformat(), - } + return _serialize_last_message(last_msg) return None @extend_schema_field(ChatParticipantPreviewSerializer(many=True)) @@ -367,14 +404,7 @@ class ChatDetailSerializer(serializers.ModelSerializer): if not last_msg: return None - return { - "id": last_msg.id, - "content": last_msg.content[:100], - "author_name": last_msg.author.get_full_name() - if last_msg.author - else "Unknown", - "created_at": last_msg.created_at.isoformat(), - } + return _serialize_last_message(last_msg) @extend_schema_field(serializers.IntegerField()) def get_unread_count(self, obj): diff --git a/backend/communications/api/viewsets.py b/backend/communications/api/viewsets.py index dc60bcfa..ed5333b4 100644 --- a/backend/communications/api/viewsets.py +++ b/backend/communications/api/viewsets.py @@ -230,7 +230,9 @@ class ChatViewSet(viewsets.ModelViewSet): 'messages', queryset=Message.objects.filter( is_deleted=False, - ).select_related('author').order_by('-created_at')[:1], + ).select_related('author', 'poll').prefetch_related( + 'attachments' + ).order_by('-created_at')[:1], to_attr='_prefetched_last_message' ), ).distinct() diff --git a/backend/tests/api/v1/communications/test_communications_api.py b/backend/tests/api/v1/communications/test_communications_api.py index 9a1b4184..d6f01cc1 100644 --- a/backend/tests/api/v1/communications/test_communications_api.py +++ b/backend/tests/api/v1/communications/test_communications_api.py @@ -3,7 +3,15 @@ """ import pytest -from communications.models import Chat, ChatMembership, ChatReadState, ChatUserSettings, Message +from communications.models import ( + Chat, + ChatMembership, + ChatReadState, + ChatUserSettings, + Message, + MessageAttachment, + Poll, +) from django.contrib.auth import get_user_model from django.urls import reverse from django.utils import timezone @@ -115,6 +123,60 @@ class TestChatViewSet: assert response.status_code == status.HTTP_200_OK assert len(response.data) >= 2 + def test_last_message_preview_for_attachment_and_poll( + self, + auth_client, + private_chat, + user1, + ): + image_message = Message.objects.create( + chat=private_chat, + author=user1, + content="", + has_attachments=True, + ) + MessageAttachment.objects.bulk_create([ + MessageAttachment( + message=image_message, + file="chat_attachments/test.jpg", + file_type="image", + file_name="test.jpg", + file_size=128, + mime_type="image/jpeg", + ) + ]) + + response = auth_client.get("/api/v1/communications/chats/") + assert response.status_code == status.HTTP_200_OK + chat_data = next( + item + for item in response.data["results"] + if item["id"] == private_chat.id + ) + assert chat_data["last_message"]["preview_text"] == "Фото" + assert chat_data["last_message"]["preview_type"] == "image" + + poll_message = Message.objects.create( + chat=private_chat, + author=user1, + content="", + ) + Poll.objects.create( + message=poll_message, + author=user1, + question="Куда пойдем?", + ) + + response = auth_client.get("/api/v1/communications/chats/") + assert response.status_code == status.HTTP_200_OK + chat_data = next( + item + for item in response.data["results"] + if item["id"] == private_chat.id + ) + assert chat_data["last_message"]["preview_text"] == "Голосование: Куда пойдем?" + assert chat_data["last_message"]["preview_type"] == "poll" + def test_list_chats_unauthenticated(self): """Список чатов без аутентификации - должен вернуть 403""" client = APIClient() diff --git a/frontend/src/app/messages-chatscope/page.tsx b/frontend/src/app/messages-chatscope/page.tsx index d21cbc4b..d07af515 100644 --- a/frontend/src/app/messages-chatscope/page.tsx +++ b/frontend/src/app/messages-chatscope/page.tsx @@ -326,7 +326,9 @@ export default function ChatscapeMessenger() { {filteredChats.map(chat => { const title = getChatTitle(chat, currentUserId); const avatar = getChatAvatar(chat, currentUserId); - const lastMessage = chat.last_message?.content || 'Нет сообщений'; + const lastMessage = chat.last_message?.preview_text + || chat.last_message?.content + || 'Нет сообщений'; const unreadCount = chat.unread_count || 0; return ( diff --git a/frontend/src/components/messages/CreateChatModal.tsx b/frontend/src/components/messages/CreateChatModal.tsx index 1616cfbf..0925a99d 100644 --- a/frontend/src/components/messages/CreateChatModal.tsx +++ b/frontend/src/components/messages/CreateChatModal.tsx @@ -3,7 +3,7 @@ import Image from "next/image"; import { useRouter } from "next/navigation"; import { useState, type ChangeEvent } from "react"; -import { Globe, Image as ImageIcon, Radio, Users, X } from "lucide-react"; +import { Globe, ImagePlus, Radio, Trash2, Users } from "lucide-react"; import { apiClient } from "@/lib/api"; import { Modal } from "@/components/ui"; @@ -20,6 +20,27 @@ type CreateChatPayload = { avatar?: File; }; +const chatTypes = [ + { + value: "group", + label: "Групповой", + description: "Общение участников", + icon: Users, + }, + { + value: "channel", + label: "Канал", + description: "Публикации для подписчиков", + icon: Radio, + }, + { + value: "global", + label: "Глобальный", + description: "Доступен всей компании", + icon: Globe, + }, +] as const; + export default function CreateChatModal({ isOpen, onClose }: CreateChatModalProps) { const router = useRouter(); const [chatType, setChatType] = useState<"group" | "channel" | "global">("group"); @@ -28,6 +49,7 @@ export default function CreateChatModal({ isOpen, onClose }: CreateChatModalProp const [avatar, setAvatar] = useState(null); const [avatarPreview, setAvatarPreview] = useState(null); const [creating, setCreating] = useState(false); + const [error, setError] = useState(null); const resetForm = () => { setChatType("group"); @@ -35,20 +57,23 @@ export default function CreateChatModal({ isOpen, onClose }: CreateChatModalProp setDescription(""); setAvatar(null); setAvatarPreview(null); + setError(null); }; const close = () => { if (creating) return; + resetForm(); onClose(); }; const handleCreate = async () => { if (creating) return; if (!name.trim()) { - alert("Введите название чата"); + setError("Введите название чата"); return; } + setError(null); setCreating(true); try { const payload: CreateChatPayload = { @@ -65,7 +90,7 @@ export default function CreateChatModal({ isOpen, onClose }: CreateChatModalProp } catch (error: unknown) { console.error("Ошибка создания чата:", error); const message = error instanceof Error ? error.message : String(error); - alert(`Не удалось создать чат: ${message}`); + setError(`Не удалось создать чат: ${message}`); } finally { setCreating(false); } @@ -75,10 +100,12 @@ export default function CreateChatModal({ isOpen, onClose }: CreateChatModalProp const file = event.target.files?.[0]; if (!file) return; if (!file.type.startsWith("image/")) { - alert("Можно загружать только изображения"); + setError("Для аватара можно загрузить только изображение"); + event.target.value = ""; return; } + setError(null); setAvatar(file); const reader = new FileReader(); reader.onloadend = () => setAvatarPreview(reader.result as string); @@ -90,87 +117,141 @@ export default function CreateChatModal({ isOpen, onClose }: CreateChatModalProp isOpen={isOpen} onClose={close} title="Создать чат" - size="sm" + size="md" closeOnEsc={!creating} + className="h-[100dvh] max-w-full rounded-none sm:h-auto sm:rounded-2xl" footer={( -
- -
)} > -
-
- -
- {([ - ["group", "Групповой", Users], - ["channel", "Канал", Radio], - ["global", "Глобальный", Globe], - ] as const).map(([value, label, Icon]) => ( - - ))} +
{ + event.preventDefault(); + void handleCreate(); + }} + > + {error && ( +

+ {error} +

+ )} + +
+
+ Тип чата +
+ {chatTypes.map(({ value, label, description: typeDescription, icon: Icon }) => ( + + ))} +
-
-
- - setName(event.target.value)} placeholder="Введите название чата..." className="app-input w-full rounded-lg px-3 py-2 text-sm" /> -
+
+ -
- -