Расширен модал создания чатов: добавлены все поля модели (аватар, отдел, include_all_employees, is_main) + автосжатие аватаров чатов

This commit is contained in:
admin i 2025-11-30 11:18:59 +03:00
parent 61070160c8
commit 0a85b25d27
4 changed files with 385 additions and 42 deletions

View File

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

View File

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

View File

@ -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, а не менеджер

View File

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