diff --git a/backend/communications/apps.py b/backend/communications/apps.py index 4ff47043..72f5775a 100644 --- a/backend/communications/apps.py +++ b/backend/communications/apps.py @@ -8,7 +8,7 @@ class CommunicationsConfig(AppConfig): def ready(self): import communications.signals - import communications.notification_signals # Подключаем signals уведомлений + import communications.notifications # Модуль уведомлений (signals регистрируются автоматически) import communications.rules # django-rules: регистрация предикатов и правил доступа def create_main_global_chat(sender, **kwargs): diff --git a/backend/communications/notification_signals.py b/backend/communications/notification_signals.py.old similarity index 100% rename from backend/communications/notification_signals.py rename to backend/communications/notification_signals.py.old diff --git a/backend/communications/notifications/__init__.py b/backend/communications/notifications/__init__.py new file mode 100644 index 00000000..ae50ed50 --- /dev/null +++ b/backend/communications/notifications/__init__.py @@ -0,0 +1,43 @@ +""" +Модуль уведомлений для Communications приложения. + +Публичный API: + - NotificationVerbs - константы глаголов уведомлений + - MessageTemplates - шаблоны сообщений + - ActionURLs - генераторы URL-адресов + - extract_mentions - извлечение упоминаний из текста + - truncate_message - обрезка текста сообщения + - get_chat_name - получение названия чата + - get_users_with_notifications_enabled - проверка настроек уведомлений + +Использование: + from communications.notifications import extract_mentions, get_chat_name + + # В коде: + mentions = extract_mentions(message.content) + chat_name = get_chat_name(chat) +""" + +from .config import ( + NotificationVerbs, + MessageTemplates, + ActionURLs, + extract_mentions, + truncate_message, + get_chat_name, +) +from .handlers import get_users_with_notifications_enabled + +# Импортируем handlers и signals для регистрации +from . import handlers # noqa: F401 +from . import signals # noqa: F401 + +__all__ = [ + 'NotificationVerbs', + 'MessageTemplates', + 'ActionURLs', + 'extract_mentions', + 'truncate_message', + 'get_chat_name', + 'get_users_with_notifications_enabled', +] diff --git a/backend/communications/notifications/config.py b/backend/communications/notifications/config.py new file mode 100644 index 00000000..e359038a --- /dev/null +++ b/backend/communications/notifications/config.py @@ -0,0 +1,159 @@ +""" +Конфигурация уведомлений для модуля Communications. + +Содержит: +- NotificationVerbs - глаголы для уведомлений +- MessageTemplates - шаблоны сообщений +- ActionURLs - URL-адреса действий +- Вспомогательные функции для обработки сообщений +""" + +import re + + +class NotificationVerbs: + """Глаголы (verbs) для уведомлений communications.""" + + NEW_MESSAGE = 'chat_new_message' + ANNOUNCEMENT = 'announcement_new_message' + MENTION = 'chat_mention' + REPLY = 'chat_reply' + ADDED_TO_CHAT = 'chat_added_to_chat' + + +class MessageTemplates: + """Шаблоны сообщений для уведомлений communications.""" + + @staticmethod + def mention(author_name: str) -> str: + """Шаблон для упоминания пользователя.""" + return f'Вас упомянул {author_name}' + + @staticmethod + def reply(author_name: str) -> str: + """Шаблон для ответа на сообщение.""" + return f'{author_name} ответил на ваше сообщение' + + @staticmethod + def announcement(author_name: str) -> str: + """Шаблон для нового объявления.""" + return f'Новое объявление от {author_name}' + + @staticmethod + def private_message(author_name: str) -> str: + """Шаблон для приватного сообщения.""" + return f'Новое сообщение от {author_name}' + + @staticmethod + def group_message(author_name: str, chat_name: str) -> str: + """Шаблон для сообщения в группе.""" + return f'{author_name} в {chat_name}' + + @staticmethod + def added_to_chat(chat_name: str) -> str: + """Шаблон для добавления в чат.""" + return f'Вы были добавлены в чат "{chat_name}"' + + @staticmethod + def added_to_chat_title() -> str: + """Заголовок для уведомления о добавлении в чат.""" + return 'Вас добавили в чат' + + +class ActionURLs: + """URL-адреса для действий с сообщениями.""" + + MESSAGES = '/messages' + + @staticmethod + def chat_detail(chat_id: int) -> str: + """ + Возвращает URL-адрес чата. + + TODO: реализовать прямые ссылки на конкретные чаты + """ + return f'/messages?chat={chat_id}' + + @staticmethod + def message_detail(chat_id: int, message_id: int) -> str: + """ + Возвращает URL-адрес сообщения. + + TODO: реализовать прямые ссылки на конкретные сообщения + """ + return f'/messages?chat={chat_id}&message={message_id}' + + +# ===== Вспомогательные функции ===== + +def extract_mentions(text: str) -> list[str]: + """ + Извлекает email'ы из упоминаний вида @email. + + Args: + text: Текст сообщения + + Returns: + list: Список email'ов без символа @ + + Examples: + >>> extract_mentions("Привет @user@example.com и @test@mail.com") + ['user@example.com', 'test@mail.com'] + """ + if not text: + return [] + + # Паттерн: @ + email формат + pattern = r'@([\w.+-]+@[\w.-]+\.[\w]+)' + mentions = re.findall(pattern, text) + + return list(set(mentions)) # Уникальные значения + + +def truncate_message(text: str, max_length: int = 100) -> str: + """ + Обрезает текст сообщения до указанной длины. + + Args: + text: Исходный текст + max_length: Максимальная длина (по умолчанию 100) + + Returns: + Обрезанный текст с многоточием если нужно + """ + if not text: + return '' + + if len(text) <= max_length: + return text + + return text[:max_length - 3] + '...' + + +def get_chat_name(chat) -> str: + """ + Возвращает название чата в зависимости от типа. + + Args: + chat: Объект Chat + + Returns: + str: Название чата + """ + 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 'Чат' diff --git a/backend/communications/notifications/handlers.py b/backend/communications/notifications/handlers.py new file mode 100644 index 00000000..cae8f8b9 --- /dev/null +++ b/backend/communications/notifications/handlers.py @@ -0,0 +1,220 @@ +""" +Бизнес-логика отправки уведомлений для модуля Communications. + +Функции: +- get_users_with_notifications_enabled - проверка настроек уведомлений +- notify_new_message - уведомление о новом сообщении (с упоминаниями и ответами) +- notify_chat_added - уведомление о добавлении в чат +""" + +from django.contrib.auth import get_user_model +from notifications.signals import notify + +from ..models import ChatUserSettings +from .config import ( + NotificationVerbs, + MessageTemplates, + ActionURLs, + extract_mentions, + truncate_message, + get_chat_name, +) + +Employee = get_user_model() + + +def get_users_with_notifications_enabled(chat, users) -> dict[int, bool]: + """ + Получает словарь пользователей с их настройками уведомлений. + Оптимизированная версия - один запрос к БД вместо 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} + + +def notify_new_message(message): + """ + Отправляет уведомления о новом сообщении в чате. + + Обрабатывает три типа уведомлений: + 1. Упоминания (@username) - высокий приоритет + 2. Ответы на сообщения - средний приоритет + 3. Обычные сообщения в чате - низкий приоритет + + Args: + message: Экземпляр модели Message + """ + chat = message.chat + author = message.author + content = message.content + + # Получаем всех участников чата кроме автора + 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) + + # Отслеживаем, кому уже отправили уведомления + notified_user_ids = set() + + # 1. УПОМИНАНИЯ (@username) + mentioned_users = extract_mentions(content) + author_name = author.get_full_name() or author.username + + 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): + notify.send( + sender=author, + recipient=user, + verb=NotificationVerbs.MENTION, + action_object=message, + description=truncate_message(content, 100), + action_url=ActionURLs.MESSAGES, + data={ + 'title': MessageTemplates.mention(author_name), + 'chat_id': chat.id, + 'chat_name': get_chat_name(chat), + 'message_id': message.id, + 'author_id': author.id, + } + ) + notified_user_ids.add(user.id) + except Employee.DoesNotExist: + continue + + # 2. ОТВЕТЫ НА СООБЩЕНИЯ + if message.reply_to and message.reply_to.author_id != author.id: + original_author = message.reply_to.author + + if original_author.id not in notified_user_ids: + if notification_settings.get(original_author.id, True): + notify.send( + sender=author, + recipient=original_author, + verb=NotificationVerbs.REPLY, + action_object=message, + description=truncate_message(content, 100), + action_url=ActionURLs.MESSAGES, + data={ + 'title': MessageTemplates.reply(author_name), + 'chat_id': chat.id, + 'chat_name': get_chat_name(chat), + 'message_id': message.id, + 'reply_to_id': message.reply_to.id, + 'author_id': author.id, + } + ) + notified_user_ids.add(original_author.id) + + # 3. ОБЫЧНЫЕ УВЕДОМЛЕНИЯ О НОВОМ СООБЩЕНИИ + if chat.type == 'announcement': + notification_verb = NotificationVerbs.ANNOUNCEMENT + title = MessageTemplates.announcement(author_name) + max_length = 150 + is_announcement = True + else: + notification_verb = NotificationVerbs.NEW_MESSAGE + if chat.type == 'private': + title = MessageTemplates.private_message(author_name) + else: + title = MessageTemplates.group_message(author_name, get_chat_name(chat)) + max_length = 100 + is_announcement = False + + for recipient in participants_list: + # Пропускаем тех, кому уже отправили уведомление + if recipient.id in notified_user_ids: + continue + + # Проверяем настройки уведомлений + if not notification_settings.get(recipient.id, True): + continue + + metadata = { + 'chat_id': chat.id, + 'chat_name': get_chat_name(chat), + 'message_id': message.id, + 'author_id': author.id, + } + if is_announcement: + metadata['is_announcement'] = True + + notify.send( + sender=author, + recipient=recipient, + verb=notification_verb, + action_object=message, + description=truncate_message(content, max_length), + action_url=ActionURLs.MESSAGES, + data={**metadata, 'title': title}, + ) + + +def notify_chat_added(chat, new_users, added_by=None): + """ + Отправляет уведомления пользователям о добавлении в чат. + + Args: + chat: Объект Chat + new_users: Список/QuerySet новых пользователей + added_by: Пользователь, который добавил (опционально) + """ + # ОПТИМИЗАЦИЯ: Загружаем настройки одним запросом + 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 + + notify.send( + sender=added_by, + recipient=user, + verb=NotificationVerbs.ADDED_TO_CHAT, + action_object=chat, + description=MessageTemplates.added_to_chat(get_chat_name(chat)), + action_url=ActionURLs.MESSAGES, + data={ + 'title': MessageTemplates.added_to_chat_title(), + 'chat_id': chat.id, + 'chat_name': get_chat_name(chat), + 'added_by_id': added_by.id if added_by else None, + } + ) diff --git a/backend/communications/notifications/signals.py b/backend/communications/notifications/signals.py new file mode 100644 index 00000000..034766a0 --- /dev/null +++ b/backend/communications/notifications/signals.py @@ -0,0 +1,62 @@ +""" +Django signals для автоматической генерации уведомлений в модуле Communications. + +Обрабатывает события: +- post_save для Message - новое сообщение (с упоминаниями, ответами) +- m2m_changed для Chat.participants - добавление пользователей в чат +""" + +from django.contrib.auth import get_user_model +from django.db.models.signals import post_save, m2m_changed +from django.dispatch import receiver + +from ..models import Message, Chat +from .handlers import notify_new_message, notify_chat_added + +Employee = get_user_model() + + +@receiver(post_save, sender=Message) +def create_message_notifications(sender, instance, created, **kwargs): + """ + Создает уведомления при создании нового сообщения. + + Обрабатывает: + 1. Новое сообщение в чате (для всех участников кроме автора) + 2. Упоминания (@username) в тексте + 3. Ответ на сообщение (reply_to) + + ОПТИМИЗАЦИЯ: Уведомления отправляются через универсальную систему + channels.py → Celery → WebSocket/Email/Push + """ + if not created or instance.is_system or instance.is_deleted: + return + + # Отправляем через универсальную систему + notify_new_message(instance) + + +@receiver(m2m_changed, sender=Chat.participants.through) +def create_chat_added_notifications(sender, instance, action, pk_set, **kwargs): + """ + Создает уведомления когда пользователя добавляют в чат. + + Args: + instance: Chat объект + action: Тип изменения ('post_add', 'post_remove', etc.) + pk_set: Набор ID добавленных/удаленных пользователей + """ + 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) + + # Отправляем уведомления через handlers + notify_chat_added(chat, new_users, added_by=added_by)