refactor(communications): реструктуризация notifications в модульную архитектуру
Проблема: - Монолитный notification_signals.py (~310 строк) смешивал разные concerns - Сложная логика с упоминаниями, ответами и обычными сообщениями в одном файле - Множество вспомогательных функций без четкой структуры Решение: Разделен на модульную структуру communications/notifications/: - config.py - NotificationVerbs, MessageTemplates, ActionURLs, вспомогательные функции (extract_mentions, truncate_message, get_chat_name) - handlers.py - notify_new_message(), notify_chat_added(), get_users_with_notifications_enabled() - signals.py - Django signal receivers (@receiver) - __init__.py - публичный API модуля Изменения: - Создан: communications/notifications/ модуль с 4 файлами - Переименован: notification_signals.py → notification_signals.py.old - Изменен: apps.py - обновлен импорт на communications.notifications Логика уведомлений: 1. Упоминания (@email) - высокий приоритет 2. Ответы на сообщения - средний приоритет 3. Обычные сообщения - низкий приоритет Преимущества: - ✅ Единообразие с requests_app, procurement, feed - ✅ Четкое разделение ответственности (SRP) - ✅ ОПТИМИЗАЦИЯ: Настройки уведомлений загружаются одним запросом - ✅ Легче тестировать компоненты - ✅ Публичный API для переиспользования функций - ✅ Централизованные шаблоны сообщений Верификация: - python manage.py check - успешно
This commit is contained in:
parent
258ff58df1
commit
8182199744
@ -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):
|
||||
|
||||
43
backend/communications/notifications/__init__.py
Normal file
43
backend/communications/notifications/__init__.py
Normal file
@ -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',
|
||||
]
|
||||
159
backend/communications/notifications/config.py
Normal file
159
backend/communications/notifications/config.py
Normal file
@ -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 'Чат'
|
||||
220
backend/communications/notifications/handlers.py
Normal file
220
backend/communications/notifications/handlers.py
Normal file
@ -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,
|
||||
}
|
||||
)
|
||||
62
backend/communications/notifications/signals.py
Normal file
62
backend/communications/notifications/signals.py
Normal file
@ -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)
|
||||
Loading…
x
Reference in New Issue
Block a user