refactor: Базовый класс для всех отправителей уведомлений

Создана объектно-ориентированная архитектура для отправителей:

Новая структура:
- 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)
This commit is contained in:
lizerk 2026-03-09 16:04:48 +03:00
parent f4fa00d9bd
commit e1be95fbe4
6 changed files with 534 additions and 161 deletions

View File

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

View File

@ -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',
]

View File

@ -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}"
)

View File

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

View File

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

View File

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