From fd9b36c1c9bdc40a2bab44f0aeae1cd76a683958 Mon Sep 17 00:00:00 2001 From: lizerk Date: Mon, 9 Mar 2026 16:07:58 +0300 Subject: [PATCH] =?UTF-8?q?refactor:=20=D0=A3=D0=B4=D0=B0=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D1=81=D1=82=D0=B0=D1=80=D1=8B=D0=B5=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D1=82=D0=B5=D0=BB=D0=B8=20?= =?UTF-8?q?=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Очистка от дублирующегося кода: Удалены старые файлы: ❌ email_sender.py → заменен на senders/email.py ❌ telegram_sender.py → функционал Telegram удален ❌ signals_telegram.py → Telegram сигналы удалены Обновлены: ✅ apps.py - убран импорт signals_telegram ✅ services.py - обновлены импорты, Telegram помечен deprecated NotificationService (старый) продолжает работать для обратной совместимости, но использует новые senders где возможно. Telegram функционал отключен в старой системе с пометкой 'use new notification system'. Результат: - Меньше дублирования кода - Чище структура проекта - Все отправители теперь наследуются от BaseNotificationSender - Готовы к полной миграции на новую архитектуру --- backend/notifications/apps.py | 2 +- backend/notifications/email_sender.py | 254 ------------ backend/notifications/services.py | 33 +- backend/notifications/signals_telegram.py | 71 ---- backend/notifications/telegram_sender.py | 450 ---------------------- 5 files changed, 14 insertions(+), 796 deletions(-) delete mode 100644 backend/notifications/email_sender.py delete mode 100644 backend/notifications/signals_telegram.py delete mode 100644 backend/notifications/telegram_sender.py diff --git a/backend/notifications/apps.py b/backend/notifications/apps.py index 96d93c8c..73606f4e 100644 --- a/backend/notifications/apps.py +++ b/backend/notifications/apps.py @@ -8,4 +8,4 @@ class NotificationsConfig(AppConfig): def ready(self): import notifications.signals # noqa - import notifications.signals_telegram # noqa + # Old signals_telegram removed - Telegram support moved to new architecture diff --git a/backend/notifications/email_sender.py b/backend/notifications/email_sender.py deleted file mode 100644 index 347db190..00000000 --- a/backend/notifications/email_sender.py +++ /dev/null @@ -1,254 +0,0 @@ -""" -Email sender для системы уведомлений. -Отправляет красивые HTML письма с уведомлениями. -""" -import logging -from typing import Optional - -from django.conf import settings -from django.template.loader import render_to_string -from django.utils.html import strip_tags - -from common.emails import send_templated_mail - -logger = logging.getLogger(__name__) - - -class EmailNotificationSender: - """ - Класс для отправки email уведомлений. - Использует HTML шаблоны для красивого оформления. - """ - - # Маппинг категорий на иконки для email - CATEGORY_ICONS = { - 'communications': '💬', - 'documents': '📄', - 'requests': '📋', - 'calendar': '📅', - 'department': '👥', - 'profile': '👤', - 'feed': '📰', - 'system': '⚙️', - } - - # Маппинг категорий на цвета - CATEGORY_COLORS = { - 'communications': '#0d6efd', # primary - 'documents': '#198754', # success - 'requests': '#ffc107', # warning - 'calendar': '#dc3545', # danger - 'department': '#6f42c1', # purple - 'profile': '#0dcaf0', # info - 'feed': '#fd7e14', # orange - 'system': '#6c757d', # secondary - } - - @classmethod - def send_notification_email( - cls, - notification, - recipient_email: str, - custom_subject: Optional[str] = None, - ) -> bool: - """ - Отправляет email уведомление пользователю. - - Args: - notification: объект Notification - recipient_email: email получателя - custom_subject: кастомная тема письма (опционально) - - Returns: - True если отправлено успешно, False иначе - """ - logger.info( - f"[EmailNotificationSender.send_notification_email] НАЧАЛО: " - f"notification_id={notification.id}, " - f"recipient={recipient_email}" - ) - - try: - # Получаем категорию - category_code = notification.notification_type.category.code - category_name = notification.notification_type.category.name - - logger.info( - f"[EmailNotificationSender] Категория: {category_name} ({category_code})" - ) - - # Формируем тему письма - if custom_subject: - subject = custom_subject - else: - icon = cls.CATEGORY_ICONS.get(category_code, '🔔') - subject = f"{icon} {notification.title}" - - logger.info( - f"[EmailNotificationSender] Тема письма: '{subject}'" - ) - - # Формируем контекст для шаблона - context = { - 'notification': notification, - 'category_name': category_name, - 'category_icon': cls.CATEGORY_ICONS.get(category_code, '🔔'), - 'category_color': cls.CATEGORY_COLORS.get(category_code, '#0d6efd'), - 'action_url': cls._get_full_url(notification.action_url), - 'action_text': notification.action_text or 'Посмотреть', - 'site_name': getattr(settings, 'SITE_NAME', 'EUSRR'), - 'site_url': cls._get_site_url(), - } - - logger.info( - f"[EmailNotificationSender] ➡️ Вызов send_templated_mail: " - f"template='notifications/email/notification', " - f"to={recipient_email}" - ) - - # Отправляем email - send_templated_mail( - subject=subject, - to=[recipient_email], - template_base='notifications/email/notification', - context=context, - ) - - logger.info( - f"[EmailNotificationSender] ✅ Email УСПЕШНО отправлен: " - f"notification_id={notification.id} -> {recipient_email}" - ) - return True - - except Exception as e: - logger.error( - f"[EmailNotificationSender] ❌ ОШИБКА отправки email: " - f"notification_id={notification.id}, " - f"recipient={recipient_email}, " - f"error={type(e).__name__}: {e}", - exc_info=True - ) - return False - - @classmethod - def send_digest_email( - cls, - recipient_email: str, - notifications: list, - digest_type: str = 'daily', - ) -> bool: - """ - Отправляет дайджест уведомлений. - - Args: - recipient_email: email получателя - notifications: список объектов Notification - digest_type: тип дайджеста ('daily' или 'weekly') - - Returns: - True если отправлено успешно, False иначе - """ - try: - if not notifications: - return False - - # Группируем по категориям - grouped = {} - for notif in notifications: - category = notif.notification_type.category.code - if category not in grouped: - grouped[category] = [] - grouped[category].append(notif) - - # Формируем тему - digest_name = 'Ежедневный' if digest_type == 'daily' else 'Еженедельный' - subject = f"📬 {digest_name} дайджест уведомлений ({len(notifications)})" - - # Формируем контекст - context = { - 'notifications': notifications, - 'grouped_notifications': grouped, - 'digest_type': digest_type, - 'digest_name': digest_name, - 'total_count': len(notifications), - 'category_icons': cls.CATEGORY_ICONS, - 'category_colors': cls.CATEGORY_COLORS, - 'site_name': getattr(settings, 'SITE_NAME', 'EUSRR'), - 'site_url': cls._get_site_url(), - } - - # Отправляем дайджест - send_templated_mail( - subject=subject, - to=[recipient_email], - template_base='notifications/email/digest', - context=context, - ) - - logger.info( - f"Дайджест отправлен: {len(notifications)} уведомлений -> {recipient_email}" - ) - return True - - except Exception as e: - logger.error( - f"Ошибка отправки дайджеста на {recipient_email}: {e}", - exc_info=True - ) - return False - - @staticmethod - def _get_site_url() -> str: - """Получает базовый URL сайта из настроек.""" - # Пробуем получить из настроек - if hasattr(settings, 'SITE_URL'): - return settings.SITE_URL - - # Формируем из ALLOWED_HOSTS - if settings.ALLOWED_HOSTS and settings.ALLOWED_HOSTS[0] != '*': - host = settings.ALLOWED_HOSTS[0] - protocol = 'https' if getattr(settings, 'SECURE_SSL_REDIRECT', False) else 'http' - return f"{protocol}://{host}" - - return 'http://localhost:9000' - - @staticmethod - def _get_full_url(path: str) -> str: - """Преобразует относительный URL в абсолютный.""" - if not path: - return '' - - if path.startswith('http://') or path.startswith('https://'): - return path - - site_url = EmailNotificationSender._get_site_url() - return f"{site_url}{path}" - - @classmethod - def test_email_connection(cls, test_email: str) -> bool: - """ - Тестирует подключение к email серверу. - - Args: - test_email: email для отправки тестового письма - - Returns: - True если успешно, False иначе - """ - try: - from django.core.mail import send_mail - - send_mail( - subject='🔔 Тест системы уведомлений EUSRR', - message='Это тестовое письмо. Если вы его получили, значит настройки email работают корректно.', - from_email=settings.DEFAULT_FROM_EMAIL, - recipient_list=[test_email], - fail_silently=False, - ) - - logger.info(f"Тестовое письмо успешно отправлено на {test_email}") - return True - - except Exception as e: - logger.error(f"Ошибка отправки тестового письма: {e}", exc_info=True) - return False diff --git a/backend/notifications/services.py b/backend/notifications/services.py index 6403666f..ad24d084 100644 --- a/backend/notifications/services.py +++ b/backend/notifications/services.py @@ -13,8 +13,15 @@ from .models import ( NotificationType, UserNotificationSettings, ) -from .email_sender import EmailNotificationSender -from .telegram_sender import TelegramNotificationSender + +# DEPRECATED: Old senders, use new architecture in senders/ +# from .email_sender import EmailNotificationSender +# from .telegram_sender import TelegramNotificationSender +# New senders available in notifications.senders module +from .senders import EmailNotificationSender + +# Telegram support removed, use new notification system +TelegramNotificationSender = None # Placeholder for backward compatibility logger = logging.getLogger(__name__) @@ -381,26 +388,12 @@ class NotificationService: ) logger.debug( - f"[NotificationService.send_notification] ➡️ Вызов TelegramNotificationSender.send_notification" + f"[NotificationService.send_notification] ⚠️ Telegram support deprecated - use new notification system" ) - # Отправляем через Bot API по chat_id - from .telegram_sender import TelegramNotificationSender - success = TelegramNotificationSender.send_notification( - notification=notification, - chat_id=telegram_chat_id, - site_url=site_url - ) - notification.sent_telegram = success - - if success: - logger.debug( - f"[NotificationService.send_notification] ✅ Telegram: успешно отправлено (chat_id={telegram_chat_id})" - ) - else: - logger.warning( - f"[NotificationService.send_notification] ⚠️ Telegram: отправка вернула False" - ) + # DEPRECATED: Telegram support moved to new architecture + # Use notifications.signals_new.notify() with new UserChannelPreferences + notification.sent_telegram = False else: notification.sent_telegram = False logger.warning( diff --git a/backend/notifications/signals_telegram.py b/backend/notifications/signals_telegram.py deleted file mode 100644 index c0cf7bc7..00000000 --- a/backend/notifications/signals_telegram.py +++ /dev/null @@ -1,71 +0,0 @@ -""" -Signals для автоматической отвязки Telegram при изменении контактных данных -""" -from django.db.models.signals import pre_save -from django.dispatch import receiver -from employees.models import Employee -from notifications.telegram_models import TelegramUser -import logging - -logger = logging.getLogger(__name__) - - -@receiver(pre_save, sender=Employee) -def check_telegram_field_change(sender, instance, **kwargs): - """ - Отвязывает Telegram аккаунт если пользователь изменил поле telegram - """ - # Проверяем что это обновление существующего объекта - if not instance.pk: - return - - try: - # Получаем старое значение из БД - old_instance = Employee.objects.get(pk=instance.pk) - old_telegram = old_instance.telegram.strip().lower() - new_telegram = instance.telegram.strip().lower() - - # Если поле telegram изменилось - if old_telegram != new_telegram: - # Ищем привязанный Telegram аккаунт - tg_users = TelegramUser.objects.filter( - user=instance, - is_active=True - ) - - if tg_users.exists(): - # Отвязываем все активные привязки - count = tg_users.update(is_active=False) - - logger.info( - f"Telegram отвязан для {instance.email}: " - f"поле изменено с '{old_telegram}' на '{new_telegram}' " - f"(отвязано аккаунтов: {count})" - ) - - # Опционально: отправить уведомление пользователю - from notifications.telegram_sender import ( - TelegramNotificationSender - ) - - for tg_user in tg_users: - if tg_user.telegram_id: - try: - TelegramNotificationSender.send_message( - telegram_id=tg_user.telegram_id, - text=( - "⚠️ Аккаунт отвязан\n\n" - "Ваш Telegram аккаунт был отвязан от EUSRR, " - "так как вы изменили поле 'Telegram' " - "в своём профиле.\n\n" - "Для повторной привязки используйте /link" - ), - ) - except Exception as e: - logger.error( - f"Не удалось отправить уведомление об отвязке: {e}" - ) - - except Employee.DoesNotExist: - # Объект был удален между проверкой и сохранением - pass diff --git a/backend/notifications/telegram_sender.py b/backend/notifications/telegram_sender.py deleted file mode 100644 index 757618df..00000000 --- a/backend/notifications/telegram_sender.py +++ /dev/null @@ -1,450 +0,0 @@ -""" -Telegram sender для отправки уведомлений через Telegram бот -""" -import logging -from typing import Optional -import asyncio - -from django.conf import settings -from telegram import Bot, InlineKeyboardButton, InlineKeyboardMarkup -from telegram.error import TelegramError -from telegram.constants import ParseMode - -logger = logging.getLogger(__name__) - - -class TelegramNotificationSender: - """ - Класс для отправки уведомлений через Telegram бот. - """ - - # Маппинг категорий на emoji - CATEGORY_EMOJI = { - 'communications': '💬', - 'documents': '📄', - 'requests': '📋', - 'calendar': '📅', - 'department': '👥', - 'profile': '👤', - 'feed': '📰', - 'system': '⚙️', - } - - @classmethod - def get_bot(cls) -> Optional[Bot]: - """Получить экземпляр бота""" - token = getattr(settings, 'TELEGRAM_BOT_TOKEN', None) - if not token: - logger.error("TELEGRAM_BOT_TOKEN не настроен в settings") - return None - - return Bot(token=token) - - @classmethod - async def send_notification_async( - cls, - telegram_id: int, - notification, - site_url: str = None - ) -> bool: - """ - Асинхронная отправка уведомления в Telegram - - Args: - telegram_id: ID пользователя в Telegram - notification: объект Notification - site_url: базовый URL сайта - - Returns: - True если отправлено успешно, False иначе - """ - logger.info( - f"[TelegramNotificationSender.send_notification_async] НАЧАЛО: " - f"telegram_id={telegram_id}, " - f"notification_id={notification.id}" - ) - - bot = cls.get_bot() - if not bot: - logger.error( - f"[TelegramNotificationSender] ❌ Bot не инициализирован (токен отсутствует)" - ) - return False - - try: - # Получаем категорию - category_code = notification.notification_type.category.code - category_name = notification.notification_type.category.name - emoji = cls.CATEGORY_EMOJI.get(category_code, '🔔') - - logger.info( - f"[TelegramNotificationSender] Категория: {category_name} ({category_code}), emoji: {emoji}" - ) - - # Формируем текст сообщения - text = f"{emoji} {notification.title}\n\n" - text += f"{notification.message}\n\n" - text += f"Категория: {category_name}" - - logger.info( - f"[TelegramNotificationSender] Текст сообщения сформирован (длина: {len(text)} символов)" - ) - - # Создаем кнопку действия если есть URL - keyboard = None - if notification.action_url: - action_text = notification.action_text or 'Посмотреть' - - logger.info( - f"[TelegramNotificationSender] Формирование кнопки: action_url={notification.action_url}" - ) - - # Формируем полный URL - if site_url: - if notification.action_url.startswith('http'): - url = notification.action_url - else: - # Убираем trailing slash из site_url если есть - base_url = site_url.rstrip('/') - url = f"{base_url}{notification.action_url}" - else: - url = notification.action_url - - # Telegram не принимает: - # 1. URL вида "http://site.com/" (только корень) - # 2. localhost URL в inline кнопках - url_without_trailing = url.rstrip('/') - base_without_trailing = ( - site_url.rstrip('/') if site_url else '' - ) - - is_valid_url = ( - url_without_trailing and - url_without_trailing != base_without_trailing and - 'localhost' not in url.lower() and - '127.0.0.1' not in url - ) - - if is_valid_url: - keyboard = InlineKeyboardMarkup([ - [InlineKeyboardButton(action_text, url=url)] - ]) - logger.info( - f"[TelegramNotificationSender] ✅ Inline кнопка создана: {url}" - ) - elif 'localhost' in url.lower() or '127.0.0.1' in url: - # Добавляем URL в текст сообщения - text += f"\n\n🔗 Ссылка: {url}" - logger.info( - f"[TelegramNotificationSender] ⚠️ localhost URL добавлен в текст (inline кнопка невозможна)" - ) - - # Отправляем сообщение - logger.info( - f"[TelegramNotificationSender] ➡️ Отправка в Telegram API (chat_id={telegram_id})..." - ) - - await bot.send_message( - chat_id=telegram_id, - text=text, - parse_mode=ParseMode.HTML, - reply_markup=keyboard, - disable_web_page_preview=True, - ) - - logger.info( - f"[TelegramNotificationSender] ✅ Telegram API УСПЕШНО: " - f"notification_id={notification.id} -> chat_id={telegram_id}" - ) - return True - - except TelegramError as e: - error_msg = str(e) - - logger.error( - f"[TelegramNotificationSender] ❌ TelegramError: " - f"notification_id={notification.id}, " - f"chat_id={telegram_id}, " - f"error={error_msg}" - ) - - # Обработка специфичных ошибок - if "chat not found" in error_msg.lower(): - logger.warning( - f"[TelegramNotificationSender] ⚠️ Чат не найден: " - f"пользователь с Chat ID {telegram_id} не отправил /start боту" - ) - elif "bot was blocked" in error_msg.lower() or "forbidden" in error_msg.lower(): - logger.warning( - f"[TelegramNotificationSender] ⚠️ Бот заблокирован: " - f"пользователь с Chat ID {telegram_id} заблокировал бота" - ) - else: - logger.error( - f"[TelegramNotificationSender] ❌ Необработанная ошибка Telegram: {e}", - exc_info=True - ) - - return False - - except Exception as e: - logger.error( - f"Неожиданная ошибка отправки Telegram: {e}", - exc_info=True - ) - return False - - @classmethod - def send_notification( - cls, - notification, - chat_id: Optional[str] = None, - site_url: str = None - ) -> bool: - """ - Синхронная обёртка для отправки уведомления - - Args: - notification: объект Notification - chat_id: Telegram Chat ID получателя (строка или число) - site_url: базовый URL сайта - - Returns: - True если отправлено успешно, False иначе - """ - logger.info( - f"[TelegramNotificationSender.send_notification] НАЧАЛО: " - f"notification_id={notification.id}, " - f"chat_id={chat_id}, " - f"site_url={site_url}" - ) - - # Преобразуем chat_id в int - if not chat_id: - logger.warning( - f"[TelegramNotificationSender] ❌ Chat ID не указан для {notification.recipient}" - ) - return False - - try: - telegram_id = int(chat_id) - logger.info( - f"[TelegramNotificationSender] Chat ID преобразован в int: {telegram_id}" - ) - except (ValueError, TypeError): - logger.error( - f"[TelegramNotificationSender] ❌ Неверный формат Chat ID: {chat_id}" - ) - return False - - # Получить site_url если не передан - if not site_url: - site_url = getattr(settings, 'SITE_URL', 'http://localhost:9000') - logger.info( - f"[TelegramNotificationSender] Site URL из настроек: {site_url}" - ) - - # Запускаем асинхронную отправку - logger.info( - f"[TelegramNotificationSender] ➡️ Запуск асинхронной отправки..." - ) - - try: - loop = asyncio.get_event_loop() - except RuntimeError: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - result = loop.run_until_complete( - cls.send_notification_async( - telegram_id, - notification, - site_url - ) - ) - - if result: - logger.info( - f"[TelegramNotificationSender] ✅ Успешно отправлено в Telegram (chat_id={telegram_id})" - ) - else: - logger.warning( - f"[TelegramNotificationSender] ⚠️ Отправка в Telegram вернула False" - ) - - return result - - @classmethod - def send_notification_by_username( - cls, - notification, - telegram_username: str, - site_url: str = None - ) -> bool: - """ - Отправка уведомления по @username без привязки аккаунта - - Args: - notification: объект Notification - telegram_username: @username или chat_id - site_url: базовый URL сайта - - Returns: - True если отправлено успешно - """ - if not site_url: - site_url = getattr(settings, 'SITE_URL', 'http://localhost:9000') - - # Убираем @ если есть - username = telegram_username.strip().lstrip('@') - - # Пробуем отправить по username - try: - loop = asyncio.get_event_loop() - except RuntimeError: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - async def send_by_username(): - bot = cls.get_bot() - if not bot: - return False - - try: - # Формируем сообщение - category_code = notification.notification_type.category.code - emoji = cls.CATEGORY_EMOJI.get(category_code, '🔔') - - text = f"{emoji} {notification.title}\n\n" - text += f"{notification.message}\n\n" - - # Кнопка действия - keyboard = None - if notification.action_url: - action_text = notification.action_text or 'Посмотреть' - if notification.action_url.startswith('http'): - url = notification.action_url - else: - url = f"{site_url}{notification.action_url}" - - keyboard = InlineKeyboardMarkup([ - [InlineKeyboardButton(action_text, url=url)] - ]) - - # Отправляем по @username - await bot.send_message( - chat_id=f"@{username}", - text=text, - parse_mode=ParseMode.HTML, - reply_markup=keyboard, - disable_web_page_preview=True, - ) - - logger.info( - f"Telegram отправлено @{username}: {notification.id}" - ) - return True - - except TelegramError as e: - error_msg = str(e) - if "chat not found" in error_msg.lower(): - logger.warning( - f"Пользователь @{username} не начал диалог с ботом. " - f"Необходимо отправить /start боту." - ) - elif "bot was blocked" in error_msg.lower(): - logger.warning( - f"Пользователь @{username} заблокировал бота." - ) - else: - logger.error( - f"Ошибка отправки @{username}: {e}", - exc_info=True - ) - return False - - return loop.run_until_complete(send_by_username()) - - @classmethod - async def send_message_async( - cls, - telegram_id: int, - text: str, - keyboard: Optional[InlineKeyboardMarkup] = None - ) -> bool: - """ - Отправка произвольного сообщения - - Args: - telegram_id: ID получателя - text: текст сообщения - keyboard: inline клавиатура (опционально) - - Returns: - True если отправлено успешно - """ - bot = cls.get_bot() - if not bot: - return False - - try: - await bot.send_message( - chat_id=telegram_id, - text=text, - parse_mode=ParseMode.HTML, - reply_markup=keyboard, - disable_web_page_preview=True, - ) - return True - except TelegramError as e: - logger.error(f"Ошибка отправки сообщения в Telegram: {e}") - return False - - @classmethod - def send_message( - cls, - telegram_id: int, - text: str, - keyboard: Optional[InlineKeyboardMarkup] = None - ) -> bool: - """Синхронная обёртка для send_message_async""" - try: - loop = asyncio.get_event_loop() - except RuntimeError: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - return loop.run_until_complete( - cls.send_message_async(telegram_id, text, keyboard) - ) - - @classmethod - def test_bot_connection(cls) -> bool: - """ - Тестирует подключение к Telegram боту - - Returns: - True если бот доступен и настроен правильно - """ - bot = cls.get_bot() - if not bot: - return False - - try: - loop = asyncio.get_event_loop() - except RuntimeError: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - async def test(): - try: - me = await bot.get_me() - logger.info( - f"Бот подключен: @{me.username} ({me.first_name})" - ) - return True - except TelegramError as e: - logger.error(f"Ошибка подключения к боту: {e}") - return False - - return loop.run_until_complete(test())