""" Сервисный слой для работы с уведомлениями """ 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() def is_celery_available(): """Проверка доступности Celery worker""" try: from celery import current_app inspect = current_app.control.inspect() active_workers = inspect.active() return active_workers is not None and len(active_workers) > 0 except Exception: return False class NotificationService: """Сервис для создания и отправки уведомлений""" @staticmethod def create_notification_async( 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, ) -> bool: """ Создать уведомление асинхронно через Celery (рекомендуется) Args: recipient: Получатель уведомления notification_type_code: Код типа уведомления title: Заголовок message: Текст сообщения content_object: Связанный объект (опционально) action_url: URL для действия action_text: Текст кнопки действия metadata: Дополнительные данные Returns: True если задача поставлена в очередь, False если fallback на sync """ try: # Проверяем доступность Celery if not is_celery_available(): logger.warning( f"[create_notification_async] Celery недоступен, fallback на синхронный режим" ) NotificationService.create_notification( recipient=recipient, notification_type_code=notification_type_code, title=title, message=message, content_object=content_object, action_url=action_url, action_text=action_text, metadata=metadata, send_immediately=True, ) return False # Импортируем задачу from notifications.tasks import send_notification_task # Отправляем задачу в Celery send_notification_task.delay( notification_type=notification_type_code, user_id=recipient.id, title=title, message=message, link=action_url, sender_id=None, metadata=metadata or {}, ) logger.debug( f"[create_notification_async] ✅ Задача отправлена в Celery: " f"user={recipient.id}, type={notification_type_code}" ) return True except Exception as e: logger.exception( f"[create_notification_async] ❌ Ошибка при отправке в Celery: {e}" ) # Fallback на синхронный режим NotificationService.create_notification( recipient=recipient, notification_type_code=notification_type_code, title=title, message=message, content_object=content_object, action_url=action_url, action_text=action_text, metadata=metadata, send_immediately=True, ) return False @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.debug( 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.debug( 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.debug( 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.debug( f"[NotificationService.create_notification] ✅ Уведомление создано: " f"id={notification.id}, " f"action_url={action_url}" ) # Отправить если нужно if send_immediately: logger.debug( f"[NotificationService.create_notification] ➡️ Немедленная отправка notification_id={notification.id}" ) NotificationService.send_notification(notification, settings) else: logger.debug( f"[NotificationService.create_notification] ⏸️ Отложенная отправка notification_id={notification.id}" ) return notification @staticmethod def send_notification( notification: Notification, settings: Optional[UserNotificationSettings] = None ): """Отправить уведомление по всем каналам""" logger.debug( f"[NotificationService.send_notification] 📤 НАЧАЛО notification_id={notification.id} " f"recipient={notification.recipient.id} type={notification.notification_type.code}" ) if settings is None: settings = NotificationService.get_user_settings( notification.recipient, notification.notification_type ) logger.debug( 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.debug( f"[NotificationService.send_notification] 🌐 Отправка через WebSocket..." ) try: # Отправляем WebSocket только для онлайн уведомлений (не push) NotificationService.send_web_socket(notification) notification.sent_web = True logger.debug( f"[NotificationService.send_notification] ✅ WebSocket: успешно отправлено" ) except Exception as e: logger.error( f"[NotificationService.send_notification] ❌ WebSocket: ошибка - {e}", exc_info=True ) else: logger.debug( f"[NotificationService.send_notification] ⏭️ WebSocket: канал отключен" ) # Отправить Web Push (для offline пользователей) # ВАЖНО: отправляем независимо от send_web настройки, # если есть активные push-подписки if settings.send_web: logger.debug( f"[NotificationService.send_notification] 📲 Отправка Web Push (offline)..." ) try: push_count = NotificationService.send_web_push_notification(notification) if push_count > 0: logger.debug( 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.debug( f"[NotificationService.send_notification] ⏭️ Web Push: канал отключен" ) # Отправить на email if settings.send_email: logger.debug( f"[NotificationService.send_notification] 📧 Отправка Email..." ) try: recipient_email = notification.recipient.email if recipient_email: logger.debug( 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.debug( 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.debug( f"[NotificationService.send_notification] ⏭️ Email: канал отключен" ) # Отправить в Telegram if settings.send_telegram: logger.debug( f"[NotificationService.send_notification] 💬 Отправка в Telegram..." ) logger.debug( 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.debug( 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.debug( 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.debug( 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.debug( 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.debug( f"[NotificationService.send_notification] 🎯 ЗАВЕРШЕНО notification_id={notification.id} " f"Web={'✅' if notification.sent_web else '❌'} " f"Email={'✅' if notification.sent_email else '❌'} " f"Telegram={'✅' if notification.sent_telegram else '❌'}" ) @staticmethod def send_web_socket(notification: Notification): """ Отправить уведомление через WebSocket (только для онлайн пользователей). Переименовано из send_web_notification для ясности. """ logger.debug( 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.debug( f"[NotificationService.send_web_socket] Отправка в группу: {group_name}" ) async_to_sync(channel_layer.group_send)( group_name, { 'type': 'notification_new', 'notification': notification_data } ) logger.debug( f"[NotificationService.send_web_socket] ✅ Уведомление отправлено в WebSocket" ) # Обновить счетчик unread_count = Notification.objects.filter( recipient=notification.recipient, is_read=False, is_archived=False ).count() logger.debug( f"[NotificationService.send_web_socket] Обновление счетчика: {unread_count} непрочитанных" ) async_to_sync(channel_layer.group_send)( group_name, { 'type': 'notification_count_update', 'count': unread_count } ) logger.debug( 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.debug( 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.debug( f"[NotificationService.send_web_push_notification] ℹ️ Нет активных push-подписок" ) return 0 logger.debug( 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.debug( 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