lizerk 5220a96684 refactor: Модуль notifications полностью самостоятельный и переиспользуемый
КРИТИЧНЫЕ УЛУЧШЕНИЯ:
✅ Убран хардкод 'EUSRR' → универсальное 'My Site'
✅ Созданы email шаблоны внутри модуля (4 файла)
✅ Централизованная конфигурация в config.py
✅ API перемещен в notifications/api/

Новые файлы:
- config.py - централизованные настройки с fallback значениями
- templates/notifications/email/*.html/*.txt - HTML и текстовые шаблоны
- api/__init__.py, api/views.py, api/urls.py - REST API внутри модуля

Изменения в существующих файлах:
- senders/email.py: использует config.site_name() и config.from_email()
- tasks/email.py: rate_limit, max_retries, retry_delay из config
- tasks/push.py: rate_limit, max_retries, retry_delay из config
- tasks/websocket.py: max_retries, retry_delay из config
- api/v1/notifications/urls.py: реэкспорт из notifications.api (обратная совместимость)

Структура шаблонов:
📁 notifications/templates/notifications/email/
  ├── notification.html - красивый HTML для обычных уведомлений
  ├── notification.txt - текстовая версия
  ├── digest.html - HTML дайджест (daily/weekly)
  └── digest.txt - текстовая версия дайджеста

Конфигурация (config.py):
- DEFAULT_CONFIG с fallback значениями для всех параметров
- get_config() - объединяет с settings.NOTIFICATIONS_CONFIG
- Удобные функции: email_rate_limit(), push_max_retries(), site_name()
- Полностью настраиваемо через settings проекта

API внутри модуля:
- notifications/api/views.py - все endpoint'ы
- notifications/api/urls.py - URL patterns
- Относительные импорты (from ..models)
- Обратная совместимость через api/v1/notifications/urls.py

УНИВЕРСАЛЬНОСТЬ:
✅ Нет хардкода проекта
✅ Все шаблоны внутри модуля
✅ API внутри модуля
✅ Конфигурация через settings
✅ Можно скопировать в любой проект Django 5.2+
2026-03-09 17:06:20 +03:00

234 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Email отправитель для системы уведомлений
TODO: Создать email шаблоны:
- templates/notifications/email/notification.html
- templates/notifications/email/notification.txt
- templates/notifications/email/digest.html
- templates/notifications/email/digest.txt
"""
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
from notifications import config
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': config.site_name(),
'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=config.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': config.site_name(),
'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}"