From cdd26c91fe4c0255fb3cc6ec707642d5f5352446 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Thu, 27 Nov 2025 18:12:09 +0300 Subject: [PATCH] =?UTF-8?q?Feature:=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B0=20=D0=BE=D1=82=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BA=D0=B0=20=D1=84=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B2=20=D1=87=D0=B0=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Добавлен API endpoint /communications/api/upload-message/ для загрузки сообщений с файлами - Обновлён consumers.py: добавлена сериализация вложений в serialize_message() - Поддержка 4 типов файлов: image, video, audio, file - Автоопределение типа по MIME Frontend: - Обновлён chat_detail.html: отправка файлов через fetch API вместо alert - Добавлено отображение вложений в шаблоне (изображения, аудио, видео, документы) - Обновлён chatMessageTemplates.js: рендеринг вложений для WebSocket сообщений - Добавлена функция formatFileSize() для отображения размера файлов Модель MessageAttachment уже существовала, миграции применены --- backend/communications/api_views.py | 105 +++++++++++++++++- backend/communications/consumers.py | 14 +++ backend/communications/urls.py | 5 + .../js/components/chatMessageTemplates.js | 60 +++++++++- .../templates/communications/chat_detail.html | 87 +++++++++++---- 5 files changed, 249 insertions(+), 22 deletions(-) diff --git a/backend/communications/api_views.py b/backend/communications/api_views.py index db4506fb..4e0d0772 100644 --- a/backend/communications/api_views.py +++ b/backend/communications/api_views.py @@ -307,16 +307,117 @@ def pin_message(request, message_id): # ============ ATTACHMENTS ============ +@login_required +@require_POST +def upload_message_with_attachments(request): + """Отправка сообщения с вложениями""" + chat_id = request.POST.get('chat_id') + content = request.POST.get('content', '').strip() + + if not chat_id: + return JsonResponse( + {'ok': False, 'error': 'chat_id required'}, + status=400 + ) + + chat = get_object_or_404(Chat, pk=chat_id) + + # Проверка доступа к чату + membership = ChatMembership.objects.filter( + chat=chat, + user=request.user, + is_active=True, + can_send_messages=True + ).first() + + if not membership: + return JsonResponse( + {'ok': False, 'error': 'Cannot send messages to this chat'}, + status=403 + ) + + # Если нет ни текста, ни файлов + if not content and not request.FILES: + return JsonResponse( + {'ok': False, 'error': 'Content or files required'}, + status=400 + ) + + with transaction.atomic(): + # Создаем сообщение + message = Message.objects.create( + chat=chat, + author=request.user, + content=content or '', + has_attachments=bool(request.FILES) + ) + + # Обрабатываем все загруженные файлы + attachments = [] + for file_key in request.FILES: + uploaded_file = request.FILES[file_key] + + # Определение типа файла + mime_type = uploaded_file.content_type or '' + if mime_type.startswith('image/'): + file_type = 'image' + elif mime_type.startswith('video/'): + file_type = 'video' + elif mime_type.startswith('audio/'): + file_type = 'audio' + else: + file_type = 'file' + + attachment = MessageAttachment.objects.create( + message=message, + file=uploaded_file, + file_type=file_type, + file_name=uploaded_file.name, + file_size=uploaded_file.size, + mime_type=mime_type + ) + + attachments.append({ + 'id': attachment.id, + 'file_name': attachment.file_name, + 'file_type': attachment.file_type, + 'file_url': attachment.file.url, + 'file_size': attachment.file_size, + 'mime_type': attachment.mime_type + }) + + avatar_url = None + if hasattr(request.user, 'avatar') and request.user.avatar: + avatar_url = request.user.avatar.url + + return JsonResponse({ + 'ok': True, + 'message_id': message.id, + 'chat_id': chat.id, + 'author': { + 'id': request.user.id, + 'full_name': request.user.get_full_name(), + 'avatar': avatar_url + }, + 'content': message.content, + 'created_at': message.created_at.isoformat(), + 'attachments': attachments + }) + + @login_required @require_POST def upload_attachment(request): - """Загрузка вложения""" + """Загрузка вложения к существующему сообщению""" if 'file' not in request.FILES: return JsonResponse({'ok': False, 'error': 'No file'}, status=400) message_id = request.POST.get('message_id') if not message_id: - return JsonResponse({'ok': False, 'error': 'message_id required'}, status=400) + return JsonResponse( + {'ok': False, 'error': 'message_id required'}, + status=400 + ) message = get_object_or_404(Message, pk=message_id) diff --git a/backend/communications/consumers.py b/backend/communications/consumers.py index 232aa2cc..06cb9515 100644 --- a/backend/communications/consumers.py +++ b/backend/communications/consumers.py @@ -42,6 +42,20 @@ def serialize_message(m: Message) -> dict: "has_attachments": m.has_attachments, } + # Вложения + if m.has_attachments: + 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, + }) + data["attachments"] = attachments + # Ответ на сообщение if m.reply_to_id: data["reply_to"] = { diff --git a/backend/communications/urls.py b/backend/communications/urls.py index 41d3ea06..3d01c05f 100644 --- a/backend/communications/urls.py +++ b/backend/communications/urls.py @@ -69,6 +69,11 @@ urlpatterns = [ ), # Attachments + path( + "api/upload-message/", + api_views.upload_message_with_attachments, + name="api_upload_message" + ), path( "api/attachment/upload/", api_views.upload_attachment, diff --git a/backend/static/js/components/chatMessageTemplates.js b/backend/static/js/components/chatMessageTemplates.js index d83ef46a..e27f7623 100644 --- a/backend/static/js/components/chatMessageTemplates.js +++ b/backend/static/js/components/chatMessageTemplates.js @@ -80,6 +80,17 @@ export function nameHTML(userId, name, url, meId, profileUrl, detailUrlTemplate) return `${esc(name || 'Сотрудник')}`; } +/** + * Форматирует размер файла + * @param {number} bytes - Размер в байтах + * @returns {string} Размер в человекочитаемом формате + */ +export function formatFileSize(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; +} + /** * Создаёт разделитель дня * @param {string} dateStr - Дата в текстовом формате @@ -141,6 +152,52 @@ export function createMessageElement(msg, options = {}) { ); const time = formatTime(ts); const text = esc(msg.content || '').replace(/\n/g, '
'); + + // Формирование вложений + let attachmentsHTML = ''; + if (msg.has_attachments && msg.attachments && msg.attachments.length > 0) { + attachmentsHTML = '
'; + msg.attachments.forEach(att => { + attachmentsHTML += '
'; + + if (att.file_type === 'image') { + attachmentsHTML += ` + + ${esc(att.file_name)} + `; + } else if (att.file_type === 'audio') { + attachmentsHTML += ` + +
${esc(att.file_name)}
`; + } else if (att.file_type === 'video') { + attachmentsHTML += ` + +
${esc(att.file_name)}
`; + } else { + const fileSize = att.file_size ? formatFileSize(att.file_size) : ''; + attachmentsHTML += ` + + +
+
${esc(att.file_name)}
+
${fileSize}
+
+
`; + } + + attachmentsHTML += '
'; + }); + attachmentsHTML += '
'; + } const bubble = `
@@ -148,7 +205,8 @@ export function createMessageElement(msg, options = {}) { ${who} · ${time}
- ${text} + ${text ? text : ''} + ${attachmentsHTML}
`; diff --git a/backend/templates/communications/chat_detail.html b/backend/templates/communications/chat_detail.html index 8450e46f..af55e7e1 100644 --- a/backend/templates/communications/chat_detail.html +++ b/backend/templates/communications/chat_detail.html @@ -86,7 +86,46 @@ ·
- {{ message.content|linebreaksbr }} + {% if message.content %} + {{ message.content|linebreaksbr }} + {% endif %} + + {% if message.has_attachments %} +
+ {% for attachment in message.attachments.all %} +
+ {% if attachment.file_type == 'image' %} + + {{ attachment.file_name }} + + {% elif attachment.file_type == 'audio' %} + +
{{ attachment.file_name }}
+ {% elif attachment.file_type == 'video' %} + +
{{ attachment.file_name }}
+ {% else %} + + +
+
{{ attachment.file_name }}
+
{{ attachment.file_size|filesizeformat }}
+
+
+ {% endif %} +
+ {% endfor %} +
+ {% endif %}
@@ -302,29 +341,39 @@ document.addEventListener('DOMContentLoaded', function() { formData.append(`file_${index}`, file); }); + formData.append('chat_id', '{{ chat.id }}'); + try { - // TODO: Реализовать API endpoint для загрузки файлов - // const response = await fetch('/communications/api/upload-message/', { - // method: 'POST', - // body: formData, - // headers: { - // 'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value - // } - // }); + const response = await fetch('/communications/api/upload-message/', { + method: 'POST', + body: formData, + headers: { + 'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value + } + }); - console.log('Отправка файлов пока не реализована на бэкенде'); - alert('Отправка файлов будет реализована в следующем обновлении'); + const result = await response.json(); - // Очищаем форму - textarea.value = ''; - selectedFiles = []; - updatePreview(); - documentInput.value = ''; - imageInput.value = ''; - cameraInput.value = ''; - audioInput.value = ''; + if (result.ok) { + console.log('Сообщение с файлами отправлено:', result); + + // Очищаем форму + textarea.value = ''; + selectedFiles = []; + updatePreview(); + documentInput.value = ''; + imageInput.value = ''; + cameraInput.value = ''; + audioInput.value = ''; + + // Отображаем сообщение через WebSocket будет автоматически + } else { + console.error('Ошибка отправки:', result.error); + alert('Ошибка при отправке файлов: ' + result.error); + } } catch (error) { console.error('Ошибка отправки файлов:', error); + alert('Ошибка сети при отправке файлов'); } } else if (content) { // Отправляем только текст (стандартное поведение формы)