fix(requests): исправлена отправка уведомлений при создании заявления

ПРОБЛЕМА: Уведомления не отправлялись при создании нового заявления

ПРИЧИНА: Сигнал post_save срабатывал ДО установки ManyToMany связей (recipients, cc_users)

РЕШЕНИЕ:
- Добавлен сигнал m2m_changed для отслеживания установки recipients/cc_users
- При создании заявления оно помечается как ожидающее отправки уведомлений
- После установки recipients через .set() срабатывает m2m_changed и отправляет уведомления
- Используется глобальный set _pending_new_requests для отслеживания

УЛУЧШЕНИЯ:
- Добавлено подробное логирование процесса создания и отправки уведомлений
- Разные эмодзи-иконки для разных типов получателей (� адресовано, � в копии, ✅ на согласование)
- Все типы используют notification_type='request_new' (существующий в БД)

Файлы:
- notification_signals.py: добавлены сигналы m2m_changed
- serializers.py: добавлено логирование создания и установки recipients
This commit is contained in:
Игорь Надеин 2025-12-29 11:56:56 +03:00
parent 209e1080f3
commit 002c1ffb55
4 changed files with 139 additions and 84 deletions

View File

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

View File

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

View File

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

View File

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