feat: implement Web Push notifications for offline browser notifications

- Add WebPushSubscription model for storing push subscriptions
- Generate VAPID keys for Web Push authentication
- Add API endpoints for push subscribe/unsubscribe
- Create Service Worker (sw.js) for handling push events
- Create JavaScript module for managing push subscriptions
- Integrate send_web_push_notification into NotificationService
- Add Service Worker registration to base.html
- Update requirements.txt with pywebpush package
This commit is contained in:
Игорь Надеин 2026-01-05 14:10:42 +03:00
parent 7d623babbf
commit 0d7c442b6b
10 changed files with 1175 additions and 66 deletions

View File

@ -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")
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")

View File

@ -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'
),
]

View File

@ -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
]
})

View File

@ -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')},
},
),
]

View File

@ -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'])

View File

@ -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(

View File

@ -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

View File

@ -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<boolean>} Успешность инициализации
*/
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<string>} Статус разрешения: 'granted', 'denied', 'default'
*/
async requestPermission() {
if (!this.isSupported) {
return 'unsupported';
}
const permission = await Notification.requestPermission();
console.log('[PushNotifications] Разрешение:', permission);
return permission;
}
/**
* Подписывается на push-уведомления
* @returns {Promise<boolean>} Успешность подписки
*/
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<boolean>} Успешность отписки
*/
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;

192
backend/static/sw.js Normal file
View File

@ -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);

View File

@ -137,6 +137,34 @@
<!-- Notification Manager -->
<script type="module" src="{% static 'js/notifications/notification-manager.js' %}"></script>
<!-- Web Push Notifications -->
<script type="module">
import { pushNotifications } from "{% static 'js/notifications/push-notifications.js' %}";
// Инициализация Web Push после загрузки страницы
document.addEventListener('DOMContentLoaded', async () => {
const initialized = await pushNotifications.init();
if (initialized) {
console.log('[WebPush] Инициализирован, подписка:', pushNotifications.isSubscribed);
// Если разрешение еще не спрошено и пользователь не подписан -
// можно показать промо для подписки (опционально)
if (pushNotifications.getPermissionStatus() === 'default' && !pushNotifications.isSubscribed) {
// Автоматически не подписываем, ждем явного действия пользователя
// Можно добавить кнопку в UI для подписки
console.log('[WebPush] Пользователь еще не подписан на push-уведомления');
}
// Если разрешение дано, но нет подписки - подписываем
if (pushNotifications.getPermissionStatus() === 'granted' && !pushNotifications.isSubscribed) {
console.log('[WebPush] Разрешение есть, подписываемся...');
await pushNotifications.subscribe();
}
}
});
</script>
{% endif %}
{% block extra_js %}{% endblock %}