Corp/backend/notifications/services.py
Игорь Надеин 44488cb40e feat: Implement Web Push notifications with VAPID
- Split notification sending into separate WebSocket and Web Push channels
- Add Web Push support for offline users
- Configure VAPID keys for push authentication
- Add 'aud' field to VAPID claims for WNS compatibility
- Optimize subscription sync - only POST when endpoint changes
- Remove debug logs and minimize server logging
- Update Service Worker with improved error handling
- Add Service-Worker-Allowed header for proper scope

Web Push now works independently from WebSocket, enabling
notifications when browser is closed (requires HTTPS in production)
2026-01-06 07:48:04 +03:00

643 lines
25 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

"""
Сервисный слой для работы с уведомлениями
"""
import json
import logging
from typing import Any, Dict, Optional
from django.contrib.auth import get_user_model
from django.utils import timezone
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync
from .models import (
Notification,
NotificationType,
UserNotificationSettings,
)
from .email_sender import EmailNotificationSender
from .telegram_sender import TelegramNotificationSender
logger = logging.getLogger(__name__)
User = get_user_model()
class NotificationService:
"""Сервис для создания и отправки уведомлений"""
@staticmethod
def create_notification(
recipient: User,
notification_type_code: str,
title: str,
message: str,
content_object=None,
action_url: str = '',
action_text: str = 'Посмотреть',
metadata: Optional[Dict[str, Any]] = None,
send_immediately: bool = True,
) -> Optional[Notification]:
"""
Создать и отправить уведомление
Args:
recipient: Получатель уведомления
notification_type_code: Код типа уведомления
title: Заголовок
message: Текст сообщения
content_object: Связанный объект (опционально)
action_url: URL для действия
action_text: Текст кнопки действия
metadata: Дополнительные данные
send_immediately: Отправить сразу или отложить
Returns:
Созданное уведомление или None если тип не найден
"""
logger.info(
f"[NotificationService.create_notification] НАЧАЛО: "
f"recipient={recipient.id} ({recipient.email}), "
f"type={notification_type_code}, "
f"send_immediately={send_immediately}, "
f"title='{title[:50]}...'"
)
try:
notification_type = NotificationType.objects.get(
code=notification_type_code,
is_active=True
)
logger.info(
f"[NotificationService.create_notification] Тип уведомления найден: "
f"{notification_type.name} (category={notification_type.category.code})"
)
except NotificationType.DoesNotExist:
logger.error(
f"[NotificationService.create_notification] ❌ ОШИБКА: "
f"Тип уведомления '{notification_type_code}' не найден или неактивен!"
)
return None
# Проверить настройки пользователя
settings = NotificationService.get_user_settings(
recipient, notification_type
)
logger.info(
f"[NotificationService.create_notification] Настройки пользователя: "
f"enabled={settings.is_enabled}, "
f"web={settings.send_web}, "
f"email={settings.send_email}, "
f"telegram={settings.send_telegram}"
)
if not settings.is_enabled:
logger.warning(
f"[NotificationService.create_notification] ⏭️ ПРОПУСК: "
f"Уведомления отключены для user={recipient.id}"
)
return None
# Создать краткое сообщение
short_message = message[:150] if len(message) > 150 else message
# Создать уведомление
notification = Notification.objects.create(
recipient=recipient,
notification_type=notification_type,
title=title,
message=message,
short_message=short_message,
content_object=content_object,
action_url=action_url,
action_text=action_text,
metadata=metadata or {},
)
logger.info(
f"[NotificationService.create_notification] ✅ Уведомление создано: "
f"id={notification.id}, "
f"action_url={action_url}"
)
# Отправить если нужно
if send_immediately:
logger.info(
f"[NotificationService.create_notification] ➡️ Немедленная отправка notification_id={notification.id}"
)
NotificationService.send_notification(notification, settings)
else:
logger.info(
f"[NotificationService.create_notification] ⏸️ Отложенная отправка notification_id={notification.id}"
)
return notification
@staticmethod
def send_notification(
notification: Notification,
settings: Optional[UserNotificationSettings] = None
):
"""Отправить уведомление по всем каналам"""
logger.info(
f"\n{'='*80}\n"
f"[NotificationService.send_notification] 📤 НАЧАЛО ОТПРАВКИ\n"
f" notification_id={notification.id}\n"
f" recipient={notification.recipient.id} ({notification.recipient.email})\n"
f" type={notification.notification_type.code}\n"
f" title='{notification.title}'\n"
f"{'='*80}"
)
if settings is None:
settings = NotificationService.get_user_settings(
notification.recipient,
notification.notification_type
)
logger.info(
f"[NotificationService.send_notification] Активные каналы: "
f"{'✅' if settings.send_web else '❌'} Web, "
f"{'✅' if settings.send_email else '❌'} Email, "
f"{'✅' if settings.send_telegram else '❌'} Telegram"
)
# Отправить на веб (WebSocket) - для онлайн пользователей
if settings.send_web:
logger.info(
f"[NotificationService.send_notification] 🌐 Отправка через WebSocket..."
)
try:
# Отправляем WebSocket только для онлайн уведомлений (не push)
NotificationService.send_web_socket(notification)
notification.sent_web = True
logger.info(
f"[NotificationService.send_notification] ✅ WebSocket: успешно отправлено"
)
except Exception as e:
logger.error(
f"[NotificationService.send_notification] ❌ WebSocket: ошибка - {e}",
exc_info=True
)
else:
logger.info(
f"[NotificationService.send_notification] ⏭️ WebSocket: канал отключен"
)
# Отправить Web Push (для offline пользователей)
# ВАЖНО: отправляем независимо от send_web настройки,
# если есть активные push-подписки
if settings.send_web:
logger.info(
f"[NotificationService.send_notification] 📲 Отправка Web Push (offline)..."
)
try:
push_count = NotificationService.send_web_push_notification(notification)
if push_count > 0:
logger.info(
f"[NotificationService.send_notification] ✅ Web Push: отправлено на {push_count} устройств"
)
except Exception as e:
logger.error(
f"[NotificationService.send_notification] ❌ Web Push: ошибка - {e}",
exc_info=True
)
else:
logger.info(
f"[NotificationService.send_notification] ⏭️ Web Push: канал отключен"
)
# Отправить на email
if settings.send_email:
logger.info(
f"[NotificationService.send_notification] 📧 Отправка Email..."
)
try:
recipient_email = notification.recipient.email
if recipient_email:
logger.info(
f"[NotificationService.send_notification] Email адрес: {recipient_email}"
)
success = EmailNotificationSender.send_notification_email(
notification=notification,
recipient_email=recipient_email,
)
notification.sent_email = success
if success:
logger.info(
f"[NotificationService.send_notification] ✅ Email: успешно отправлено на {recipient_email}"
)
else:
logger.warning(
f"[NotificationService.send_notification] ⚠️ Email: отправка вернула False"
)
else:
notification.sent_email = False
logger.warning(
f"[NotificationService.send_notification] ⚠️ Email: у пользователя {notification.recipient} нет email адреса"
)
except Exception as e:
notification.sent_email = False
logger.error(
f"[NotificationService.send_notification] ❌ Email: ошибка отправки - {e}",
exc_info=True
)
else:
logger.info(
f"[NotificationService.send_notification] ⏭️ Email: канал отключен"
)
# Отправить в Telegram
if settings.send_telegram:
logger.info(
f"[NotificationService.send_notification] 💬 Отправка в Telegram..."
)
logger.info(
f"[NotificationService.send_notification] Получатель: user_id={notification.recipient.id}"
)
try:
# Проверяем наличие Telegram Chat ID в профиле
telegram_chat_id = getattr(
notification.recipient, 'telegram', ''
)
if telegram_chat_id:
telegram_chat_id = telegram_chat_id.strip()
logger.info(
f"[NotificationService.send_notification] Telegram Chat ID: '{telegram_chat_id}'"
)
if telegram_chat_id:
# Получаем site_url для формирования полных ссылок
from django.conf import settings as django_settings
site_url = getattr(
django_settings,
'SITE_URL',
'http://localhost:9000'
)
logger.info(
f"[NotificationService.send_notification] ➡️ Вызов TelegramNotificationSender.send_notification"
)
# Отправляем через 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.info(
f"[NotificationService.send_notification] ✅ Telegram: успешно отправлено (chat_id={telegram_chat_id})"
)
else:
logger.warning(
f"[NotificationService.send_notification] ⚠️ Telegram: отправка вернула False"
)
else:
notification.sent_telegram = False
logger.warning(
f"[NotificationService.send_notification] ⚠️ Telegram: у пользователя нет Chat ID"
)
except Exception as e:
notification.sent_telegram = False
logger.error(
f"[NotificationService.send_notification] ❌ Telegram: ошибка - {e}",
exc_info=True
)
else:
logger.info(
f"[NotificationService.send_notification] ⏭️ Telegram: канал отключен"
)
# Обновить время отправки
notification.sent_at = timezone.now()
notification.save(update_fields=[
'sent_web',
'sent_email',
'sent_telegram',
'sent_whatsapp',
'sent_wechat',
'sent_at'
])
logger.info(
f"\n{'='*80}\n"
f"[NotificationService.send_notification] 🎯 ЗАВЕРШЕНО\n"
f" notification_id={notification.id}\n"
f" Результаты: "
f"Web={'\u2705' if notification.sent_web else '\u274c'}, "
f"Email={'\u2705' if notification.sent_email else '\u274c'}, "
f"Telegram={'\u2705' if notification.sent_telegram else '\u274c'}\n"
f"{'='*80}"
)
@staticmethod
def send_web_socket(notification: Notification):
"""
Отправить уведомление через WebSocket (только для онлайн пользователей).
Переименовано из send_web_notification для ясности.
"""
logger.info(
f"[NotificationService.send_web_socket] НАЧАЛО: "
f"notification_id={notification.id}, "
f"recipient={notification.recipient.id}"
)
channel_layer = get_channel_layer()
notification_data = {
'id': notification.id,
'title': notification.title,
'message': notification.short_message,
'category': notification.notification_type.category.code,
'category_name': notification.notification_type.category.name,
'icon': notification.notification_type.category.icon,
'color': notification.notification_type.category.color,
'priority': notification.notification_type.priority,
'action_url': notification.action_url,
'action_text': notification.action_text,
'created_at': notification.created_at.isoformat(),
}
group_name = f'notifications_{notification.recipient.id}'
logger.info(
f"[NotificationService.send_web_socket] Отправка в группу: {group_name}"
)
async_to_sync(channel_layer.group_send)(
group_name,
{
'type': 'notification_new',
'notification': notification_data
}
)
logger.info(
f"[NotificationService.send_web_socket] ✅ Уведомление отправлено в WebSocket"
)
# Обновить счетчик
unread_count = Notification.objects.filter(
recipient=notification.recipient,
is_read=False,
is_archived=False
).count()
logger.info(
f"[NotificationService.send_web_socket] Обновление счетчика: {unread_count} непрочитанных"
)
async_to_sync(channel_layer.group_send)(
group_name,
{
'type': 'notification_count_update',
'count': unread_count
}
)
logger.info(
f"[NotificationService.send_web_socket] ✅ Счетчик обновлен"
)
@staticmethod
def send_web_push_notification(notification: Notification) -> int:
"""
Отправить push-уведомление через Web Push API.
Работает даже когда браузер закрыт.
Args:
notification: Объект уведомления
Returns:
Количество успешно отправленных push-уведомлений
"""
from django.conf import settings as django_settings
from .models import WebPushSubscription
logger.info(
f"[NotificationService.send_web_push_notification] НАЧАЛО: "
f"notification_id={notification.id}, "
f"recipient={notification.recipient.id}"
)
# Проверяем наличие VAPID ключей
vapid_private_key = getattr(django_settings, 'VAPID_PRIVATE_KEY', None)
vapid_public_key = getattr(django_settings, 'VAPID_PUBLIC_KEY', None)
vapid_email = getattr(django_settings, 'VAPID_ADMIN_EMAIL', 'admin@example.com')
if not vapid_private_key or not vapid_public_key:
logger.warning(
f"[NotificationService.send_web_push_notification] ⚠️ VAPID ключи не настроены"
)
return 0
# Получаем все активные подписки пользователя
subscriptions = WebPushSubscription.objects.filter(
user=notification.recipient,
is_active=True
)
subscription_count = subscriptions.count()
if subscription_count == 0:
logger.info(
f"[NotificationService.send_web_push_notification] ℹ️ Нет активных push-подписок"
)
return 0
logger.info(
f"[NotificationService.send_web_push_notification] Найдено подписок: {subscription_count}"
)
# Формируем payload для push-уведомления
payload = {
'title': notification.title,
'body': notification.short_message or notification.message[:150],
'tag': f'notification-{notification.id}',
'icon': '/static/img/logo-192.png',
'badge': '/static/img/badge-72.png',
'data': {
'url': notification.action_url or '/',
'notification_id': notification.id,
'category': notification.notification_type.category.code,
},
'requireInteraction': notification.notification_type.priority in ['high', 'urgent'],
}
try:
from pywebpush import webpush, WebPushException
except ImportError:
logger.error(
f"[NotificationService.send_web_push_notification] ❌ pywebpush не установлен"
)
return 0
success_count = 0
failed_subscriptions = []
for subscription in subscriptions:
try:
subscription_info = {
'endpoint': subscription.endpoint,
'keys': {
'p256dh': subscription.p256dh_key,
'auth': subscription.auth_key
}
}
# Извлекаем origin из endpoint для поля 'aud' в VAPID claims
from urllib.parse import urlparse
parsed_url = urlparse(subscription.endpoint)
audience = f"{parsed_url.scheme}://{parsed_url.netloc}"
vapid_claims = {
'sub': f'mailto:{vapid_email}',
'aud': audience
}
webpush(
subscription_info=subscription_info,
data=json.dumps(payload),
vapid_private_key=vapid_private_key,
vapid_claims=vapid_claims,
ttl=86400 # 24 часа
)
success_count += 1
subscription.reset_errors()
except WebPushException as e:
logger.warning(f"[WebPush] Error: {e}")
# Обрабатываем ошибки
subscription.error_count += 1
subscription.last_error = str(e)[:500]
# Если подписка устарела (410 Gone) или недействительна - деактивируем
if e.response and e.response.status_code in [404, 410]:
subscription.is_active = False
logger.info(f"[WebPush] Subscription {subscription.id} deactivated (expired)")
# Если слишком много ошибок - деактивируем
if subscription.error_count >= 5:
subscription.is_active = False
logger.info(f"[WebPush] Subscription {subscription.id} deactivated (too many errors)")
subscription.save(update_fields=['error_count', 'last_error', 'is_active', 'updated_at'])
except Exception as e:
logger.error(f"[WebPush] Unexpected error: {e}")
logger.info(
f"[NotificationService.send_web_push_notification] ✅ Завершено: "
f"{success_count}/{subscription_count} успешно"
)
return success_count
@staticmethod
def get_user_settings(
user: User,
notification_type: NotificationType
) -> UserNotificationSettings:
"""Получить или создать настройки пользователя"""
settings, created = UserNotificationSettings.objects.get_or_create(
user=user,
notification_type=notification_type,
defaults={
'is_enabled': notification_type.default_enabled,
'send_web': notification_type.default_channels.get(
'web', True
),
'send_email': notification_type.default_channels.get(
'email', False
),
'send_telegram': notification_type.default_channels.get(
'telegram', False
),
'send_whatsapp': notification_type.default_channels.get(
'whatsapp', False
),
'send_wechat': notification_type.default_channels.get(
'wechat', False
),
}
)
logger.debug(
f"[Settings] User {user.id} type={notification_type.code} "
f"created={created} enabled={settings.is_enabled} "
f"web={settings.send_web} email={settings.send_email} "
f"telegram={settings.send_telegram}"
)
return settings
@staticmethod
def mark_as_read(notification_id: int, user: User) -> bool:
"""Отметить уведомление как прочитанное"""
try:
notification = Notification.objects.get(
id=notification_id,
recipient=user
)
notification.mark_as_read()
# Обновить счетчик через WebSocket
channel_layer = get_channel_layer()
unread_count = Notification.objects.filter(
recipient=user,
is_read=False,
is_archived=False
).count()
async_to_sync(channel_layer.group_send)(
f'notifications_{user.id}',
{
'type': 'notification_count_update',
'count': unread_count
}
)
return True
except Notification.DoesNotExist:
return False
@staticmethod
def mark_all_as_read(user: User, category: Optional[str] = None) -> int:
"""Отметить все уведомления как прочитанные"""
queryset = Notification.objects.filter(
recipient=user,
is_read=False
)
if category:
queryset = queryset.filter(
notification_type__category__code=category
)
count = queryset.update(
is_read=True,
read_at=timezone.now()
)
# Обновить счетчик
channel_layer = get_channel_layer()
unread_count = Notification.objects.filter(
recipient=user,
is_read=False,
is_archived=False
).count()
async_to_sync(channel_layer.group_send)(
f'notifications_{user.id}',
{
'type': 'notification_count_update',
'count': unread_count
}
)
return count