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())