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:
parent
e552be8547
commit
cdd26c91fe
@ -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)
|
||||
|
||||
|
||||
@ -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"] = {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>`;
|
||||
|
||||
|
||||
@ -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) {
|
||||
// Отправляем только текст (стандартное поведение формы)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user