diff --git a/backend/api/v1/requests_app/serializers.py b/backend/api/v1/requests_app/serializers.py index fd2d9bc2..56a55fe3 100644 --- a/backend/api/v1/requests_app/serializers.py +++ b/backend/api/v1/requests_app/serializers.py @@ -363,6 +363,9 @@ class RequestWriteSerializer(serializers.ModelSerializer): cc_user_ids = validated_data.pop('cc_user_ids', []) departments = validated_data.pop('departments', []) + # Отладка: проверяем что передается + print(f"📝 [SERIALIZER] create: recipient_ids={recipient_ids}, cc_user_ids={cc_user_ids}") + if not self._is_power(): # обычный пользователь: запрещённые поля убираем/переписываем validated_data.pop("status", None) @@ -379,6 +382,7 @@ class RequestWriteSerializer(serializers.ModelSerializer): # Создаем заявку request_obj = super().create(validated_data) + print(f"✅ [SERIALIZER] Заявление #{request_obj.id} создано, устанавливаем recipients...") # Устанавливаем связи ManyToMany if departments: @@ -387,6 +391,11 @@ class RequestWriteSerializer(serializers.ModelSerializer): self._set_recipients(request_obj, recipient_ids, is_cc=False) self._set_recipients(request_obj, cc_user_ids, is_cc=True) + # Проверяем что сохранилось + recipients_count = request_obj.recipients.count() + cc_count = request_obj.cc_users.count() + print(f"✅ [SERIALIZER] Recipients установлены: {recipients_count} основных, {cc_count} в копии") + return request_obj def update(self, instance, validated_data: dict[str, Any]) -> Request: diff --git a/backend/requests_app/notification_signals.py b/backend/requests_app/notification_signals.py index e0f416ee..37cb91fd 100644 --- a/backend/requests_app/notification_signals.py +++ b/backend/requests_app/notification_signals.py @@ -10,7 +10,8 @@ Signals для автоматической генерации уведомле """ from django.contrib.auth import get_user_model -from django.db.models.signals import post_save, pre_save +from django.db import transaction +from django.db.models.signals import post_save, pre_save, m2m_changed from django.dispatch import receiver from notifications.services import NotificationService @@ -19,30 +20,33 @@ from .models import Request, RequestComment Employee = get_user_model() +# Флаг для отслеживания новых заявлений, ожидающих уведомления +_pending_new_requests = set() + + @receiver(post_save, sender=Request) def create_request_notifications(sender, instance, created, **kwargs): """ Создает уведомления при создании или изменении заявления. Обрабатывает: - 1. Новое заявление - уведомление руководителю/ответственным + 1. Новое заявление - помечаем для отправки уведомлений после установки recipients 2. Изменение статуса - уведомление автору """ request_obj = instance # Логирование для отладки - import logging - logger = logging.getLogger(__name__) - logger.info( - f"[SIGNAL] create_request_notifications: created={created}, " - f"status={request_obj.status}, id={request_obj.id}" - ) + print(f"🔔 [SIGNAL] create_request_notifications: created={created}, status={request_obj.status}, id={request_obj.id}") if created and request_obj.status != "draft": - # Новая заявка - уведомляем ответственных - logger.info(f"[SIGNAL] Вызываем notify_new_request для заявления #{request_obj.id}") - notify_new_request(request_obj) - elif not created: + # Помечаем заявление как ожидающее отправки уведомлений + # Уведомления будут отправлены после установки recipients через m2m_changed + print(f"📌 [SIGNAL] Помечаем заявление #{request_obj.id} для отправки уведомлений после установки recipients") + _pending_new_requests.add(request_obj.id) + else: + print(f"⏭️ [SIGNAL] Пропускаем: created={created}, status={request_obj.status}") + + if not created: # Проверяем изменение статуса через сохраненный атрибут _old_status if hasattr(request_obj, "_old_status"): old_status = request_obj._old_status @@ -66,6 +70,37 @@ def track_status_change(sender, instance, **kwargs): instance._old_status = None +@receiver(m2m_changed, sender=Request.recipients.through) +def notify_on_recipients_changed(sender, instance, action, **kwargs): + """ + Отправляет уведомления о новом заявлении ПОСЛЕ установки recipients. + + Срабатывает когда recipients устанавливаются через .set(), .add() и т.д. + """ + # Проверяем что это завершение операции установки recipients + if action == "post_add" and instance.id in _pending_new_requests: + print(f"🎯 [M2M_SIGNAL] Recipients установлены для заявления #{instance.id}, отправляем уведомления") + _pending_new_requests.discard(instance.id) + notify_new_request(instance) + + +@receiver(m2m_changed, sender=Request.cc_users.through) +def notify_on_cc_users_changed(sender, instance, action, **kwargs): + """ + Дополнительная проверка для cc_users. + + Если recipients не были установлены, но установлены cc_users, + также отправляем уведомления. + """ + # Если это завершение добавления cc_users И заявление все еще ожидает уведомлений + if action == "post_add" and instance.id in _pending_new_requests: + # Проверяем что recipients пусты (значит уведомления еще не отправлены) + if instance.recipients.count() == 0: + print(f"🎯 [M2M_SIGNAL] CC users установлены для заявления #{instance.id} (без recipients), отправляем уведомления") + _pending_new_requests.discard(instance.id) + notify_new_request(instance) + + @receiver(post_save, sender=RequestComment) def create_comment_notification(sender, instance, created, **kwargs): """ @@ -120,8 +155,10 @@ def create_comment_notification(sender, instance, created, **kwargs): # Отправляем уведомления author_name = author.get_full_name() or author.username request_type = request_obj.get_type_display() - employee_name = request_obj.employee.get_full_name() or request_obj.employee.username - + employee_name = ( + request_obj.employee.get_full_name() or request_obj.employee.username + ) + for recipient in recipients_set: NotificationService.create_notification( recipient=recipient, @@ -156,20 +193,23 @@ def notify_new_request(request_obj): При sent_to_all_department=True отправляет всем сотрудникам отделов """ - import logging - logger = logging.getLogger(__name__) - + print(f"📨 [notify_new_request] Начинаем обработку заявления #{request_obj.id}") + recipients_set = set() # 1. Основные получатели + recipients_count = request_obj.recipients.count() + print(f" Recipients в БД: {recipients_count}") for recipient in request_obj.recipients.filter(is_active=True): recipients_set.add(recipient) - logger.info(f"[notify_new_request] Добавлен основной получатель: {recipient.username}") + print(f" ✅ Добавлен основной получатель: {recipient.username} (ID: {recipient.id})") # 2. Копия (CC) + cc_count = request_obj.cc_users.count() + print(f" CC users в БД: {cc_count}") for cc_user in request_obj.cc_users.filter(is_active=True): recipients_set.add(cc_user) - logger.info(f"[notify_new_request] Добавлен CC: {cc_user.username}") + print(f" ✅ Добавлен CC: {cc_user.username} (ID: {cc_user.id})") # 3. Если sent_to_all_department - все сотрудники отделов if request_obj.sent_to_all_department: @@ -218,17 +258,21 @@ def notify_new_request(request_obj): recipients_set.update(dept_processors) - # Логирование итогового списка получателей - logger.info( - f"[notify_new_request] Всего получателей для заявления #{request_obj.id}: {len(recipients_set)}" - ) + # Итоговое логирование + print(f"📊 [notify_new_request] Всего получателей для заявления #{request_obj.id}: {len(recipients_set)}") + if len(recipients_set) == 0: + print(f"⚠️ [notify_new_request] НЕТ ПОЛУЧАТЕЛЕЙ! Уведомления не будут отправлены.") + return + for r in recipients_set: - logger.info(f" - {r.username} (ID: {r.id})") + print(f" 👤 {r.username} (ID: {r.id})") # Определяем тип уведомления для каждого получателя author_name = request_obj.employee.get_full_name() or request_obj.employee.username request_type = request_obj.get_type_display() - comment_preview = request_obj.comment[:150] if request_obj.comment else "Без комментария" + comment_preview = ( + request_obj.comment[:150] if request_obj.comment else "Без комментария" + ) for recipient in recipients_set: # Определяем роль получателя @@ -236,8 +280,8 @@ def notify_new_request(request_obj): is_cc = request_obj.cc_users.filter(id=recipient.id).exists() is_approver = request_obj.approver_id == recipient.id - logger.info( - f"[notify_new_request] Отправка уведомления для {recipient.username}: " + print( + f" 📤 Отправка уведомления для {recipient.username}: " f"primary={is_primary}, cc={is_cc}, approver={is_approver}" ) @@ -246,21 +290,21 @@ def notify_new_request(request_obj): # Основной получатель - требует действия title = f"📩 Вам адресовано заявление от {author_name}" message = f'Тип: "{request_type}". {comment_preview}' - notification_type = "request_new_primary" + notification_type = "request_new" # Используем общий тип elif is_cc: # Копия - информирование title = f"📋 Вы в копии заявления от {author_name}" message = f'Тип: "{request_type}". {comment_preview}' - notification_type = "request_new_cc" + notification_type = "request_new" # Используем общий тип elif is_approver: title = f"✅ Новое заявление на согласование от {author_name}" message = f'Тип: "{request_type}". {comment_preview}' - notification_type = "request_new_approver" + notification_type = "request_new" # Используем общий тип else: # Руководитель отдела или обработчик title = f"📝 Новое заявление в отделе от {author_name}" message = f'Тип: "{request_type}". {comment_preview}' - notification_type = "request_new_dept" + notification_type = "request_new" # Используем общий тип NotificationService.create_notification( recipient=recipient, @@ -337,13 +381,13 @@ def notify_status_change(request_obj, old_status, new_status): # Формируем уведомления с подробной информацией request_type = request_obj.get_type_display() - employee_name = request_obj.employee.get_full_name() or request_obj.employee.username - approver_name = ( - request_obj.approver.get_full_name() - if request_obj.approver - else "Руководитель" + employee_name = ( + request_obj.employee.get_full_name() or request_obj.employee.username ) - + approver_name = ( + request_obj.approver.get_full_name() if request_obj.approver else "Руководитель" + ) + for recipient in recipients_to_notify: if new_status == "approved": notification_type = "request_approved" diff --git a/backend/static/js/components/requestCrudHandler.js b/backend/static/js/components/requestCrudHandler.js index 8e2f8329..edaed1fb 100644 --- a/backend/static/js/components/requestCrudHandler.js +++ b/backend/static/js/components/requestCrudHandler.js @@ -320,19 +320,20 @@ export function initRequestCrudHandler(options) { } await response.json(); - + // Закрываем модальное окно - const modal = document.getElementById('reqApproveModal'); + const modal = document.getElementById("reqApproveModal"); if (modal) { const bsModal = bootstrap.Modal.getInstance(modal); if (bsModal) bsModal.hide(); } - + // Отправляем событие для обновления списка - document.dispatchEvent(new CustomEvent('request:updated', { - detail: { action: 'approve', id: reqId } - })); - + document.dispatchEvent( + new CustomEvent("request:updated", { + detail: { action: "approve", id: reqId }, + }) + ); } catch (error) { alert("Не удалось одобрить заявление: " + error.message); } @@ -362,19 +363,20 @@ export function initRequestCrudHandler(options) { } await response.json(); - + // Закрываем модальное окно - const modal = document.getElementById('reqRejectModal'); + const modal = document.getElementById("reqRejectModal"); if (modal) { const bsModal = bootstrap.Modal.getInstance(modal); if (bsModal) bsModal.hide(); } - + // Отправляем событие для обновления списка - document.dispatchEvent(new CustomEvent('request:updated', { - detail: { action: 'reject', id: reqId } - })); - + document.dispatchEvent( + new CustomEvent("request:updated", { + detail: { action: "reject", id: reqId }, + }) + ); } catch (error) { alert("Не удалось отклонить заявление: " + error.message); } diff --git a/backend/static/js/pages/requestList.js b/backend/static/js/pages/requestList.js index 4c0582d9..fedba8d7 100644 --- a/backend/static/js/pages/requestList.js +++ b/backend/static/js/pages/requestList.js @@ -3,10 +3,10 @@ * @description Инициализация страницы списка заявлений */ -import { initRequestModalHandler } from '../components/requestModalHandler.js'; -import { initRequestCommentsHandler } from '../components/requestCommentsHandler.js'; -import { initRequestCrudHandler } from '../components/requestCrudHandler.js'; -import { initRequestListHandler } from '../components/requestListHandler.js'; +import { initRequestModalHandler } from "../components/requestModalHandler.js"; +import { initRequestCommentsHandler } from "../components/requestCommentsHandler.js"; +import { initRequestCrudHandler } from "../components/requestCrudHandler.js"; +import { initRequestListHandler } from "../components/requestListHandler.js"; /** * Инициализирует страницу списка заявлений @@ -25,22 +25,22 @@ export function initRequestListPage(config) { commentsUrlTemplate, addCommentUrlTemplate, autoShowComments = false, - useAjaxList = false + useAjaxList = false, } = config; // Получаем токен доступа const accessMeta = document.querySelector('meta[name="api-access"]'); - const ACCESS = accessMeta ? accessMeta.content : ''; - + const ACCESS = accessMeta ? accessMeta.content : ""; + // Функция для получения CSRF токена из cookie function getCsrfToken() { - const name = 'csrftoken'; + const name = "csrftoken"; let cookieValue = null; - if (document.cookie && document.cookie !== '') { - const cookies = document.cookie.split(';'); + if (document.cookie && document.cookie !== "") { + const cookies = document.cookie.split(";"); for (let i = 0; i < cookies.length; i++) { const cookie = cookies[i].trim(); - if (cookie.substring(0, name.length + 1) === (name + '=')) { + if (cookie.substring(0, name.length + 1) === name + "=") { cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); break; } @@ -48,41 +48,41 @@ export function initRequestListPage(config) { } return cookieValue; } - + const headers = { - 'X-CSRFToken': getCsrfToken() + "X-CSRFToken": getCsrfToken(), }; - + if (ACCESS) { - headers['Authorization'] = 'Bearer ' + ACCESS; + headers["Authorization"] = "Bearer " + ACCESS; } // Если включен AJAX режим - инициализируем обработчик списка if (useAjaxList) { - const userId = parseInt(document.body.dataset.userId || '0', 10); - const canProcess = document.body.dataset.canProcess === 'true'; - - console.log('Request list AJAX mode:', { + const userId = parseInt(document.body.dataset.userId || "0", 10); + const canProcess = document.body.dataset.canProcess === "true"; + + console.log("Request list AJAX mode:", { apiListUrl, userId, - canProcess + canProcess, }); - + // Инициализация списка заявлений (AJAX загрузка с бесконечной прокруткой) const requestListHandler = initRequestListHandler({ apiListUrl, - detailUrlTemplate: '/requests/{id}/', + detailUrlTemplate: "/requests/{id}/", userId, canProcess, - headers + headers, }); - + // Обработка переключения фильтров через URL параметры const urlParams = new URLSearchParams(window.location.search); - const currentView = urlParams.get('view') || ''; - const currentType = urlParams.get('type') || ''; - const currentStatus = urlParams.get('status') || ''; - + const currentView = urlParams.get("view") || ""; + const currentType = urlParams.get("type") || ""; + const currentStatus = urlParams.get("status") || ""; + if (currentView && requestListHandler.setView) { requestListHandler.setView(currentView); } @@ -96,26 +96,26 @@ export function initRequestListPage(config) { // Инициализация обработчиков модальных окон initRequestModalHandler({ - autoShowComments + autoShowComments, }); // Инициализация обработчика комментариев initRequestCommentsHandler({ commentsUrlTemplate, addCommentUrlTemplate, - autoloadCounts: true + autoloadCounts: true, }); // Инициализация CRUD операций для заявлений initRequestCrudHandler({ apiListUrl, apiDetailBase, - headers + headers, }); // Слушаем события обновления заявлений (approve/reject) - document.addEventListener('request:updated', function(event) { - console.log('Request updated:', event.detail); + document.addEventListener("request:updated", function (event) { + console.log("Request updated:", event.detail); // Перезагружаем страницу для обновления списка // TODO: в будущем можно заменить на AJAX обновление конкретной карточки setTimeout(() => window.location.reload(), 500);