Corp/backend/communications/notification_signals.py
Игорь Надеин 413fdd21aa fix: используем transaction.on_commit для Celery задач
Исправлена проблема race condition когда Celery задача process_message_notifications
запускалась до завершения транзакции БД, что приводило к ошибке 'message_not_found'.

Теперь задача запускается только после успешного commit, гарантируя что объект
уже доступен в базе данных для worker процесса.
2026-01-09 11:14:13 +03:00

326 lines
13 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 для автоматической генерации уведомлений в модуле Communications.
Обрабатывает события:
- Новое сообщение в чате
- Упоминание (@username) в сообщении
- Ответ на сообщение
- Добавление в чат
"""
import re
from django.db import transaction
from django.db.models.signals import post_save, m2m_changed
from django.dispatch import receiver
from django.contrib.auth import get_user_model
from .models import Message, Chat, ChatUserSettings
from notifications.services import NotificationService
Employee = get_user_model()
def should_send_notification(chat, user):
"""
Проверяет, нужно ли отправлять уведомление пользователю для данного чата.
Args:
chat: Chat объект
user: Employee объект
Returns:
bool: True если нужно отправить уведомление, False если пользователь отключил уведомления
"""
try:
settings = ChatUserSettings.objects.get(chat=chat, user=user)
return settings.notifications_enabled
except ChatUserSettings.DoesNotExist:
# Если настройки не найдены, считаем что уведомления включены по умолчанию
return True
def get_users_with_notifications_enabled(chat, users):
"""
Получает словарь пользователей с их настройками уведомлений.
Оптимизированная версия - один запрос к БД вместо N.
Args:
chat: Chat объект
users: QuerySet или список пользователей
Returns:
dict: {user_id: notifications_enabled}
"""
if not users:
return {}
user_ids = [u.id for u in users]
# Один запрос для всех пользователей
settings = ChatUserSettings.objects.filter(
chat=chat,
user_id__in=user_ids
).values('user_id', 'notifications_enabled')
# Создаем словарь для быстрого доступа
settings_dict = {s['user_id']: s['notifications_enabled'] for s in settings}
# Для пользователей без настроек - по умолчанию True
return {uid: settings_dict.get(uid, True) for uid in user_ids}
@receiver(post_save, sender=Message)
def create_message_notifications(sender, instance, created, **kwargs):
"""
Создает уведомления при создании нового сообщения.
ОПТИМИЗАЦИЯ: Вся логика обработки вынесена в асинхронную Celery задачу,
чтобы не блокировать сохранение сообщения.
Обрабатывает:
1. Новое сообщение в чате (для всех участников кроме автора)
2. Упоминания (@username) в тексте
3. Ответ на сообщение (reply_to)
"""
if not created or instance.is_system or instance.is_deleted:
return
# Запускаем ВСЮ обработку асинхронно в одной задаче
# Signal handler сразу возвращает управление
from notifications.tasks import process_message_notifications_task
def send_task():
"""Отложенная отправка задачи после commit транзакции"""
try:
# Запускаем асинхронно через Celery
process_message_notifications_task.delay(instance.id)
except Exception as e:
# Если Celery недоступен - fallback на синхронную обработку
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Celery unavailable, using sync fallback: {e}")
_create_message_notifications_sync(instance)
# ВАЖНО: Запускаем задачу только после commit транзакции
# Иначе Celery worker не найдет объект в БД (race condition)
transaction.on_commit(send_task)
def _create_message_notifications_sync(instance):
"""
Синхронная версия обработки уведомлений (fallback для разработки).
Используется только когда Celery недоступен.
"""
chat = instance.chat
author = instance.author
content = instance.content
# Получаем всех участников чата кроме автора
# Для announcement/channel/department используем get_participants
if chat.type in ['announcement', 'channel', 'department', 'global']:
participants = chat.get_participants.exclude(id=author.id)
else:
participants = chat.participants.exclude(id=author.id)
# ОПТИМИЗАЦИЯ: Загружаем настройки уведомлений ОДНИМ запросом для всех участников
participants_list = list(participants)
if not participants_list:
return # Нет получателей - выходим
notification_settings = get_users_with_notifications_enabled(chat, participants_list)
# 1. Обработка упоминаний (@username)
mentioned_users = extract_mentions(content)
mentioned_user_ids = []
if mentioned_users:
for email in mentioned_users:
try:
user = Employee.objects.get(email=email)
if user in participants_list and user.id != author.id:
if notification_settings.get(user.id, True):
NotificationService.create_notification(
recipient=user,
notification_type_code='chat_mention',
title=f'Вас упомянул {author.get_full_name() or author.username}',
message=truncate_message(content, 100),
content_object=instance,
action_url=f'/communications/chats/{chat.id}/?message={instance.id}',
metadata={
'chat_id': chat.id,
'chat_name': get_chat_name(chat),
'message_id': instance.id,
'author_id': author.id,
}
)
mentioned_user_ids.append(user.id)
except Employee.DoesNotExist:
continue
# 2. Обработка ответов на сообщения
if instance.reply_to and instance.reply_to.author_id != author.id:
original_author = instance.reply_to.author
if original_author.id not in mentioned_user_ids:
if notification_settings.get(original_author.id, True):
NotificationService.create_notification(
recipient=original_author,
notification_type_code='chat_reply',
title=f'{author.get_full_name() or author.username} ответил на ваше сообщение',
message=truncate_message(content, 100),
content_object=instance,
action_url=f'/communications/chats/{chat.id}/?message={instance.id}',
metadata={
'chat_id': chat.id,
'chat_name': get_chat_name(chat),
'message_id': instance.id,
'reply_to_id': instance.reply_to.id,
'author_id': author.id,
}
)
# 3. Обычное уведомление о новом сообщении
excluded_ids = set(mentioned_user_ids)
if instance.reply_to and instance.reply_to.author_id != author.id:
excluded_ids.add(instance.reply_to.author_id)
if chat.type == 'announcement':
notification_type = 'announcement_new_message'
title = f'Новое объявление от {author.get_full_name() or author.username}'
is_announcement = True
else:
notification_type = 'chat_new_message'
if chat.type == 'private':
title = f'Новое сообщение от {author.get_full_name() or author.username}'
else:
title = f'{author.get_full_name() or author.username} в {get_chat_name(chat)}'
is_announcement = False
for recipient in participants_list:
if recipient.id in excluded_ids:
continue
if not notification_settings.get(recipient.id, True):
continue
metadata = {
'chat_id': chat.id,
'chat_name': get_chat_name(chat),
'message_id': instance.id,
'author_id': author.id,
}
if is_announcement:
metadata['is_announcement'] = True
NotificationService.create_notification(
recipient=recipient,
notification_type_code=notification_type,
title=title,
message=truncate_message(content, 150 if is_announcement else 100),
content_object=instance,
action_url=f'/communications/chats/{chat.id}/',
metadata=metadata
)
@receiver(m2m_changed, sender=Chat.participants.through)
def create_chat_added_notifications(sender, instance, action, pk_set, **kwargs):
"""
Создает уведомления когда пользователя добавляют в чат.
"""
if action != 'post_add':
return
chat = instance
# Получаем информацию о том, кто добавил (если доступно)
# Это может быть последний изменивший или создатель чата
added_by = getattr(chat, 'created_by', None) or chat.participants.first()
# ОПТИМИЗАЦИЯ: Получаем всех новых пользователей одним запросом
new_users = Employee.objects.filter(id__in=pk_set)
# ОПТИМИЗАЦИЯ: Загружаем настройки одним запросом
notification_settings = get_users_with_notifications_enabled(chat, new_users)
# Создаем уведомления для новых участников
for user in new_users:
# Не отправляем уведомление тому, кто сам себя добавил
if added_by and user.id == added_by.id:
continue
# Проверяем настройки уведомлений (из предзагруженного словаря)
if not notification_settings.get(user.id, True):
continue
NotificationService.create_notification_async(
recipient=user,
notification_type_code='chat_added_to_chat',
title=f'Вас добавили в чат',
message=f'Вы были добавлены в чат "{get_chat_name(chat)}"',
content_object=chat,
action_url=f'/communications/chats/{chat.id}/',
metadata={
'chat_id': chat.id,
'chat_type': chat.type,
'chat_name': get_chat_name(chat),
'added_by_id': added_by.id if added_by else None,
}
)
# ===== Вспомогательные функции =====
def extract_mentions(text):
"""
Извлекает email'ы из упоминаний вида @email.
Returns:
list: Список email'ов без символа @
"""
if not text:
return []
# Паттерн: @ + email формат
pattern = r'@([\w.+-]+@[\w.-]+\.[\w]+)'
mentions = re.findall(pattern, text)
return list(set(mentions)) # Уникальные значения
def truncate_message(text, max_length=100):
"""
Обрезает текст сообщения до указанной длины.
"""
if not text:
return ''
if len(text) <= max_length:
return text
return text[:max_length - 3] + '...'
def get_chat_name(chat):
"""
Возвращает название чата в зависимости от типа.
"""
if chat.name:
return chat.name
if chat.type == 'global':
return 'Глобальный чат'
if chat.type == 'department' and chat.department:
return f'Чат отдела: {chat.department.name}'
if chat.type == 'private':
# Для приватного чата можно вернуть имена участников
participants = chat.participants.all()[:2]
if participants:
names = [p.get_full_name() or p.username for p in participants]
return ', '.join(names)
return 'Чат'