Corp/backend/calendar_app/notification_signals.py
Игорь Надеин 914d90736b feat: полностью асинхронная обработка уведомлений
- Добавлены Celery задачи для календаря, ленты и заявок
- Обновлены сигналы для запуска задач через transaction.on_commit
- Signal handlers теперь сразу возвращают управление
- Вся обработка (циклы, отправка) происходит в фоне

Аналогично сообщениям в чатах:
- process_event_notifications_task (календарь)
- process_post_notifications_task (лента)
- process_request_notifications_task (заявки)

Fixes: Блокировка транзакций при массовой отправке уведомлений
2026-01-09 14:48:43 +03:00

264 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Signals для автоматической генерации уведомлений в модуле Calendar.
Обрабатывает события:
- Новое событие календаря
- Изменение события
- Отмена события
Напоминания (за час, за день) будут реализованы через Celery tasks.
"""
from django.db.models.signals import post_save, pre_delete, pre_save
from django.dispatch import receiver
from django.contrib.auth import get_user_model
from .models import CalendarEvent
from notifications.services import NotificationService
Employee = get_user_model()
@receiver(post_save, sender=CalendarEvent)
def create_event_notifications(sender, instance, created, **kwargs):
"""
Создает уведомления при создании или изменении события асинхронно.
Уведомления отправляются:
- При создании - всем сотрудникам компании или отдела
- При изменении - участникам события
"""
from django.db import transaction
from notifications.tasks import process_event_notifications_task
def send_task():
"""Отложенная отправка задачи после commit транзакции"""
try:
if created:
# Новое событие
process_event_notifications_task.delay(
event_id=instance.id,
action='created'
)
else:
# Изменение события
changed_fields = getattr(instance, '_changed_fields', [])
if changed_fields:
process_event_notifications_task.delay(
event_id=instance.id,
action='updated',
changed_fields=changed_fields
)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.warning(
f"Failed to queue event notifications task: {e}, "
f"falling back to sync"
)
# Fallback на старую логику
if created:
notify_event_created(instance)
else:
if hasattr(instance, '_changed_fields'):
notify_event_changed(instance, instance._changed_fields)
transaction.on_commit(send_task)
@receiver(pre_save, sender=CalendarEvent)
def track_event_changes(sender, instance, **kwargs):
"""
Отслеживает изменения важных полей события для уведомлений.
"""
if instance.pk:
try:
old_event = CalendarEvent.objects.get(pk=instance.pk)
changed_fields = []
# Отслеживаем важные поля
important_fields = [
'title', 'start_date', 'end_date',
'start_time', 'end_time', 'location'
]
for field in important_fields:
old_value = getattr(old_event, field)
new_value = getattr(instance, field)
if old_value != new_value:
changed_fields.append(field)
instance._changed_fields = changed_fields
except CalendarEvent.DoesNotExist:
instance._changed_fields = []
@receiver(pre_delete, sender=CalendarEvent)
def notify_event_cancelled(sender, instance, **kwargs):
"""
Создает уведомления при удалении (отмене) события асинхронно.
"""
from notifications.tasks import process_event_notifications_task
try:
# Запускаем задачу синхронно, т.к. pre_delete выполняется до удаления
# и объект ещё существует в БД
process_event_notifications_task.apply_async(
args=[instance.id, 'cancelled'],
countdown=1 # Небольшая задержка чтобы удаление успело произойти
)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.warning(
f"Failed to queue event cancellation task: {e}, "
f"falling back to sync"
)
# Fallback на старую логику
recipients = get_event_recipients(instance)
for recipient in recipients:
NotificationService.create_notification(
recipient=recipient,
notification_type_code='event_cancelled',
title='Событие отменено',
message=(
f'Событие "{instance.title}" '
f'({instance.start_date.strftime("%d.%m.%Y")}) отменено'
),
action_url=get_calendar_url(instance),
metadata={
'event_title': instance.title,
'event_date': instance.start_date.isoformat(),
'department_id': (
instance.department.id if instance.department else None
),
'is_company_event': instance.is_company,
}
)
# ===== Вспомогательные функции =====
def notify_event_created(event):
"""
Отправляет уведомления о новом событии всем участникам.
"""
recipients = get_event_recipients(event)
# Исключаем создателя события
if event.created_by:
recipients = [r for r in recipients if r.id != event.created_by.id]
event_scope = (
f'отдела {event.department.name}'
if event.department
else 'компании'
)
for recipient in recipients:
NotificationService.create_notification_async(
recipient=recipient,
notification_type_code='event_created',
title='Новое событие в календаре',
message=(
f'Добавлено событие {event_scope}: "{event.title}" '
f'({event.start_date.strftime("%d.%m.%Y")})'
),
content_object=event,
action_url=get_calendar_url(event),
metadata={
'event_id': event.id,
'event_title': event.title,
'event_date': event.start_date.isoformat(),
'department_id': (
event.department.id if event.department else None
),
'is_company_event': event.is_company,
'created_by_id': (
event.created_by.id if event.created_by else None
),
}
)
def notify_event_changed(event, changed_fields):
"""
Отправляет уведомления об изменении события.
"""
if not changed_fields:
return
recipients = get_event_recipients(event)
# Формируем сообщение об изменениях
field_names = {
'title': 'название',
'start_date': 'дата начала',
'end_date': 'дата окончания',
'start_time': 'время начала',
'end_time': 'время окончания',
'location': 'место проведения',
}
changes_text = ', '.join([
field_names.get(f, f) for f in changed_fields
])
for recipient in recipients:
NotificationService.create_notification_async(
recipient=recipient,
notification_type_code='event_changed',
title='Событие изменено',
message=(
f'Событие "{event.title}" изменено. '
f'Изменения: {changes_text}'
),
content_object=event,
action_url=get_calendar_url(event),
metadata={
'event_id': event.id,
'event_title': event.title,
'changed_fields': changed_fields,
'event_date': event.start_date.isoformat(),
'department_id': (
event.department.id if event.department else None
),
}
)
def get_event_recipients(event):
"""
Возвращает список получателей уведомлений для события.
- Для события компании - все активные сотрудники
- Для события отдела - сотрудники этого отдела
- Для личного события - только владелец
"""
if event.is_personal:
# Личное событие - только владелец
return [event.employee] if event.employee else []
elif event.is_company:
# Событие компании - все активные сотрудники
return list(Employee.objects.filter(is_active=True))
elif event.department:
# Событие отдела - сотрудники через EmployeeDepartment
from employees.models import EmployeeDepartment
employee_ids = EmployeeDepartment.objects.filter(
department=event.department,
is_active=True
).values_list('employee_id', flat=True)
return list(Employee.objects.filter(id__in=employee_ids, is_active=True))
return []
def get_calendar_url(event):
"""
Возвращает URL календаря в зависимости от типа события.
Для всех типов событий используем параметр event_id для прямого открытия.
"""
# Возвращаем главную страницу календаря с параметром event_id
# Это позволит открыть модал события автоматически
return f'/?event_id={event.id}'