refactor: Удалены старые отправители уведомлений

Очистка от дублирующегося кода:

Удалены старые файлы:
❌ 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
- Готовы к полной миграции на новую архитектуру
This commit is contained in:
lizerk 2026-03-09 16:07:58 +03:00
parent e1be95fbe4
commit fd9b36c1c9
5 changed files with 14 additions and 796 deletions

View File

@ -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

View File

@ -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

View File

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

View File

@ -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=(
"⚠️ <b>Аккаунт отвязан</b>\n\n"
"Ваш Telegram аккаунт был отвязан от EUSRR, "
"так как вы изменили поле 'Telegram' "
"в своём профиле.\n\n"
"Для повторной привязки используйте /link"
),
)
except Exception as e:
logger.error(
f"Не удалось отправить уведомление об отвязке: {e}"
)
except Employee.DoesNotExist:
# Объект был удален между проверкой и сохранением
pass

View File

@ -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} <b>{notification.title}</b>\n\n"
text += f"{notification.message}\n\n"
text += f"<i>Категория: {category_name}</i>"
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} <b>{notification.title}</b>\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())