From e1be95fbe453115c0445face846120f12cc44278 Mon Sep 17 00:00:00 2001 From: lizerk Date: Mon, 9 Mar 2026 16:04:48 +0300 Subject: [PATCH] =?UTF-8?q?refactor:=20=D0=91=D0=B0=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=B2=D1=81=D0=B5=D1=85=20=D0=BE=D1=82=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D1=82=D0=B5=D0=BB=D0=B5=D0=B9=20=D1=83=D0=B2?= =?UTF-8?q?=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Создана объектно-ориентированная архитектура для отправителей: Новая структура: - BaseNotificationSender - абстрактный базовый класс - EmailNotificationSender(BaseNotificationSender) - WebSocketNotificationSender(BaseNotificationSender) - PushNotificationSender(BaseNotificationSender) Преимущества: ✅ Единый интерфейс send() для всех каналов ✅ can_send() - проверка возможности отправки ✅ Унифицированное логирование (log_success, log_error, log_skip) ✅ Легко расширять новыми каналами ✅ Переиспользуемость кода Изменения: - notifications/senders/base.py - базовый класс - notifications/senders/email.py - email отправитель (упрощен) - notifications/senders/websocket.py - WebSocket отправитель - notifications/senders/push.py - Web Push отправитель - notifications/channels.py - обновлен для использования классов Старые функции send_* сохранены для обратной совместимости (deprecated) --- backend/notifications/channels.py | 193 +++--------------- backend/notifications/senders/__init__.py | 14 ++ backend/notifications/senders/base.py | 75 +++++++ backend/notifications/senders/email.py | 226 +++++++++++++++++++++ backend/notifications/senders/push.py | 100 +++++++++ backend/notifications/senders/websocket.py | 87 ++++++++ 6 files changed, 534 insertions(+), 161 deletions(-) create mode 100644 backend/notifications/senders/__init__.py create mode 100644 backend/notifications/senders/base.py create mode 100644 backend/notifications/senders/email.py create mode 100644 backend/notifications/senders/push.py create mode 100644 backend/notifications/senders/websocket.py diff --git a/backend/notifications/channels.py b/backend/notifications/channels.py index 47c3b211..87c4d7f6 100644 --- a/backend/notifications/channels.py +++ b/backend/notifications/channels.py @@ -9,11 +9,14 @@ from django.db.models.signals import post_save from django.dispatch import receiver -from django.conf import settings -from channels.layers import get_channel_layer -from asgiref.sync import async_to_sync import logging +from .senders import ( + EmailNotificationSender, + WebSocketNotificationSender, + PushNotificationSender, +) + logger = logging.getLogger(__name__) @@ -53,13 +56,16 @@ def handle_notification_channels(sender, instance, created, **kwargs): # Отправляем по каналам согласно настройкам if prefs.web_enabled: - send_websocket_notification(notification) + sender = WebSocketNotificationSender() + sender.send(notification, silent=prefs.is_in_dnd_period()) if prefs.email_enabled and prefs.email_frequency == 'instant': - send_email_notification(notification) + sender = EmailNotificationSender() + sender.send(notification) if prefs.push_enabled: - send_push_notification(notification) + sender = PushNotificationSender() + sender.send(notification) def send_websocket_notification(notification, silent=False): @@ -69,53 +75,11 @@ def send_websocket_notification(notification, silent=False): Args: notification: Объект Notification silent: Если True, уведомление без звука/визуальных эффектов + + DEPRECATED: Используйте WebSocketNotificationSender().send() """ - try: - channel_layer = get_channel_layer() - user_channel = f"user_{notification.recipient.id}" - - # Формируем данные для фронтенда - message_data = { - 'type': 'notification', - 'id': notification.id, - 'verb': notification.verb, - 'description': notification.description, - 'action_url': notification.action_url, - 'timestamp': notification.timestamp.isoformat(), - 'unread': notification.unread, - 'silent': silent, - 'data': notification.data, - } - - # Добавляем actor если есть - if notification.actor: - message_data['actor'] = { - 'type': notification.actor_content_type.model, - 'id': notification.actor_object_id, - 'str': str(notification.actor), - } - - # Добавляем target если есть - if notification.target: - message_data['target'] = { - 'type': notification.target_content_type.model, - 'id': notification.target_object_id, - 'str': str(notification.target), - } - - # Отправляем через Channels - async_to_sync(channel_layer.group_send)( - user_channel, - { - 'type': 'notification_message', - 'message': message_data - } - ) - - logger.info(f'WebSocket notification sent to user {notification.recipient.id}') - - except Exception as e: - logger.error(f'Failed to send WebSocket notification: {e}', exc_info=True) + sender = WebSocketNotificationSender() + return sender.send(notification, silent=silent) def send_email_notification(notification): @@ -124,42 +88,11 @@ def send_email_notification(notification): Args: notification: Объект Notification + + DEPRECATED: Используйте EmailNotificationSender().send() """ - try: - from common.emails import send_email - - user = notification.recipient - - # Формируем email - subject = f'Уведомление: {notification.verb}' - - context = { - 'notification': notification, - 'user': user, - 'action_url': notification.action_url, - 'site_url': settings.SITE_URL if hasattr(settings, 'SITE_URL') else 'http://localhost:8000', - } - - # Используем общий шаблон или специфичный для verb - template_name = f'notifications/email/{notification.verb}.html' - fallback_template = 'notifications/email/default.html' - - send_email( - subject=subject, - template_name=template_name, - fallback_template=fallback_template, - context=context, - recipient_list=[user.email], - ) - - # Отмечаем что отправлено - notification.emailed = True - notification.save(update_fields=['emailed']) - - logger.info(f'Email notification sent to {user.email}') - - except Exception as e: - logger.error(f'Failed to send email notification: {e}', exc_info=True) + sender = EmailNotificationSender() + return sender.send(notification) def send_push_notification(notification): @@ -168,60 +101,11 @@ def send_push_notification(notification): Args: notification: Объект Notification + + DEPRECATED: Используйте PushNotificationSender().send() """ - try: - from push_notifications.models import WebPushDevice - from .web_push_models import WebPushSubscription - - user = notification.recipient - - # Получаем активные подписки - subscriptions = WebPushSubscription.objects.filter( - user=user, - is_active=True - ) - - if not subscriptions.exists(): - logger.debug(f'User {user.id} has no active push subscriptions') - return - - # Формируем данные для push - actor_str = str(notification.actor) if notification.actor else 'Система' - title = f'{actor_str} {notification.verb}' - body = notification.description or '' - - push_data = { - 'head': title, - 'body': body, - 'icon': '/static/img/logo.png', - 'url': notification.action_url or '/', - 'tag': f'notification-{notification.id}', - 'requireInteraction': False, - } - - # Отправляем через django-push-notifications - for sub in subscriptions: - try: - # Используем push_notifications для отправки - # (предполагается что WebPushSubscription связана с WebPushDevice) - device = WebPushDevice.objects.filter( - registration_id=sub.endpoint - ).first() - - if device: - device.send_message( - message=push_data, - extra={ - 'notification_id': notification.id - } - ) - logger.info(f'Push notification sent to device {device.id}') - - except Exception as e: - logger.error(f'Failed to send push to subscription {sub.id}: {e}') - - except Exception as e: - logger.error(f'Failed to send push notification: {e}', exc_info=True) + sender = PushNotificationSender() + return sender.send(notification) # === Digest Email (для email_frequency = daily/weekly) === @@ -237,7 +121,6 @@ def send_email_digest(user, frequency='daily'): try: from datetime import timedelta from django.utils import timezone - from common.emails import send_email from .models_new import Notification # Определяем период @@ -260,27 +143,15 @@ def send_email_digest(user, frequency='daily'): logger.debug(f'No notifications to digest for user {user.id}') return - # Формируем email - subject = f'Дайджест уведомлений за {"день" if frequency == "daily" else "неделю"}' + # Используем EmailNotificationSender для отправки дайджеста + sender = EmailNotificationSender() + success = sender.send_digest(user, notifications, frequency) - context = { - 'user': user, - 'notifications': notifications, - 'count': notifications.count(), - 'site_url': settings.SITE_URL if hasattr(settings, 'SITE_URL') else 'http://localhost:8000', - } - - send_email( - subject=subject, - template_name='notifications/email/digest.html', - context=context, - recipient_list=[user.email], - ) - - # Отмечаем как отправленные - notifications.update(emailed=True) - - logger.info(f'Email digest ({frequency}) sent to {user.email} with {notifications.count()} notifications') + if success: + logger.info( + f'Email digest ({frequency}) sent to {user.email} ' + f'with {notifications.count()} notifications' + ) except Exception as e: logger.error(f'Failed to send email digest: {e}', exc_info=True) diff --git a/backend/notifications/senders/__init__.py b/backend/notifications/senders/__init__.py new file mode 100644 index 00000000..cc68a049 --- /dev/null +++ b/backend/notifications/senders/__init__.py @@ -0,0 +1,14 @@ +""" +Отправители уведомлений по различным каналам +""" +from .base import BaseNotificationSender +from .email import EmailNotificationSender +from .websocket import WebSocketNotificationSender +from .push import PushNotificationSender + +__all__ = [ + 'BaseNotificationSender', + 'EmailNotificationSender', + 'WebSocketNotificationSender', + 'PushNotificationSender', +] diff --git a/backend/notifications/senders/base.py b/backend/notifications/senders/base.py new file mode 100644 index 00000000..63558552 --- /dev/null +++ b/backend/notifications/senders/base.py @@ -0,0 +1,75 @@ +""" +Базовый класс для всех отправителей уведомлений +""" +from abc import ABC, abstractmethod +from typing import Optional +import logging + +logger = logging.getLogger(__name__) + + +class BaseNotificationSender(ABC): + """ + Абстрактный базовый класс для отправителей уведомлений. + + Все отправители должны наследоваться от этого класса и реализовать метод send(). + """ + + def __init__(self): + self.logger = logging.getLogger(self.__class__.__name__) + + @abstractmethod + def send(self, notification, **kwargs) -> bool: + """ + Отправляет уведомление через конкретный канал. + + Args: + notification: Объект Notification + **kwargs: Дополнительные параметры для канала + + Returns: + True если отправлено успешно, False иначе + """ + pass + + def can_send(self, notification, user_preferences) -> bool: + """ + Проверяет, можно ли отправить уведомление через этот канал. + + Args: + notification: Объект Notification + user_preferences: UserChannelPreferences + + Returns: + True если можно отправлять, False иначе + """ + return True + + def log_success(self, notification, recipient: str): + """Логирует успешную отправку""" + self.logger.info( + f"✅ Уведомление отправлено: " + f"notification_id={notification.id}, " + f"recipient={recipient}, " + f"channel={self.__class__.__name__}" + ) + + def log_error(self, notification, error: Exception, recipient: Optional[str] = None): + """Логирует ошибку отправки""" + self.logger.error( + f"❌ Ошибка отправки: " + f"notification_id={notification.id}, " + f"recipient={recipient}, " + f"channel={self.__class__.__name__}, " + f"error={type(error).__name__}: {error}", + exc_info=True + ) + + def log_skip(self, notification, reason: str): + """Логирует пропуск отправки""" + self.logger.debug( + f"⏭️ Отправка пропущена: " + f"notification_id={notification.id}, " + f"channel={self.__class__.__name__}, " + f"reason={reason}" + ) diff --git a/backend/notifications/senders/email.py b/backend/notifications/senders/email.py new file mode 100644 index 00000000..9ae676d3 --- /dev/null +++ b/backend/notifications/senders/email.py @@ -0,0 +1,226 @@ +""" +Email отправитель для системы уведомлений +""" +from typing import Optional + +from django.conf import settings +from django.template.loader import render_to_string +from django.core.mail import send_mail + +from .base import BaseNotificationSender + + +class EmailNotificationSender(BaseNotificationSender): + """ + Отправитель email уведомлений. + Использует HTML шаблоны для красивого оформления. + """ + + # Маппинг verb на эмодзи для email + VERB_ICONS = { + 'liked': '❤️', + 'commented': '💬', + 'mentioned': '@', + 'approved': '✅', + 'rejected': '❌', + 'created': '📝', + 'updated': '🔄', + 'deleted': '🗑️', + 'shared': '🔗', + 'followed': '👥', + } + + def can_send(self, notification, user_preferences) -> bool: + """Проверяет, включена ли отправка email""" + if not user_preferences.email_enabled: + self.log_skip(notification, "email_enabled=False") + return False + + if user_preferences.email_frequency != 'instant': + self.log_skip(notification, f"email_frequency={user_preferences.email_frequency}") + return False + + return True + + def send(self, notification, **kwargs) -> bool: + """ + Отправляет email уведомление. + + Args: + notification: Объект Notification + **kwargs: + custom_subject: кастомная тема письма (опционально) + recipient_email: явный email получателя (опционально) + + Returns: + True если отправлено успешно, False иначе + """ + try: + user = notification.recipient + recipient_email = kwargs.get('recipient_email', user.email) + + if not recipient_email: + self.log_skip(notification, "no email address") + return False + + # Формируем тему письма + custom_subject = kwargs.get('custom_subject') + if custom_subject: + subject = custom_subject + else: + icon = self.VERB_ICONS.get(notification.verb, '🔔') + actor_str = str(notification.actor) if notification.actor else 'Система' + subject = f"{icon} {actor_str} {notification.verb}" + + # Формируем контекст для шаблона + context = { + 'notification': notification, + 'actor': notification.actor, + 'verb': notification.verb, + 'description': notification.description, + 'action_url': self._get_full_url(notification.action_url), + 'action_text': 'Посмотреть', + 'site_name': getattr(settings, 'SITE_NAME', 'EUSRR'), + 'site_url': self._get_site_url(), + 'verb_icon': self.VERB_ICONS.get(notification.verb, '🔔'), + } + + # Рендерим HTML и текстовую версию + html_message = self._render_template('notifications/email/notification.html', context) + text_message = self._render_template('notifications/email/notification.txt', context) + + # Отправляем email + send_mail( + subject=subject, + message=text_message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[recipient_email], + html_message=html_message, + fail_silently=False, + ) + + # Отмечаем что отправлено + notification.emailed = True + notification.save(update_fields=['emailed']) + + self.log_success(notification, recipient_email) + return True + + except Exception as e: + self.log_error(notification, e, recipient_email) + return False + + def send_digest(self, user, notifications: list, frequency: str = 'daily') -> bool: + """ + Отправляет email дайджест уведомлений. + + Args: + user: User объект + notifications: Список объектов Notification + frequency: 'daily' или 'weekly' + + Returns: + True если отправлено успешно, False иначе + """ + try: + if not notifications: + return False + + recipient_email = user.email + if not recipient_email: + return False + + # Формируем тему + digest_name = 'Ежедневный' if frequency == 'daily' else 'Еженедельный' + subject = f"📬 {digest_name} дайджест уведомлений ({len(notifications)})" + + # Группируем по verb + grouped = {} + for notif in notifications: + verb = notif.verb + if verb not in grouped: + grouped[verb] = [] + grouped[verb].append(notif) + + # Формируем контекст + context = { + 'user': user, + 'notifications': notifications, + 'grouped_notifications': grouped, + 'frequency': frequency, + 'digest_name': digest_name, + 'total_count': len(notifications), + 'verb_icons': self.VERB_ICONS, + 'site_name': getattr(settings, 'SITE_NAME', 'EUSRR'), + 'site_url': self._get_site_url(), + } + + # Рендерим шаблоны + html_message = self._render_template('notifications/email/digest.html', context) + text_message = self._render_template('notifications/email/digest.txt', context) + + # Отправляем дайджест + send_mail( + subject=subject, + message=text_message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[recipient_email], + html_message=html_message, + fail_silently=False, + ) + + # Отмечаем как отправленные + notifications.update(emailed=True) + + self.logger.info( + f"✅ Email дайджест отправлен: " + f"user={user.id}, " + f"count={len(notifications)}, " + f"frequency={frequency}, " + f"recipient={recipient_email}" + ) + return True + + except Exception as e: + self.logger.error( + f"❌ Ошибка отправки дайджеста: " + f"user={user.id}, " + f"frequency={frequency}, " + f"error={type(e).__name__}: {e}", + exc_info=True + ) + return False + + def _render_template(self, template_name: str, context: dict) -> str: + """Рендерит шаблон с контекстом""" + try: + return render_to_string(template_name, context) + except Exception: + # Если шаблон не найден, возвращаем простой текст + notif = context.get('notification') + if notif: + return f"{notif.description}\n\n{context.get('action_url', '')}" + return "" + + def _get_site_url(self) -> str: + """Получает базовый URL сайта из настроек.""" + if hasattr(settings, 'SITE_URL'): + return settings.SITE_URL + + 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' + + def _get_full_url(self, path: str) -> str: + """Преобразует относительный URL в абсолютный.""" + if not path: + return '' + + if path.startswith('http://') or path.startswith('https://'): + return path + + site_url = self._get_site_url() + return f"{site_url}{path}" diff --git a/backend/notifications/senders/push.py b/backend/notifications/senders/push.py new file mode 100644 index 00000000..aeac28c0 --- /dev/null +++ b/backend/notifications/senders/push.py @@ -0,0 +1,100 @@ +""" +Web Push отправитель для browser push notifications +""" +from django.conf import settings + +from .base import BaseNotificationSender + + +class PushNotificationSender(BaseNotificationSender): + """ + Отправитель Web Push уведомлений через django-push-notifications. + Доставляет уведомления в браузер даже когда вкладка закрыта. + """ + + def can_send(self, notification, user_preferences) -> bool: + """Проверяет, включены ли push уведомления""" + if not user_preferences.push_enabled: + self.log_skip(notification, "push_enabled=False") + return False + return True + + def send(self, notification, **kwargs) -> bool: + """ + Отправляет Web Push уведомление. + + Args: + notification: Объект Notification + **kwargs: Дополнительные параметры + + Returns: + True если отправлено успешно, False иначе + """ + try: + from push_notifications.models import WebPushDevice + from ..web_push_models import WebPushSubscription + + user = notification.recipient + + # Получаем активные подписки + subscriptions = WebPushSubscription.objects.filter( + user=user, + is_active=True + ) + + if not subscriptions.exists(): + self.log_skip(notification, "no active push subscriptions") + return False + + # Формируем данные для push + actor_str = str(notification.actor) if notification.actor else 'Система' + title = f'{actor_str} {notification.verb}' + body = notification.description or '' + + push_data = { + 'head': title, + 'body': body, + 'icon': '/static/img/logo.png', + 'url': notification.action_url or '/', + 'tag': f'notification-{notification.id}', + 'requireInteraction': False, + } + + sent_count = 0 + # Отправляем через django-push-notifications + for sub in subscriptions: + try: + # Используем push_notifications для отправки + device = WebPushDevice.objects.filter( + registration_id=sub.endpoint + ).first() + + if device: + device.send_message( + message=push_data, + extra={ + 'notification_id': notification.id + } + ) + sent_count += 1 + self.logger.debug(f"Push отправлен на device {device.id}") + + except Exception as e: + self.logger.error( + f"Ошибка отправки push на subscription {sub.id}: {e}" + ) + continue + + if sent_count > 0: + self.log_success(notification, f"{sent_count} devices") + return True + else: + self.log_skip(notification, "no successful sends") + return False + + except ImportError: + self.log_skip(notification, "django-push-notifications not installed") + return False + except Exception as e: + self.log_error(notification, e, f"user_{notification.recipient.id}") + return False diff --git a/backend/notifications/senders/websocket.py b/backend/notifications/senders/websocket.py new file mode 100644 index 00000000..4e569d78 --- /dev/null +++ b/backend/notifications/senders/websocket.py @@ -0,0 +1,87 @@ +""" +WebSocket отправитель для realtime уведомлений +""" +from channels.layers import get_channel_layer +from asgiref.sync import async_to_sync + +from .base import BaseNotificationSender + + +class WebSocketNotificationSender(BaseNotificationSender): + """ + Отправитель WebSocket уведомлений через Django Channels. + Обеспечивает realtime доставку в браузер. + """ + + def can_send(self, notification, user_preferences) -> bool: + """Проверяет, включены ли веб уведомления""" + if not user_preferences.web_enabled: + self.log_skip(notification, "web_enabled=False") + return False + return True + + def send(self, notification, silent: bool = False, **kwargs) -> bool: + """ + Отправляет уведомление через WebSocket. + + Args: + notification: Объект Notification + silent: Если True, уведомление без звука/визуальных эффектов + **kwargs: Дополнительные параметры + + Returns: + True если отправлено успешно, False иначе + """ + try: + channel_layer = get_channel_layer() + if not channel_layer: + self.log_skip(notification, "channel_layer not configured") + return False + + user_id = notification.recipient.id + user_channel = f"user_{user_id}" + + # Формируем данные для фронтенда + message_data = { + 'type': 'notification', + 'id': notification.id, + 'verb': notification.verb, + 'description': notification.description, + 'action_url': notification.action_url, + 'timestamp': notification.timestamp.isoformat(), + 'unread': notification.unread, + 'silent': silent, + 'data': notification.data, + } + + # Добавляем actor если есть + if notification.actor: + message_data['actor'] = { + 'type': notification.actor_content_type.model, + 'id': notification.actor_object_id, + 'str': str(notification.actor), + } + + # Добавляем target если есть + if notification.target: + message_data['target'] = { + 'type': notification.target_content_type.model, + 'id': notification.target_object_id, + 'str': str(notification.target), + } + + # Отправляем через Channels + async_to_sync(channel_layer.group_send)( + user_channel, + { + 'type': 'notification_message', + 'message': message_data + } + ) + + self.log_success(notification, f"user_{user_id}") + return True + + except Exception as e: + self.log_error(notification, e, f"user_{notification.recipient.id}") + return False