Исправлена проблема race condition когда Celery задача process_message_notifications запускалась до завершения транзакции БД, что приводило к ошибке 'message_not_found'. Теперь задача запускается только после успешного commit, гарантируя что объект уже доступен в базе данных для worker процесса.
326 lines
13 KiB
Python
326 lines
13 KiB
Python
"""
|
||
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 'Чат'
|