Расширен модал создания чатов: добавлены все поля модели (аватар, отдел, include_all_employees, is_main) + автосжатие аватаров чатов
This commit is contained in:
parent
61070160c8
commit
0a85b25d27
@ -24,37 +24,121 @@ from .models import (
|
||||
@login_required
|
||||
@require_POST
|
||||
def create_chat(request):
|
||||
"""Создание нового чата (group, channel, announcement)"""
|
||||
"""
|
||||
Создание нового чата
|
||||
(group, department, channel, announcement, global)
|
||||
"""
|
||||
import json
|
||||
|
||||
# Читаем JSON из тела запроса
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({'ok': False, 'error': 'Invalid JSON'}, status=400)
|
||||
|
||||
chat_type = data.get('type', 'group')
|
||||
name = data.get('name', '').strip()
|
||||
description = data.get('description', '').strip()
|
||||
participant_ids = data.get('participant_ids', [])
|
||||
|
||||
if chat_type not in ['group', 'channel', 'announcement']:
|
||||
|
||||
# Читаем данные из FormData или JSON
|
||||
if request.content_type and 'multipart/form-data' in request.content_type:
|
||||
# FormData (с файлами)
|
||||
chat_type = request.POST.get('type', 'group')
|
||||
name = request.POST.get('name', '').strip()
|
||||
description = request.POST.get('description', '').strip()
|
||||
department_id = request.POST.get('department_id')
|
||||
include_all = request.POST.get('include_all_employees') == 'true'
|
||||
is_main = request.POST.get('is_main') == 'true'
|
||||
avatar = request.FILES.get('avatar')
|
||||
participant_ids = request.POST.getlist('participant_ids')
|
||||
else:
|
||||
# JSON (без файлов - legacy)
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse(
|
||||
{'ok': False, 'error': 'Invalid JSON'}, status=400
|
||||
)
|
||||
|
||||
chat_type = data.get('type', 'group')
|
||||
name = data.get('name', '').strip()
|
||||
description = data.get('description', '').strip()
|
||||
department_id = data.get('department_id')
|
||||
include_all = data.get('include_all_employees', False)
|
||||
is_main = data.get('is_main', False)
|
||||
avatar = None
|
||||
participant_ids = data.get('participant_ids', [])
|
||||
|
||||
# Валидация типа чата
|
||||
valid_types = ['group', 'department', 'channel', 'announcement', 'global']
|
||||
if chat_type not in valid_types:
|
||||
types_str = ", ".join(valid_types)
|
||||
return JsonResponse(
|
||||
{'ok': False, 'error': 'Invalid chat type'}, status=400
|
||||
{
|
||||
'ok': False,
|
||||
'error': f'Invalid chat type. Must be one of: {types_str}'
|
||||
},
|
||||
status=400
|
||||
)
|
||||
|
||||
if not name:
|
||||
|
||||
# Валидация названия
|
||||
if not name and chat_type != 'private':
|
||||
return JsonResponse(
|
||||
{'ok': False, 'error': 'Name is required'}, status=400
|
||||
)
|
||||
|
||||
|
||||
# Валидация для чата отдела
|
||||
if chat_type == 'department':
|
||||
if not department_id:
|
||||
return JsonResponse(
|
||||
{
|
||||
'ok': False,
|
||||
'error': 'Department is required for department chat'
|
||||
},
|
||||
status=400
|
||||
)
|
||||
try:
|
||||
from employees.models import Department
|
||||
department = Department.objects.get(id=department_id)
|
||||
except Department.DoesNotExist:
|
||||
return JsonResponse(
|
||||
{'ok': False, 'error': 'Department not found'},
|
||||
status=400
|
||||
)
|
||||
else:
|
||||
department = None
|
||||
|
||||
# Валидация для основного чата
|
||||
if is_main:
|
||||
if chat_type == 'global':
|
||||
# Проверяем, нет ли уже основного глобального чата
|
||||
if Chat.objects.filter(type='global', is_main=True).exists():
|
||||
return JsonResponse(
|
||||
{'ok': False, 'error': 'Main global chat already exists'},
|
||||
status=400
|
||||
)
|
||||
elif chat_type == 'department' and department:
|
||||
# Проверяем, нет ли уже основного чата для этого отдела
|
||||
exists = Chat.objects.filter(
|
||||
type='department',
|
||||
department=department,
|
||||
is_main=True
|
||||
).exists()
|
||||
if exists:
|
||||
return JsonResponse(
|
||||
{
|
||||
'ok': False,
|
||||
'error': 'Main department chat already exists'
|
||||
},
|
||||
status=400
|
||||
)
|
||||
|
||||
# Создаем чат
|
||||
chat = Chat.objects.create(
|
||||
type=chat_type,
|
||||
name=name,
|
||||
description=description,
|
||||
created_by=request.user
|
||||
created_by=request.user,
|
||||
department=department,
|
||||
include_all_employees=include_all,
|
||||
is_main=is_main
|
||||
)
|
||||
|
||||
|
||||
# Добавляем аватар если был загружен
|
||||
if avatar:
|
||||
chat.avatar = avatar
|
||||
chat.save()
|
||||
|
||||
# Создатель - владелец с полными правами
|
||||
ChatMembership.objects.create(
|
||||
chat=chat,
|
||||
@ -65,7 +149,7 @@ def create_chat(request):
|
||||
can_remove_members=True,
|
||||
can_pin_messages=True
|
||||
)
|
||||
|
||||
|
||||
# Добавляем участников для группового чата
|
||||
if chat_type == 'group' and participant_ids:
|
||||
from employees.models import Employee
|
||||
@ -81,7 +165,43 @@ def create_chat(request):
|
||||
)
|
||||
except Employee.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
# Для чатов с include_all_employees - добавляем всех активных
|
||||
if include_all:
|
||||
from employees.models import Employee
|
||||
all_employees = Employee.objects.filter(is_active=True).exclude(
|
||||
id=request.user.id
|
||||
)
|
||||
for employee in all_employees:
|
||||
can_send = chat_type not in ['channel', 'announcement']
|
||||
ChatMembership.objects.get_or_create(
|
||||
chat=chat,
|
||||
user=employee,
|
||||
defaults={
|
||||
'role': 'member',
|
||||
'can_send_messages': can_send
|
||||
}
|
||||
)
|
||||
|
||||
# Для чата отдела - добавляем всех сотрудников отдела
|
||||
if chat_type == 'department' and department:
|
||||
from employees.models import EmployeeDepartment
|
||||
dept_employees = EmployeeDepartment.objects.filter(
|
||||
department=department,
|
||||
is_active=True
|
||||
).select_related('employee')
|
||||
|
||||
for ed in dept_employees:
|
||||
if ed.employee != request.user: # Создателя уже добавили
|
||||
ChatMembership.objects.get_or_create(
|
||||
chat=chat,
|
||||
user=ed.employee,
|
||||
defaults={
|
||||
'role': 'member',
|
||||
'can_send_messages': True
|
||||
}
|
||||
)
|
||||
|
||||
return JsonResponse({
|
||||
'ok': True,
|
||||
'chat_id': chat.id,
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
# backend\communications\signals.py
|
||||
from django.db.models.signals import post_save
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db.models.signals import post_save, pre_save
|
||||
from django.dispatch import receiver
|
||||
from employees.models import Department
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Department)
|
||||
@ -13,3 +17,58 @@ def create_main_department_chat(sender, instance, created, **kwargs):
|
||||
type="department", department=instance, is_main=True
|
||||
).exists():
|
||||
Chat.objects.create(type="department", department=instance, is_main=True)
|
||||
|
||||
|
||||
@receiver(pre_save, sender='communications.Chat')
|
||||
def compress_and_cleanup_chat_avatar(sender, instance, **kwargs):
|
||||
"""
|
||||
Сжимает новый аватар чата и удаляет старый файл
|
||||
"""
|
||||
if not instance.avatar:
|
||||
return
|
||||
|
||||
# Если это новый объект (нет pk), просто сжимаем
|
||||
if not instance.pk:
|
||||
try:
|
||||
from common.image_utils import compress_avatar
|
||||
compressed = compress_avatar(instance.avatar.read())
|
||||
if compressed:
|
||||
instance.avatar.save(
|
||||
instance.avatar.name,
|
||||
ContentFile(compressed),
|
||||
save=False
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error compressing new chat avatar: {e}")
|
||||
return
|
||||
|
||||
# Проверяем, изменился ли файл аватара
|
||||
try:
|
||||
old_instance = sender.objects.get(pk=instance.pk)
|
||||
except sender.DoesNotExist:
|
||||
return
|
||||
|
||||
old_avatar = old_instance.avatar
|
||||
new_avatar = instance.avatar
|
||||
|
||||
# Если путь изменился - это новый файл
|
||||
if old_avatar and new_avatar and old_avatar.name != new_avatar.name:
|
||||
try:
|
||||
# Сжимаем новый аватар
|
||||
from common.image_utils import compress_avatar
|
||||
compressed = compress_avatar(new_avatar.read())
|
||||
if compressed:
|
||||
new_avatar.save(
|
||||
new_avatar.name,
|
||||
ContentFile(compressed),
|
||||
save=False
|
||||
)
|
||||
|
||||
# Удаляем старый файл
|
||||
if old_avatar.name:
|
||||
old_avatar.delete(save=False)
|
||||
logger.info(
|
||||
f"Deleted old chat avatar: {old_avatar.name}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing chat avatar: {e}")
|
||||
|
||||
@ -101,6 +101,13 @@ class ChatListView(LoginRequiredMixin, ListView):
|
||||
template_name = "communications/chat_list.html"
|
||||
context_object_name = "chats"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
# Добавляем список отделов для выбора при создании чата
|
||||
from employees.models import Department
|
||||
context['departments'] = Department.objects.all().order_by('name')
|
||||
return context
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
# ВАЖНО: у Employee.departments это property, а не менеджер
|
||||
|
||||
@ -514,7 +514,7 @@
|
||||
|
||||
{# ===== МОДАЛКА СОЗДАНИЯ ЧАТА ===== #}
|
||||
<div class="modal fade" id="chatCreateModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Создать чат</h5>
|
||||
@ -524,30 +524,93 @@
|
||||
|
||||
{# Выбор типа чата #}
|
||||
<div class="mb-3">
|
||||
<label for="chatTypeSelect" class="form-label">Тип чата</label>
|
||||
<label for="chatTypeSelect" class="form-label">Тип чата <span class="text-danger">*</span></label>
|
||||
<select class="form-select" id="chatTypeSelect">
|
||||
<option value="private">Личный чат</option>
|
||||
<option value="group">Группа</option>
|
||||
<option value="department">Чат отдела</option>
|
||||
<option value="channel">Канал</option>
|
||||
<option value="announcement">Объявления</option>
|
||||
<option value="global">Глобальный чат</option>
|
||||
</select>
|
||||
<div class="form-text">
|
||||
<small>
|
||||
<strong>Личный:</strong> приватный чат с 1 сотрудником.<br>
|
||||
<strong>Группа:</strong> чат с несколькими участниками.<br>
|
||||
<strong>Отдел:</strong> автоматически включает всех сотрудников отдела.<br>
|
||||
<strong>Канал:</strong> для объявлений с ограниченной отправкой.<br>
|
||||
<strong>Объявления:</strong> односторонняя рассылка.<br>
|
||||
<strong>Глобальный:</strong> общий чат компании (может быть только 1 основной).
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Поля для группы/канала/объявлений #}
|
||||
{# Поля для группы/канала/объявлений/глобального #}
|
||||
<div id="groupFieldsSection" style="display:none;">
|
||||
<div class="mb-3">
|
||||
<label for="chatNameInput" class="form-label">Название чата <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="chatNameInput" placeholder="Введите название">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="chatDescInput" class="form-label">Описание</label>
|
||||
<textarea class="form-control" id="chatDescInput" rows="3" placeholder="Краткое описание чата"></textarea>
|
||||
</div>
|
||||
|
||||
{# Аватар чата #}
|
||||
<div class="mb-3">
|
||||
<label for="chatAvatarInput" class="form-label">Аватар чата</label>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div id="chatAvatarPreview" class="position-relative bg-light rounded-circle d-flex align-items-center justify-content-center" style="width: 80px; height: 80px;">
|
||||
<i class="bi bi-people-fill text-secondary" style="font-size: 2rem;"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<input type="file" class="form-control" id="chatAvatarInput" accept="image/jpeg,image/png,image/gif">
|
||||
<div class="form-text">JPG, PNG или GIF. Макс. 5 МБ. Будет сжато до 512x512px.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Выбор отдела (только для типа "department") #}
|
||||
<div id="departmentSection" style="display:none;">
|
||||
<div class="mb-3">
|
||||
<label for="chatDepartmentSelect" class="form-label">Отдел <span class="text-danger">*</span></label>
|
||||
<select class="form-select" id="chatDepartmentSelect">
|
||||
<option value="">-- Выберите отдел --</option>
|
||||
{% for dept in departments %}
|
||||
<option value="{{ dept.id }}">{{ dept.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">Все сотрудники выбранного отдела автоматически станут участниками чата.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Опция "Включить всех сотрудников" (для announcement/global/channel) #}
|
||||
<div id="includeAllSection" style="display:none;">
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="includeAllEmployees">
|
||||
<label class="form-check-label" for="includeAllEmployees">
|
||||
Включить всех сотрудников компании
|
||||
</label>
|
||||
<div class="form-text">Все активные сотрудники автоматически станут участниками чата.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Опция "Основной чат" (для global/department) #}
|
||||
<div id="mainChatSection" style="display:none;">
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="isMainChat">
|
||||
<label class="form-check-label" for="isMainChat">
|
||||
Сделать основным чатом
|
||||
</label>
|
||||
<div class="form-text">Основной чат отображается первым в списке. Может быть только 1 основной глобальный чат и 1 основной чат на отдел.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Выбор участников (для личного чата и группы) #}
|
||||
<div id="recipientSection">
|
||||
<label class="form-label">Выберите участников</label>
|
||||
<label class="form-label">Выберите участников <span id="recipientRequired" class="text-danger" style="display:none;">*</span></label>
|
||||
<div id="chatRecipients" class="recipient-picker" data-api-employees="{% url 'api:v1:employees-list' %}">
|
||||
<div class="rp-selected" aria-live="polite"></div>
|
||||
<div class="rp-search mb-2">
|
||||
@ -610,26 +673,84 @@
|
||||
// Переключение типа чата
|
||||
const chatTypeSelect = document.getElementById('chatTypeSelect');
|
||||
const recipientSection = document.getElementById('recipientSection');
|
||||
const recipientRequired = document.getElementById('recipientRequired');
|
||||
const groupFieldsSection = document.getElementById('groupFieldsSection');
|
||||
const departmentSection = document.getElementById('departmentSection');
|
||||
const includeAllSection = document.getElementById('includeAllSection');
|
||||
const mainChatSection = document.getElementById('mainChatSection');
|
||||
|
||||
chatTypeSelect?.addEventListener('change', function() {
|
||||
const type = this.value;
|
||||
|
||||
// Скрываем все секции по умолчанию
|
||||
recipientSection.style.display = 'none';
|
||||
recipientRequired.style.display = 'none';
|
||||
groupFieldsSection.style.display = 'none';
|
||||
departmentSection.style.display = 'none';
|
||||
includeAllSection.style.display = 'none';
|
||||
mainChatSection.style.display = 'none';
|
||||
|
||||
if(type === 'private') {
|
||||
// Личный чат: только выбор 1 участника
|
||||
recipientSection.style.display = 'block';
|
||||
groupFieldsSection.style.display = 'none';
|
||||
recipientRequired.style.display = 'inline';
|
||||
chatPicker.setMaxSelections(1);
|
||||
} else if(type === 'group') {
|
||||
// Группа: название + описание + аватар + участники
|
||||
recipientSection.style.display = 'block';
|
||||
groupFieldsSection.style.display = 'block';
|
||||
chatPicker.setMaxSelections(100);
|
||||
} else {
|
||||
// channel, announcement - только название/описание
|
||||
recipientSection.style.display = 'none';
|
||||
} else if(type === 'department') {
|
||||
// Чат отдела: название + описание + аватар + выбор отдела + опция "основной"
|
||||
groupFieldsSection.style.display = 'block';
|
||||
departmentSection.style.display = 'block';
|
||||
mainChatSection.style.display = 'block';
|
||||
} else if(type === 'global') {
|
||||
// Глобальный: название + описание + аватар + опция "включить всех" + опция "основной"
|
||||
groupFieldsSection.style.display = 'block';
|
||||
includeAllSection.style.display = 'block';
|
||||
mainChatSection.style.display = 'block';
|
||||
} else {
|
||||
// channel, announcement: название + описание + аватар + опция "включить всех"
|
||||
groupFieldsSection.style.display = 'block';
|
||||
includeAllSection.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
// Предпросмотр аватара чата
|
||||
const chatAvatarInput = document.getElementById('chatAvatarInput');
|
||||
const chatAvatarPreview = document.getElementById('chatAvatarPreview');
|
||||
|
||||
chatAvatarInput?.addEventListener('change', function(e) {
|
||||
const file = e.target.files[0];
|
||||
if(!file) {
|
||||
// Восстанавливаем иконку по умолчанию
|
||||
chatAvatarPreview.innerHTML = '<i class="bi bi-people-fill text-secondary" style="font-size: 2rem;"></i>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Валидация размера
|
||||
if(file.size > 5 * 1024 * 1024) {
|
||||
alert('Файл слишком большой. Максимум 5 МБ.');
|
||||
this.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Валидация типа
|
||||
if(!file.type.match(/^image\/(jpeg|png|gif)$/)) {
|
||||
alert('Разрешены только JPG, PNG или GIF.');
|
||||
this.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Показываем предпросмотр
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
chatAvatarPreview.innerHTML = `<img src="${e.target.result}" alt="Preview" class="rounded-circle" style="width: 100%; height: 100%; object-fit: cover;">`;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
// Функция получения CSRF токена
|
||||
function getCSRFToken() {
|
||||
// Сначала пробуем найти в скрытом поле формы
|
||||
@ -670,36 +791,72 @@
|
||||
const employeeId = selected[0].id;
|
||||
window.location.href = "{% url 'communications:start_private_chat' 0 %}".replace('/0/', `/${employeeId}/`);
|
||||
} else {
|
||||
// Создание group/channel/announcement через API
|
||||
// Создание group/department/channel/announcement/global через API
|
||||
const nameInput = document.getElementById('chatNameInput');
|
||||
const descInput = document.getElementById('chatDescInput');
|
||||
const departmentSelect = document.getElementById('chatDepartmentSelect');
|
||||
const includeAllCheckbox = document.getElementById('includeAllEmployees');
|
||||
const isMainCheckbox = document.getElementById('isMainChat');
|
||||
const avatarInput = document.getElementById('chatAvatarInput');
|
||||
|
||||
const name = nameInput?.value.trim() || '';
|
||||
const description = descInput?.value.trim() || '';
|
||||
|
||||
if(!name) {
|
||||
// Валидация обязательных полей
|
||||
if(!name && type !== 'private') {
|
||||
alert('Введите название чата');
|
||||
return;
|
||||
}
|
||||
|
||||
if(type === 'department') {
|
||||
const deptId = departmentSelect?.value;
|
||||
if(!deptId) {
|
||||
alert('Выберите отдел для чата отдела');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Собираем данные в FormData (для поддержки файлов)
|
||||
const formData = new FormData();
|
||||
formData.append('type', type);
|
||||
formData.append('name', name);
|
||||
formData.append('description', description);
|
||||
|
||||
// Добавляем аватар если выбран
|
||||
if(avatarInput?.files[0]) {
|
||||
formData.append('avatar', avatarInput.files[0]);
|
||||
}
|
||||
|
||||
// Добавляем department_id для типа "department"
|
||||
if(type === 'department' && departmentSelect?.value) {
|
||||
formData.append('department_id', departmentSelect.value);
|
||||
}
|
||||
|
||||
// Добавляем include_all_employees
|
||||
if(includeAllCheckbox?.checked) {
|
||||
formData.append('include_all_employees', 'true');
|
||||
}
|
||||
|
||||
// Добавляем is_main
|
||||
if(isMainCheckbox?.checked) {
|
||||
formData.append('is_main', 'true');
|
||||
}
|
||||
|
||||
// Собираем participant_ids для группового чата
|
||||
let participant_ids = [];
|
||||
if(type === 'group') {
|
||||
const selected = chatPicker.getSelected();
|
||||
participant_ids = selected.map(s => s.id);
|
||||
selected.forEach(s => {
|
||||
formData.append('participant_ids', s.id);
|
||||
});
|
||||
}
|
||||
|
||||
const res = await fetch('/communications/api/chat/create/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrftoken
|
||||
// НЕ устанавливаем Content-Type, браузер сам добавит multipart/form-data с boundary
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: type,
|
||||
name: name,
|
||||
description: description,
|
||||
participant_ids: participant_ids
|
||||
})
|
||||
body: formData
|
||||
});
|
||||
|
||||
if(res.ok) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user