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:
parent
7d623babbf
commit
0d7c442b6b
@ -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")
|
||||
@ -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'
|
||||
),
|
||||
]
|
||||
@ -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
|
||||
]
|
||||
})
|
||||
|
||||
@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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'])
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
359
backend/static/js/notifications/push-notifications.js
Normal file
359
backend/static/js/notifications/push-notifications.js
Normal 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
192
backend/static/sw.js
Normal 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);
|
||||
@ -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 %}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user