Feature: реализована отправка файлов в чате

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 уже существовала, миграции применены
This commit is contained in:
admin i 2025-11-27 18:12:09 +03:00
parent e552be8547
commit cdd26c91fe
5 changed files with 249 additions and 22 deletions

View File

@ -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)

View File

@ -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"] = {

View File

@ -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,

View File

@ -80,6 +80,17 @@ export function nameHTML(userId, name, url, meId, profileUrl, detailUrlTemplate)
return `<a href="${detailUrl}" class="text-decoration-none fw-semibold">${esc(name || 'Сотрудник')}</a>`;
}
/**
* Форматирует размер файла
* @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, '<br>');
// Формирование вложений
let attachmentsHTML = '';
if (msg.has_attachments && msg.attachments && msg.attachments.length > 0) {
attachmentsHTML = '<div class="message-attachments mt-2">';
msg.attachments.forEach(att => {
attachmentsHTML += '<div class="attachment-item mb-2">';
if (att.file_type === 'image') {
attachmentsHTML += `
<a href="${att.file_url}" target="_blank">
<img src="${att.file_url}"
alt="${esc(att.file_name)}"
class="img-fluid rounded"
style="max-width: 300px; max-height: 300px;">
</a>`;
} else if (att.file_type === 'audio') {
attachmentsHTML += `
<audio controls class="w-100">
<source src="${att.file_url}" type="${att.mime_type}">
</audio>
<div class="small text-secondary">${esc(att.file_name)}</div>`;
} else if (att.file_type === 'video') {
attachmentsHTML += `
<video controls class="w-100" style="max-width: 400px;">
<source src="${att.file_url}" type="${att.mime_type}">
</video>
<div class="small text-secondary">${esc(att.file_name)}</div>`;
} else {
const fileSize = att.file_size ? formatFileSize(att.file_size) : '';
attachmentsHTML += `
<a href="${att.file_url}"
class="d-flex align-items-center text-decoration-none p-2 border rounded bg-light"
download="${esc(att.file_name)}">
<i class="bi-file-earmark fs-3 text-primary me-2"></i>
<div>
<div class="fw-semibold">${esc(att.file_name)}</div>
<div class="small text-secondary">${fileSize}</div>
</div>
</a>`;
}
attachmentsHTML += '</div>';
});
attachmentsHTML += '</div>';
}
const bubble = `
<div class="d-flex flex-column" style="max-width:80%;">
@ -148,7 +205,8 @@ export function createMessageElement(msg, options = {}) {
${who} · ${time}
</div>
<div class="mt-1 bubble ${mine ? 'bubble-me' : 'bubble-other'}">
${text}
${text ? text : ''}
${attachmentsHTML}
</div>
</div>`;

View File

@ -86,7 +86,46 @@
· <time datetime="{{ message.created_at|date:'c' }}">{{ message.created_at|date:"H:i" }}</time>
</div>
<div class="mt-1 bubble {% if message.author_id == user.id %}bubble-me{% else %}bubble-other{% endif %}">
{{ message.content|linebreaksbr }}
{% if message.content %}
{{ message.content|linebreaksbr }}
{% endif %}
{% if message.has_attachments %}
<div class="message-attachments mt-2">
{% for attachment in message.attachments.all %}
<div class="attachment-item mb-2">
{% if attachment.file_type == 'image' %}
<a href="{{ attachment.file.url }}" target="_blank">
<img src="{{ attachment.file.url }}"
alt="{{ attachment.file_name }}"
class="img-fluid rounded"
style="max-width: 300px; max-height: 300px;">
</a>
{% elif attachment.file_type == 'audio' %}
<audio controls class="w-100">
<source src="{{ attachment.file.url }}" type="{{ attachment.mime_type }}">
</audio>
<div class="small text-secondary">{{ attachment.file_name }}</div>
{% elif attachment.file_type == 'video' %}
<video controls class="w-100" style="max-width: 400px;">
<source src="{{ attachment.file.url }}" type="{{ attachment.mime_type }}">
</video>
<div class="small text-secondary">{{ attachment.file_name }}</div>
{% else %}
<a href="{{ attachment.file.url }}"
class="d-flex align-items-center text-decoration-none p-2 border rounded bg-light"
download="{{ attachment.file_name }}">
<i class="bi-file-earmark fs-3 text-primary me-2"></i>
<div>
<div class="fw-semibold">{{ attachment.file_name }}</div>
<div class="small text-secondary">{{ attachment.file_size|filesizeformat }}</div>
</div>
</a>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
@ -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) {
// Отправляем только текст (стандартное поведение формы)