295 lines
11 KiB
Python
295 lines
11 KiB
Python
"""
|
||
Email отправитель для системы уведомлений
|
||
|
||
TODO: Создать email шаблоны:
|
||
- templates/notifications/email/notification.html
|
||
- templates/notifications/email/notification.txt
|
||
- templates/notifications/email/digest.html
|
||
- templates/notifications/email/digest.txt
|
||
"""
|
||
|
||
from django.conf import settings
|
||
from django.core.mail import send_mail
|
||
from django.template.loader import render_to_string
|
||
|
||
from notifications import config
|
||
from notifications.models import Notification
|
||
|
||
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, notification),
|
||
"action_text": "Посмотреть",
|
||
"site_name": config.site_name(),
|
||
"site_url": self._get_site_url(notification),
|
||
"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, frequency: str = "daily") -> bool:
|
||
"""
|
||
Отправляет email дайджест уведомлений.
|
||
|
||
Args:
|
||
user: User объект
|
||
notifications: Список объектов Notification
|
||
frequency: 'daily' или 'weekly'
|
||
|
||
Returns:
|
||
True если отправлено успешно, False иначе
|
||
"""
|
||
try:
|
||
notifications = list(notifications)
|
||
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(
|
||
notifications[0] if notifications else None
|
||
),
|
||
}
|
||
|
||
# Рендерим шаблоны
|
||
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,
|
||
)
|
||
|
||
# Отмечаем как отправленные. На вход может прийти как QuerySet,
|
||
# так и список из Celery-задачи, поэтому обновляем по id.
|
||
notification_ids = [item.id for item in notifications if item.id]
|
||
if notification_ids:
|
||
Notification.objects.filter(id__in=notification_ids).update(
|
||
emailed=True
|
||
)
|
||
|
||
self.logger.info(
|
||
"Email digest sent",
|
||
extra={
|
||
"event": "notification.email_digest.sent",
|
||
"user_id": user.id,
|
||
"notification_count": len(notifications),
|
||
"frequency": frequency,
|
||
},
|
||
)
|
||
return True
|
||
|
||
except Exception as e:
|
||
self.logger.exception(
|
||
"Email digest delivery failed",
|
||
extra={
|
||
"event": "notification.email_digest.failed",
|
||
"user_id": user.id,
|
||
"frequency": frequency,
|
||
"error_type": type(e).__name__,
|
||
},
|
||
)
|
||
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, notification=None) -> str:
|
||
"""
|
||
Получает базовый URL сайта.
|
||
|
||
Приоритет:
|
||
1. notification.data['site_url'] - если передан при создании
|
||
2. settings.SITE_URL - статическая настройка
|
||
3. settings.ALLOWED_HOSTS[0] - автоопределение из разрешенных хостов
|
||
4. 'http://localhost:9000' - fallback для разработки
|
||
|
||
Args:
|
||
notification: Объект Notification (опционально)
|
||
|
||
Returns:
|
||
Базовый URL сайта (например, 'https://example.com')
|
||
"""
|
||
# 1. Проверяем данные уведомления
|
||
if (
|
||
notification
|
||
and hasattr(notification, "data")
|
||
and isinstance(notification.data, dict)
|
||
):
|
||
if "site_url" in notification.data:
|
||
return notification.data["site_url"]
|
||
|
||
# 2. Проверяем settings.SITE_URL
|
||
if hasattr(settings, "SITE_URL"):
|
||
return settings.SITE_URL
|
||
|
||
# 3. Автоопределение из 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}"
|
||
|
||
# 4. Fallback для разработки
|
||
return "http://localhost:9000"
|
||
|
||
def _get_full_url(self, path: str, notification=None) -> str:
|
||
"""
|
||
Преобразует относительный URL в абсолютный.
|
||
|
||
Args:
|
||
path: Относительный или абсолютный URL
|
||
notification: Объект Notification для получения site_url
|
||
|
||
Returns:
|
||
Абсолютный URL
|
||
"""
|
||
if not path:
|
||
return ""
|
||
|
||
if path.startswith("http://") or path.startswith("https://"):
|
||
return path
|
||
|
||
site_url = self._get_site_url(notification)
|
||
return f"{site_url}{path}"
|