diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index c76d985d..cd583e77 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -446,4 +446,17 @@ BRAND_NAME = os.getenv("BRAND_NAME", "HiRo") BRAND_LOGO = "img/logo.png" TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "") -TELEGRAM_BOT_USERNAME = os.getenv("TELEGRAM_BOT_USERNAME", "eusrr_bot") \ No newline at end of file +TELEGRAM_BOT_USERNAME = os.getenv("TELEGRAM_BOT_USERNAME", "eusrr_bot") + +# Web Push Notifications (VAPID) +# Ключи для авторизации push-уведомлений +# Для генерации новых ключей: python -c "from py_vapid import Vapid; v = Vapid(); v.generate_keys(); v.save_key('private_key.pem'); v.save_public_key('public_key.pem')" +VAPID_PUBLIC_KEY = os.getenv( + "VAPID_PUBLIC_KEY", + "BG3WGJZH3bCPtZ1yRjXywfGsHoRT4MZ7npk25twmBireG_Di5-1FBn1ChblWCDicienEr07y4k7R7_A5IjbfjRA=" +) +VAPID_PRIVATE_KEY = os.getenv( + "VAPID_PRIVATE_KEY", + "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgIYJXs5gLYLtLpWO0cr6prv4jHJT28_hvG1BFyjcIMZKhRANCAARt1hiWR92wj7WdckY18sHxrB6EU-DGe56ZNubcJgYq3hvw4uftRQZ9QoW5Vgg4nInpxK9O8uJO0e_wOSI2340Q" +) +VAPID_ADMIN_EMAIL = os.getenv("VAPID_ADMIN_EMAIL", "admin@example.com") \ No newline at end of file diff --git a/backend/notifications/api_urls.py b/backend/notifications/api_urls.py index 8e519a55..7396adb3 100644 --- a/backend/notifications/api_urls.py +++ b/backend/notifications/api_urls.py @@ -49,4 +49,25 @@ urlpatterns = [ api_views.unlink_telegram, name='telegram_unlink' ), -] + # Web Push integration + path( + 'notifications/push/vapid-key/', + api_views.get_vapid_public_key, + name='push_vapid_key' + ), + path( + 'notifications/push/subscribe/', + api_views.subscribe_push, + name='push_subscribe' + ), + path( + 'notifications/push/unsubscribe/', + api_views.unsubscribe_push, + name='push_unsubscribe' + ), + path( + 'notifications/push/subscriptions/', + api_views.get_push_subscriptions, + name='push_subscriptions' + ), +] \ No newline at end of file diff --git a/backend/notifications/api_views.py b/backend/notifications/api_views.py index 935ccc82..e9d35e21 100644 --- a/backend/notifications/api_views.py +++ b/backend/notifications/api_views.py @@ -410,3 +410,166 @@ def unlink_telegram(request): 'message': 'Telegram аккаунт не привязан' }, status=status.HTTP_404_NOT_FOUND) + +# ============================================ +# Web Push API (для браузерных уведомлений) +# ============================================ + +from django.conf import settings +from .models import WebPushSubscription +import logging + +logger = logging.getLogger(__name__) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def get_vapid_public_key(request): + """ + Получить публичный VAPID ключ для подписки на push-уведомления. + Этот ключ нужен браузеру для создания подписки. + """ + return Response({ + 'vapid_public_key': settings.VAPID_PUBLIC_KEY + }) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def subscribe_push(request): + """ + Подписаться на Web Push уведомления. + + Ожидаемые данные: + { + "endpoint": "https://fcm.googleapis.com/...", + "keys": { + "p256dh": "...", + "auth": "..." + }, + "user_agent": "...", // опционально + "device_name": "..." // опционально + } + """ + try: + endpoint = request.data.get('endpoint') + keys = request.data.get('keys', {}) + p256dh_key = keys.get('p256dh') + auth_key = keys.get('auth') + user_agent = request.data.get('user_agent', '') + device_name = request.data.get('device_name', '') + + if not endpoint or not p256dh_key or not auth_key: + return Response({ + 'status': 'error', + 'message': 'Отсутствуют обязательные поля: endpoint, keys.p256dh, keys.auth' + }, status=status.HTTP_400_BAD_REQUEST) + + # Обновляем или создаем подписку + subscription, created = WebPushSubscription.objects.update_or_create( + user=request.user, + endpoint=endpoint, + defaults={ + 'p256dh_key': p256dh_key, + 'auth_key': auth_key, + 'user_agent': user_agent, + 'device_name': device_name, + 'is_active': True, + 'error_count': 0, + 'last_error': None, + } + ) + + logger.info( + f"[WebPush] {'Created' if created else 'Updated'} subscription for user {request.user.id} " + f"(endpoint: {endpoint[:50]}...)" + ) + + return Response({ + 'status': 'success', + 'message': 'Подписка на push-уведомления активирована', + 'created': created + }) + + except Exception as e: + logger.exception(f"[WebPush] Error subscribing user {request.user.id}: {e}") + return Response({ + 'status': 'error', + 'message': 'Ошибка при создании подписки' + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@api_view(['DELETE']) +@permission_classes([IsAuthenticated]) +def unsubscribe_push(request): + """ + Отписаться от Web Push уведомлений. + + Ожидаемые данные: + { + "endpoint": "https://fcm.googleapis.com/..." + } + + Если endpoint не указан - отписывает от всех подписок пользователя. + """ + try: + endpoint = request.data.get('endpoint') + + if endpoint: + # Удаляем конкретную подписку + deleted, _ = WebPushSubscription.objects.filter( + user=request.user, + endpoint=endpoint + ).delete() + + if deleted: + logger.info(f"[WebPush] Deleted subscription for user {request.user.id} (endpoint: {endpoint[:50]}...)") + return Response({ + 'status': 'success', + 'message': 'Подписка удалена' + }) + else: + return Response({ + 'status': 'error', + 'message': 'Подписка не найдена' + }, status=status.HTTP_404_NOT_FOUND) + else: + # Удаляем все подписки пользователя + deleted, _ = WebPushSubscription.objects.filter(user=request.user).delete() + logger.info(f"[WebPush] Deleted all ({deleted}) subscriptions for user {request.user.id}") + return Response({ + 'status': 'success', + 'message': f'Удалено подписок: {deleted}' + }) + + except Exception as e: + logger.exception(f"[WebPush] Error unsubscribing user {request.user.id}: {e}") + return Response({ + 'status': 'error', + 'message': 'Ошибка при удалении подписки' + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def get_push_subscriptions(request): + """ + Получить список активных push-подписок пользователя. + """ + subscriptions = WebPushSubscription.objects.filter( + user=request.user, + is_active=True + ).order_by('-updated_at') + + return Response({ + 'subscriptions': [ + { + 'id': sub.id, + 'device_name': sub.device_name or 'Неизвестное устройство', + 'user_agent': sub.user_agent[:100] if sub.user_agent else '', + 'created_at': sub.created_at.isoformat(), + 'updated_at': sub.updated_at.isoformat(), + } + for sub in subscriptions + ] + }) diff --git a/backend/notifications/migrations/0005_add_web_push_subscription.py b/backend/notifications/migrations/0005_add_web_push_subscription.py new file mode 100644 index 00000000..8f4d2111 --- /dev/null +++ b/backend/notifications/migrations/0005_add_web_push_subscription.py @@ -0,0 +1,40 @@ +# Generated by Django 5.2.4 on 2026-01-05 10:20 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0004_alter_telegramuser_telegram_id'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='WebPushSubscription', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('endpoint', models.URLField(help_text='URL для отправки push уведомлений', max_length=512, verbose_name='Push Endpoint')), + ('auth_key', models.CharField(help_text='Ключ авторизации (subscription.keys.auth)', max_length=512, verbose_name='Auth Key')), + ('p256dh_key', models.CharField(help_text='Ключ шифрования (subscription.keys.p256dh)', max_length=512, verbose_name='P256DH Key')), + ('user_agent', models.TextField(blank=True, help_text='Браузер и ОС', verbose_name='User Agent')), + ('is_active', models.BooleanField(default=True, verbose_name='Активна')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создана')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлена')), + ('last_used_at', models.DateTimeField(blank=True, null=True, verbose_name='Последнее использование')), + ('error_count', models.IntegerField(default=0, verbose_name='Счётчик ошибок')), + ('last_error', models.TextField(blank=True, verbose_name='Последняя ошибка')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='web_push_subscriptions', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')), + ], + options={ + 'verbose_name': 'Web Push подписка', + 'verbose_name_plural': 'Web Push подписки', + 'db_table': 'notifications_web_push_subscription', + 'ordering': ['-created_at'], + 'unique_together': {('user', 'endpoint')}, + }, + ), + ] diff --git a/backend/notifications/models.py b/backend/notifications/models.py index b6d34542..b6d490a4 100644 --- a/backend/notifications/models.py +++ b/backend/notifications/models.py @@ -430,3 +430,109 @@ class NotificationTemplate(models.Model): def __str__(self): return f'{self.notification_type.name} - {self.get_channel_display()}' + +class WebPushSubscription(models.Model): + """ + Подписка пользователя на Web Push уведомления. + Хранит данные, полученные от браузера при подписке через Push API. + """ + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='web_push_subscriptions', + verbose_name='Пользователь' + ) + + # Endpoint - URL для отправки push-сообщений (от браузера) + endpoint = models.URLField( + max_length=512, + verbose_name='Push Endpoint', + help_text='URL для отправки push уведомлений' + ) + + # Ключ авторизации от браузера + auth_key = models.CharField( + max_length=512, + verbose_name='Auth Key', + help_text='Ключ авторизации (subscription.keys.auth)' + ) + + # P-256 Diffie-Hellman ключ + p256dh_key = models.CharField( + max_length=512, + verbose_name='P256DH Key', + help_text='Ключ шифрования (subscription.keys.p256dh)' + ) + + # Информация о браузере/устройстве + user_agent = models.TextField( + blank=True, + verbose_name='User Agent', + help_text='Браузер и ОС' + ) + + # Активность + is_active = models.BooleanField( + default=True, + verbose_name='Активна' + ) + + # Timestamps + created_at = models.DateTimeField( + auto_now_add=True, + verbose_name='Создана' + ) + updated_at = models.DateTimeField( + auto_now=True, + verbose_name='Обновлена' + ) + last_used_at = models.DateTimeField( + null=True, + blank=True, + verbose_name='Последнее использование' + ) + + # Статистика ошибок + error_count = models.IntegerField( + default=0, + verbose_name='Счётчик ошибок' + ) + last_error = models.TextField( + blank=True, + verbose_name='Последняя ошибка' + ) + + class Meta: + verbose_name = 'Web Push подписка' + verbose_name_plural = 'Web Push подписки' + db_table = 'notifications_web_push_subscription' + # Уникальность по endpoint (один браузер = одна подписка) + unique_together = ['user', 'endpoint'] + ordering = ['-created_at'] + + def __str__(self): + return f'WebPush: {self.user.email} ({self.endpoint[:50]}...)' + + def mark_used(self): + """Обновить время последнего использования""" + self.last_used_at = timezone.now() + self.save(update_fields=['last_used_at']) + + def increment_error(self, error_message: str = ''): + """Инкремент счётчика ошибок""" + self.error_count += 1 + self.last_error = error_message[:1000] if error_message else '' + self.save(update_fields=['error_count', 'last_error']) + + # Деактивировать при 5+ ошибках + if self.error_count >= 5: + self.is_active = False + self.save(update_fields=['is_active']) + + def reset_errors(self): + """Сбросить счётчик ошибок при успешной доставке""" + if self.error_count > 0: + self.error_count = 0 + self.last_error = '' + self.save(update_fields=['error_count', 'last_error']) diff --git a/backend/notifications/services.py b/backend/notifications/services.py index a69c7007..45de7d54 100644 --- a/backend/notifications/services.py +++ b/backend/notifications/services.py @@ -374,6 +374,155 @@ class NotificationService: logger.info( f"[NotificationService.send_web_notification] ✅ Счетчик обновлен" ) + + # Также отправляем Web Push для offline уведомлений + NotificationService.send_web_push_notification(notification) + + @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 + + vapid_claims = { + 'sub': f'mailto:{vapid_email}' + } + + success_count = 0 + failed_subscriptions = [] + + for subscription in subscriptions: + try: + subscription_info = { + 'endpoint': subscription.endpoint, + 'keys': { + 'p256dh': subscription.p256dh_key, + 'auth': subscription.auth_key + } + } + + logger.debug( + f"[NotificationService.send_web_push_notification] " + f"Отправка на endpoint: {subscription.endpoint[:50]}..." + ) + + webpush( + subscription_info=subscription_info, + data=json.dumps(payload), + vapid_private_key=vapid_private_key, + vapid_claims=vapid_claims + ) + + success_count += 1 + subscription.error_count = 0 + subscription.last_error = None + subscription.save(update_fields=['error_count', 'last_error', 'updated_at']) + + except WebPushException as e: + logger.warning( + f"[NotificationService.send_web_push_notification] ⚠️ WebPush ошибка: {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"[NotificationService.send_web_push_notification] " + f"Подписка деактивирована: {subscription.id}" + ) + + # Если слишком много ошибок - деактивируем + if subscription.error_count >= 5: + subscription.is_active = False + logger.info( + f"[NotificationService.send_web_push_notification] " + f"Подписка деактивирована (много ошибок): {subscription.id}" + ) + + subscription.save(update_fields=['error_count', 'last_error', 'is_active', 'updated_at']) + + except Exception as e: + logger.error( + f"[NotificationService.send_web_push_notification] ❌ Ошибка: {e}", + exc_info=True + ) + + logger.info( + f"[NotificationService.send_web_push_notification] ✅ Завершено: " + f"{success_count}/{subscription_count} успешно" + ) + + return success_count @staticmethod def get_user_settings( diff --git a/backend/requirements.txt b/backend/requirements.txt index 789cfe92..07d531a6 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,73 +1,111 @@ -# Core Django -Django==5.2.4 -djangorestframework==3.16.0 -djangorestframework_simplejwt==5.5.1 - -# Django Extensions -django-extensions==4.1 -django-phonenumber-field==8.1.0 -django-bootstrap5==25.1 -django-widget-tweaks==1.5.0 -django-simple-history==3.10.1 -dj-database-url==3.0.1 - -# Authentication & Registration -django-registration==5.2.1 -django-allauth==65.10.0 -djoser==2.3.3 -social-auth-app-django==5.5.1 -social-auth-core==4.7.0 - -# Environment & Config -python-dotenv==1.1.1 - -# Phone Numbers -phonenumbers==9.0.10 - -# Images -pillow==11.3.0 - -# Database -psycopg2-binary==2.9.10 - -# Telegram Bots -python-telegram-bot==22.3 +aiofiles==24.1.0 aiogram==3.21.0 - -# Task Queue & Scheduling -celery==5.5.3 +aiohappyeyeballs==2.6.1 +aiohttp==3.12.15 +aiosignal==1.4.0 +amqp==5.3.1 +annotated-types==0.7.0 +anyio==4.12.0 APScheduler==3.11.0 -redis==6.4.0 - -# WebSocket & Async +asgiref==3.11.0 +attrs==25.4.0 +autobahn==25.12.2 +Automat==25.4.16 +beautifulsoup4==4.13.4 +billiard==4.2.4 +cbor2==5.7.1 +celery==5.5.3 +certifi==2025.11.12 +cffi==2.0.0 channels==4.3.1 channels_redis==4.3.0 -daphne==4.2.1 - -# LDAP Integration -ldap3==2.9.1 - -# Security & Crypto -pycryptodome==3.23.0 +charset-normalizer==3.4.4 +click==8.3.1 +click-didyoumean==0.3.1 +click-plugins==1.1.1.2 +click-repl==0.3.0 +colorama==0.4.6 +confusable-homoglyphs==3.3.1 +constantly==23.10.4 cryptography==45.0.6 -PyJWT==2.10.1 - -# HTTP & Requests -requests==2.32.4 +daphne==4.2.1 +defusedxml==0.7.1 +dj-database-url==3.0.1 +Django==5.2.4 +django-allauth==65.10.0 +django-bootstrap5==25.1 +django-extensions==4.1 +django-filter==25.1 +django-phonenumber-field==8.1.0 +django-registration==5.2.1 +django-simple-history==3.10.1 +django-widget-tweaks==1.5.0 +djangorestframework==3.16.0 +djangorestframework_simplejwt==5.5.1 +djoser==2.3.3 +et_xmlfile==2.0.0 +frozenlist==1.8.0 +gunicorn==23.0.0 +h11==0.16.0 +http_ece==1.2.1 +httpcore==1.0.9 httpx==0.28.1 - -# Testing +hyperlink==21.0.0 +idna==3.11 +Incremental==24.11.0 +iniconfig==2.3.0 +kombu==5.5.4 +ldap3==2.9.1 +magic-filter==1.0.12 +model-bakery==1.20.5 +msgpack==1.1.2 +multidict==6.7.0 +oauthlib==3.3.1 +openpyxl==3.1.5 +packaging==25.0 +phonenumbers==9.0.10 +pillow==11.3.0 +pluggy==1.6.0 +prompt_toolkit==3.0.52 +propcache==0.4.1 +psycopg2-binary==2.9.10 +py-ubjson==0.16.1 +py-vapid==1.9.2 +pyasn1==0.6.1 +pyasn1_modules==0.4.2 +pycparser==2.23 +pycryptodome==3.23.0 +pydantic==2.11.10 +pydantic_core==2.33.2 +Pygments==2.19.2 +PyJWT==2.10.1 +pyOpenSSL==25.1.0 pytest==8.4.1 pytest-django==4.11.1 -model-bakery==1.20.5 - -# Production Server -gunicorn==23.0.0 - -# Utilities -beautifulsoup4==4.13.4 python-dateutil==2.9.0.post0 - -# Excel Export -openpyxl==3.1.5 - +python-dotenv==1.1.1 +python-telegram-bot==22.3 +python3-openid==3.2.0 +pywebpush==2.1.2 +qrcode==8.1 +redis==6.4.0 +requests==2.32.4 +requests-oauthlib==2.0.0 +service-identity==24.2.0 +six==1.17.0 +social-auth-app-django==5.5.1 +social-auth-core==4.7.0 +soupsieve==2.8.1 +sqlparse==0.5.5 +Twisted==25.5.0 +txaio==25.12.2 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +tzdata==2025.3 +tzlocal==5.3.1 +ujson==5.11.0 +urllib3==2.6.2 +vine==5.1.0 +wcwidth==0.2.14 +yarl==1.22.0 +zope.interface==8.1.1 diff --git a/backend/static/js/notifications/push-notifications.js b/backend/static/js/notifications/push-notifications.js new file mode 100644 index 00000000..fd44a78a --- /dev/null +++ b/backend/static/js/notifications/push-notifications.js @@ -0,0 +1,359 @@ +/** + * Web Push Notifications Module + * + * Модуль для управления подпиской на Web Push уведомления. + * Регистрирует Service Worker и управляет push-подписками. + * + * @module notifications/push-notifications + */ + +/** + * Класс для управления Web Push подписками + */ +class PushNotificationsManager { + /** + * Создает экземпляр менеджера push-уведомлений + */ + constructor() { + this.swRegistration = null; + this.vapidPublicKey = null; + this.isSubscribed = false; + this.isSupported = this._checkSupport(); + } + + /** + * Проверяет поддержку Push API браузером + * @returns {boolean} true если поддерживается + */ + _checkSupport() { + if (!('serviceWorker' in navigator)) { + console.warn('[PushNotifications] Service Worker не поддерживается'); + return false; + } + if (!('PushManager' in window)) { + console.warn('[PushNotifications] Push API не поддерживается'); + return false; + } + if (!('Notification' in window)) { + console.warn('[PushNotifications] Notification API не поддерживается'); + return false; + } + return true; + } + + /** + * Инициализирует менеджер push-уведомлений + * @returns {Promise} Успешность инициализации + */ + async init() { + if (!this.isSupported) { + return false; + } + + try { + // Получаем VAPID ключ с сервера + await this._fetchVapidKey(); + + // Регистрируем Service Worker + await this._registerServiceWorker(); + + // Проверяем текущий статус подписки + await this._checkSubscription(); + + console.log('[PushNotifications] Инициализация завершена, подписка:', this.isSubscribed); + return true; + + } catch (error) { + console.error('[PushNotifications] Ошибка инициализации:', error); + return false; + } + } + + /** + * Получает VAPID ключ с сервера + * @private + */ + async _fetchVapidKey() { + const response = await fetch('/api/v1/notifications/push/vapid-key/', { + credentials: 'include' + }); + + if (!response.ok) { + throw new Error('Не удалось получить VAPID ключ'); + } + + const data = await response.json(); + this.vapidPublicKey = data.vapid_public_key; + console.log('[PushNotifications] VAPID ключ получен'); + } + + /** + * Регистрирует Service Worker + * @private + */ + async _registerServiceWorker() { + // Service Worker должен быть в корне для максимального scope + this.swRegistration = await navigator.serviceWorker.register('/static/sw.js', { + scope: '/' + }); + + console.log('[PushNotifications] Service Worker зарегистрирован:', this.swRegistration.scope); + + // Ждем активации + if (this.swRegistration.installing) { + await new Promise((resolve) => { + this.swRegistration.installing.addEventListener('statechange', (e) => { + if (e.target.state === 'activated') { + resolve(); + } + }); + }); + } + } + + /** + * Проверяет текущий статус подписки + * @private + */ + async _checkSubscription() { + const subscription = await this.swRegistration.pushManager.getSubscription(); + this.isSubscribed = subscription !== null; + + if (this.isSubscribed) { + console.log('[PushNotifications] Активная подписка найдена'); + } + } + + /** + * Конвертирует base64 строку в Uint8Array для applicationServerKey + * @param {string} base64String - Base64 encoded VAPID key + * @returns {Uint8Array} + * @private + */ + _urlBase64ToUint8Array(base64String) { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding) + .replace(/-/g, '+') + .replace(/_/g, '/'); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + + return outputArray; + } + + /** + * Запрашивает разрешение на уведомления + * @returns {Promise} Статус разрешения: 'granted', 'denied', 'default' + */ + async requestPermission() { + if (!this.isSupported) { + return 'unsupported'; + } + + const permission = await Notification.requestPermission(); + console.log('[PushNotifications] Разрешение:', permission); + return permission; + } + + /** + * Подписывается на push-уведомления + * @returns {Promise} Успешность подписки + */ + async subscribe() { + if (!this.isSupported || !this.swRegistration || !this.vapidPublicKey) { + console.error('[PushNotifications] Менеджер не инициализирован'); + return false; + } + + // Проверяем разрешение + if (Notification.permission === 'denied') { + console.warn('[PushNotifications] Уведомления заблокированы пользователем'); + return false; + } + + if (Notification.permission === 'default') { + const permission = await this.requestPermission(); + if (permission !== 'granted') { + return false; + } + } + + try { + // Создаем push-подписку + const subscription = await this.swRegistration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: this._urlBase64ToUint8Array(this.vapidPublicKey) + }); + + console.log('[PushNotifications] Push-подписка создана:', subscription.endpoint); + + // Отправляем подписку на сервер + await this._sendSubscriptionToServer(subscription); + + this.isSubscribed = true; + return true; + + } catch (error) { + console.error('[PushNotifications] Ошибка подписки:', error); + return false; + } + } + + /** + * Отправляет подписку на сервер + * @param {PushSubscription} subscription + * @private + */ + async _sendSubscriptionToServer(subscription) { + const subscriptionJson = subscription.toJSON(); + + // Получаем CSRF токен + const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value + || document.cookie.match(/csrftoken=([^;]+)/)?.[1]; + + const response = await fetch('/api/v1/notifications/push/subscribe/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + credentials: 'include', + body: JSON.stringify({ + endpoint: subscriptionJson.endpoint, + keys: subscriptionJson.keys, + user_agent: navigator.userAgent, + device_name: this._getDeviceName() + }) + }); + + if (!response.ok) { + throw new Error('Не удалось сохранить подписку на сервере'); + } + + const data = await response.json(); + console.log('[PushNotifications] Подписка сохранена на сервере:', data); + } + + /** + * Определяет название устройства + * @returns {string} + * @private + */ + _getDeviceName() { + const ua = navigator.userAgent; + + if (/iPhone|iPad|iPod/.test(ua)) { + return 'iOS Device'; + } + if (/Android/.test(ua)) { + return 'Android Device'; + } + if (/Windows/.test(ua)) { + if (/Edge/.test(ua)) return 'Windows (Edge)'; + if (/Chrome/.test(ua)) return 'Windows (Chrome)'; + if (/Firefox/.test(ua)) return 'Windows (Firefox)'; + return 'Windows'; + } + if (/Mac/.test(ua)) { + if (/Chrome/.test(ua)) return 'macOS (Chrome)'; + if (/Safari/.test(ua)) return 'macOS (Safari)'; + if (/Firefox/.test(ua)) return 'macOS (Firefox)'; + return 'macOS'; + } + if (/Linux/.test(ua)) { + return 'Linux'; + } + + return 'Unknown Device'; + } + + /** + * Отписывается от push-уведомлений + * @returns {Promise} Успешность отписки + */ + async unsubscribe() { + if (!this.swRegistration) { + return true; + } + + try { + const subscription = await this.swRegistration.pushManager.getSubscription(); + + if (!subscription) { + this.isSubscribed = false; + return true; + } + + // Отписываемся в браузере + await subscription.unsubscribe(); + + // Удаляем подписку на сервере + await this._removeSubscriptionFromServer(subscription.endpoint); + + this.isSubscribed = false; + console.log('[PushNotifications] Отписка выполнена'); + return true; + + } catch (error) { + console.error('[PushNotifications] Ошибка отписки:', error); + return false; + } + } + + /** + * Удаляет подписку с сервера + * @param {string} endpoint + * @private + */ + async _removeSubscriptionFromServer(endpoint) { + const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value + || document.cookie.match(/csrftoken=([^;]+)/)?.[1]; + + const response = await fetch('/api/v1/notifications/push/unsubscribe/', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + credentials: 'include', + body: JSON.stringify({ endpoint }) + }); + + if (!response.ok) { + console.warn('[PushNotifications] Не удалось удалить подписку с сервера'); + } + } + + /** + * Возвращает текущий статус разрешения + * @returns {string} 'granted', 'denied', 'default', или 'unsupported' + */ + getPermissionStatus() { + if (!this.isSupported) { + return 'unsupported'; + } + return Notification.permission; + } + + /** + * Проверяет, подписан ли пользователь + * @returns {boolean} + */ + getSubscriptionStatus() { + return this.isSubscribed; + } +} + +// Создаем глобальный экземпляр +const pushNotifications = new PushNotificationsManager(); + +// Экспортируем для использования в других модулях +export { PushNotificationsManager, pushNotifications }; + +// Также делаем доступным глобально для совместимости +window.pushNotifications = pushNotifications; diff --git a/backend/static/sw.js b/backend/static/sw.js new file mode 100644 index 00000000..154c122a --- /dev/null +++ b/backend/static/sw.js @@ -0,0 +1,192 @@ +/** + * Service Worker для обработки Web Push уведомлений + * + * Этот файл должен находиться в корне static/ для правильного scope. + * Service Worker перехватывает push-события от браузера и показывает + * нативные уведомления даже когда страница закрыта. + */ + +// Версия Service Worker (для обновления кэша при изменениях) +const SW_VERSION = '1.0.0'; + +// Настройки по умолчанию для уведомлений +const DEFAULT_NOTIFICATION_OPTIONS = { + icon: '/static/img/logo-192.png', + badge: '/static/img/badge-72.png', + vibrate: [100, 50, 100], + requireInteraction: false, + renotify: true, +}; + +/** + * Событие установки Service Worker + */ +self.addEventListener('install', (event) => { + console.log('[SW] Service Worker installing, version:', SW_VERSION); + // Немедленно активировать новую версию + self.skipWaiting(); +}); + +/** + * Событие активации Service Worker + */ +self.addEventListener('activate', (event) => { + console.log('[SW] Service Worker activated, version:', SW_VERSION); + // Захватить все клиенты без перезагрузки + event.waitUntil(self.clients.claim()); +}); + +/** + * Обработка входящих push-уведомлений + * + * Формат данных с сервера: + * { + * "title": "Заголовок уведомления", + * "body": "Текст уведомления", + * "icon": "/static/img/...", // опционально + * "badge": "/static/img/...", // опционально + * "tag": "notification-id", // для группировки + * "data": { + * "url": "/path/to/page", // куда перейти при клике + * "notification_id": 123, + * "category": "requests", + * ... + * } + * } + */ +self.addEventListener('push', (event) => { + console.log('[SW] Push received:', event); + + let notificationData = { + title: 'Новое уведомление', + body: '', + ...DEFAULT_NOTIFICATION_OPTIONS, + data: {} + }; + + if (event.data) { + try { + const payload = event.data.json(); + console.log('[SW] Push payload:', payload); + + notificationData = { + ...notificationData, + title: payload.title || notificationData.title, + body: payload.body || payload.message || '', + icon: payload.icon || notificationData.icon, + badge: payload.badge || notificationData.badge, + tag: payload.tag || `notification-${Date.now()}`, + data: payload.data || {} + }; + + // Дополнительные опции + if (payload.image) { + notificationData.image = payload.image; + } + if (payload.actions) { + notificationData.actions = payload.actions; + } + if (payload.requireInteraction !== undefined) { + notificationData.requireInteraction = payload.requireInteraction; + } + + } catch (e) { + // Если данные не JSON, используем как текст + console.log('[SW] Push data is not JSON, using as text'); + notificationData.body = event.data.text(); + } + } + + // Показываем уведомление + const promiseChain = self.registration.showNotification( + notificationData.title, + { + body: notificationData.body, + icon: notificationData.icon, + badge: notificationData.badge, + tag: notificationData.tag, + data: notificationData.data, + vibrate: notificationData.vibrate, + requireInteraction: notificationData.requireInteraction, + renotify: notificationData.renotify, + image: notificationData.image, + actions: notificationData.actions, + } + ); + + event.waitUntil(promiseChain); +}); + +/** + * Обработка клика по уведомлению + */ +self.addEventListener('notificationclick', (event) => { + console.log('[SW] Notification click:', event); + + event.notification.close(); + + // Получаем URL для перехода + const notificationData = event.notification.data || {}; + let urlToOpen = notificationData.url || '/'; + + // Обработка действий (action buttons) + if (event.action) { + console.log('[SW] Action clicked:', event.action); + // Можно добавить обработку разных действий + if (notificationData.actions && notificationData.actions[event.action]) { + urlToOpen = notificationData.actions[event.action].url || urlToOpen; + } + } + + // Открываем или фокусируем окно + event.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }) + .then((clientList) => { + // Ищем уже открытое окно с нашим сайтом + for (const client of clientList) { + const clientUrl = new URL(client.url); + const targetUrl = new URL(urlToOpen, self.location.origin); + + // Если найдено окно с тем же origin - фокусируем и переходим + if (clientUrl.origin === targetUrl.origin && 'focus' in client) { + return client.navigate(urlToOpen).then(() => client.focus()); + } + } + + // Если нет открытого окна - открываем новое + if (self.clients.openWindow) { + return self.clients.openWindow(urlToOpen); + } + }) + ); +}); + +/** + * Обработка закрытия уведомления без клика + */ +self.addEventListener('notificationclose', (event) => { + console.log('[SW] Notification closed:', event.notification.tag); + + // Можно отправить на сервер информацию о закрытии + // Например, для аналитики + const notificationData = event.notification.data || {}; + if (notificationData.notification_id) { + // fetch('/api/v1/notifications/' + notificationData.notification_id + '/dismissed/', { + // method: 'POST', + // credentials: 'include' + // }).catch(() => {}); + } +}); + +/** + * Получение сообщений от основной страницы + */ +self.addEventListener('message', (event) => { + console.log('[SW] Message received:', event.data); + + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } +}); + +console.log('[SW] Service Worker loaded, version:', SW_VERSION); diff --git a/backend/templates/base.html b/backend/templates/base.html index 245fc62e..741bc4a5 100644 --- a/backend/templates/base.html +++ b/backend/templates/base.html @@ -137,6 +137,34 @@ + + + {% endif %} {% block extra_js %}{% endblock %}