feat: Replace CDN with local vendor libraries + complete Web Push setup
- Замена всех CDN зависимостей на локальные файлы: * Bootstrap 5.3.3 (CSS + JS) * Bootstrap Icons 1.11.3 (CSS + шрифты woff/woff2) * FullCalendar 6.1.8 (JS + локали) * Cropper.js 1.6.1 * emoji-picker-element 1.21.3 - Web Push Notifications: * VAPID ключи настроены в settings.py * Service Worker (sw.js) в корне backend/ * API endpoints: /api/v1/notifications/push/ * Auto-request permission при загрузке страницы * Simplified subscribe logic (Phase 1 refactor) * resetEverything() для восстановления после ошибок - Документация: * backend/docs/guides/WEB_PUSH_QUICK_TEST.md * backend/docs/guides/FIX_NO_PUSH_SUBSCRIPTIONS.md * backend/docs/troubleshooting/WEB_PUSH_OFFLINE_ISSUE.md - Обновлено 10 шаблонов для использования local vendor - Устранены проблемы с CORS и integrity checks - Все иконки Bootstrap Icons теперь отображаются корректно
This commit is contained in:
parent
2a76ffacab
commit
74ee089809
232
backend/docs/guides/FIX_NO_PUSH_SUBSCRIPTIONS.md
Normal file
232
backend/docs/guides/FIX_NO_PUSH_SUBSCRIPTIONS.md
Normal file
@ -0,0 +1,232 @@
|
||||
# Решение: Нет активных Web Push подписок
|
||||
|
||||
**Проблема:** `[NotificationService.send_web_push_notification] ℹ️ Нет активных push-подписок`
|
||||
|
||||
**Причина:** В базе данных нет записей WebPushSubscription для пользователя. Браузер не подписан на уведомления.
|
||||
|
||||
## Быстрое решение (3 шага)
|
||||
|
||||
### Шаг 1: Откройте сайт и консоль браузера
|
||||
|
||||
1. Откройте http://localhost:9000 (или ваш адрес)
|
||||
2. Нажмите F12 → вкладка Console
|
||||
|
||||
### Шаг 2: Проверьте статус
|
||||
|
||||
Вставьте в консоль:
|
||||
|
||||
```javascript
|
||||
// Проверка 1: Service Worker
|
||||
navigator.serviceWorker.getRegistrations().then(regs => {
|
||||
console.log('Service Workers:', regs.length);
|
||||
if (regs.length === 0) console.error('❌ Service Worker НЕ зарегистрирован!');
|
||||
});
|
||||
|
||||
// Проверка 2: Разрешение
|
||||
console.log('Permission:', Notification.permission);
|
||||
// Должно быть: "granted"
|
||||
|
||||
// Проверка 3: Подписка
|
||||
navigator.serviceWorker.ready.then(reg => {
|
||||
return reg.pushManager.getSubscription();
|
||||
}).then(sub => {
|
||||
if (sub) {
|
||||
console.log('✅ Подписка активна:', sub.endpoint.substr(0, 50) + '...');
|
||||
} else {
|
||||
console.error('❌ Нет подписки!');
|
||||
}
|
||||
});
|
||||
|
||||
// Проверка 4: Наш модуль
|
||||
if (window.pushNotifications) {
|
||||
console.log('✅ pushNotifications загружен, isSubscribed:', window.pushNotifications.isSubscribed);
|
||||
} else {
|
||||
console.error('❌ pushNotifications НЕ загружен!');
|
||||
}
|
||||
```
|
||||
|
||||
### Шаг 3: Подписаться
|
||||
|
||||
Если подписки нет, выполните:
|
||||
|
||||
```javascript
|
||||
// Вариант A: Через наш модуль (если загружен)
|
||||
if (window.pushNotifications) {
|
||||
window.pushNotifications.subscribe().then(success => {
|
||||
console.log(success ? '✅ Подписка успешна!' : '❌ Ошибка подписки');
|
||||
});
|
||||
}
|
||||
|
||||
// Вариант B: Вручную импортировать и подписаться
|
||||
else {
|
||||
import('/static/js/notifications/push-notifications.js')
|
||||
.then(module => {
|
||||
window.pushNotifications = new module.PushNotificationsManager();
|
||||
return window.pushNotifications.init();
|
||||
})
|
||||
.then(() => {
|
||||
console.log('Инициализирован, подписываемся...');
|
||||
return window.pushNotifications.subscribe();
|
||||
})
|
||||
.then(success => {
|
||||
console.log(success ? '✅ Подписка успешна!' : '❌ Ошибка подписки');
|
||||
})
|
||||
.catch(err => console.error('Ошибка:', err));
|
||||
}
|
||||
```
|
||||
|
||||
**При первом вызове браузер покажет диалог:**
|
||||
```
|
||||
localhost хочет:
|
||||
☐ Показывать уведомления
|
||||
[Блокировать] [Разрешить]
|
||||
```
|
||||
|
||||
**Нажмите "Разрешить"!**
|
||||
|
||||
### Шаг 4: Проверьте в БД
|
||||
|
||||
После подписки проверьте Django shell:
|
||||
|
||||
```python
|
||||
from notifications.models import WebPushSubscription
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
user = User.objects.get(email='igor_26reg@mail.ru')
|
||||
|
||||
subs = WebPushSubscription.objects.filter(user=user, is_active=True)
|
||||
print(f"Активных подписок: {subs.count()}")
|
||||
for sub in subs:
|
||||
print(f" - {sub.device_name}: {sub.endpoint[:60]}...")
|
||||
```
|
||||
|
||||
Должно быть >= 1.
|
||||
|
||||
### Шаг 5: Отправьте тест
|
||||
|
||||
```python
|
||||
from notifications.services import NotificationService
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
user = User.objects.get(email='igor_26reg@mail.ru')
|
||||
|
||||
notification = NotificationService.create_notification(
|
||||
recipient=user,
|
||||
notification_type_code='system_announcement',
|
||||
title='🎉 Web Push работает!',
|
||||
message='Вы успешно подписались на push-уведомления. Теперь они будут приходить даже с закрытым браузером!',
|
||||
action_url='/notifications/',
|
||||
send_immediately=True
|
||||
)
|
||||
```
|
||||
|
||||
**Закройте браузер полностью** и отправьте еще раз - должно прийти нативное уведомление!
|
||||
|
||||
## Возможные проблемы
|
||||
|
||||
### ❌ Service Worker не регистрируется
|
||||
|
||||
**Проверка:** http://localhost:9000/sw.js - должен открыться JS файл
|
||||
|
||||
**Решение:** Убедитесь что файл `backend/sw.js` существует и доступен.
|
||||
|
||||
### ❌ Разрешение "denied"
|
||||
|
||||
**Причина:** Пользователь нажал "Блокировать" в диалоге браузера
|
||||
|
||||
**Решение:**
|
||||
|
||||
**Chrome:**
|
||||
1. Адресная строка → Замок 🔒 → Настройки сайта
|
||||
2. Уведомления → Разрешить
|
||||
3. Перезагрузите страницу → выполните подписку
|
||||
|
||||
**Firefox:**
|
||||
1. Адресная строка → 🛈 → Дополнительная информация
|
||||
2. Разрешения → Показывать уведомления → Разрешить
|
||||
3. Перезагрузите страницу → выполните подписку
|
||||
|
||||
### ❌ pushNotifications не загружается
|
||||
|
||||
**Причина:** Не авторизован или ошибка загрузки модуля
|
||||
|
||||
**Проверка в консоли:**
|
||||
```javascript
|
||||
// Смотрим ошибки загрузки
|
||||
console.error = console.error; // Показать все ошибки
|
||||
|
||||
// Проверить что пользователь авторизован
|
||||
fetch('/api/v1/notifications/push/vapid-key/', {credentials: 'include'})
|
||||
.then(r => r.json())
|
||||
.then(d => console.log('✅ Авторизован, VAPID key:', d.vapid_public_key.substr(0, 20) + '...'))
|
||||
.catch(e => console.error('❌ Не авторизован:', e));
|
||||
```
|
||||
|
||||
### ❌ HTTPS требуется
|
||||
|
||||
**На production:** Push API требует HTTPS (кроме localhost)
|
||||
|
||||
**Решение:** Используйте HTTPS или тестируйте на localhost.
|
||||
|
||||
### ❌ Подписка создается, но не сохраняется на сервере
|
||||
|
||||
**Лог в консоли:**
|
||||
```
|
||||
[PushNotifications] Push-подписка создана: https://...
|
||||
[PushNotifications] Ошибка: Не удалось сохранить подписку на сервере
|
||||
```
|
||||
|
||||
**Проверка:** Откройте Network tab (F12), отправьте POST на `/api/v1/notifications/push/subscribe/`
|
||||
|
||||
**Возможные причины:**
|
||||
- CSRF токен не найден
|
||||
- Не авторизован
|
||||
- Ошибка на сервере
|
||||
|
||||
**Решение:**
|
||||
```javascript
|
||||
// Проверить CSRF токен
|
||||
console.log('CSRF token:',
|
||||
document.querySelector('[name=csrfmiddlewaretoken]')?.value
|
||||
|| document.cookie.match(/csrftoken=([^;]+)/)?.[1]
|
||||
);
|
||||
```
|
||||
|
||||
## Автоматическая подписка после входа
|
||||
|
||||
Добавьте в `base.html` (уже есть):
|
||||
|
||||
```html
|
||||
<script type="module">
|
||||
import { pushNotifications } from "{% static 'js/notifications/push-notifications.js' %}";
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const initialized = await pushNotifications.init();
|
||||
|
||||
if (initialized) {
|
||||
// Если разрешение дано, но нет подписки - подписываем автоматически
|
||||
if (pushNotifications.getPermissionStatus() === 'granted' && !pushNotifications.isSubscribed) {
|
||||
await pushNotifications.subscribe();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
Этот код **уже есть** в вашем `base.html` (строки 142-169), но работает только если:
|
||||
1. Пользователь авторизован
|
||||
2. Разрешение уже дано (`Notification.permission === 'granted'`)
|
||||
|
||||
Если разрешение не дано - нужно **вручную** попросить через UI (кнопка "Включить уведомления").
|
||||
|
||||
## Итог
|
||||
|
||||
Чтобы Web Push работал:
|
||||
|
||||
1. ✅ **Код исправлен** - Web Push вызывается независимо
|
||||
2. ⚠️ **Нужно подписаться** - выполните шаги 1-3 выше
|
||||
3. ✅ **После подписки** - уведомления будут приходить даже с закрытым браузером
|
||||
|
||||
Ваш код **работает правильно**. Просто нужно **создать подписку** в браузере!
|
||||
281
backend/docs/guides/WEB_PUSH_QUICK_TEST.md
Normal file
281
backend/docs/guides/WEB_PUSH_QUICK_TEST.md
Normal file
@ -0,0 +1,281 @@
|
||||
# Быстрый тест Web Push уведомлений
|
||||
|
||||
## 1. Проверка подписки пользователя
|
||||
|
||||
Откройте Django shell:
|
||||
```bash
|
||||
cd backend
|
||||
../.venv/Scripts/python manage.py shell
|
||||
```
|
||||
|
||||
Выполните:
|
||||
```python
|
||||
from notifications.models import WebPushSubscription
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
# Найдите своего пользователя (замените email)
|
||||
user = User.objects.get(email='nadein-igor-vladimirovich@05-04-1999.ru')
|
||||
|
||||
# Проверьте подписки
|
||||
subs = WebPushSubscription.objects.filter(user=user, is_active=True)
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Пользователь: {user.email}")
|
||||
print(f"Активных Web Push подписок: {subs.count()}")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
for sub in subs:
|
||||
print(f"Устройство: {sub.device_name or 'Неизвестно'}")
|
||||
print(f"Endpoint: {sub.endpoint[:60]}...")
|
||||
print(f"Создана: {sub.created_at}")
|
||||
print(f"Ошибок: {sub.error_count}")
|
||||
print("-" * 60)
|
||||
```
|
||||
|
||||
## 2. Отправка тестового уведомления
|
||||
|
||||
### Вариант A: Через NotificationService (рекомендуется)
|
||||
|
||||
```python
|
||||
from notifications.services import NotificationService
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
user = User.objects.get(email='nadein-igor-vladimirovich@05-04-1999.ru')
|
||||
|
||||
# Создать и отправить уведомление
|
||||
notification = NotificationService.create_notification(
|
||||
recipient=user,
|
||||
notification_type_code='system_announcement', # Или любой другой существующий тип
|
||||
title='🔔 Тест Web Push',
|
||||
message='Это тестовое уведомление для проверки работы Web Push API. Если вы видите это уведомление при закрытом браузере - всё работает отлично!',
|
||||
action_url='/notifications/',
|
||||
action_text='Открыть уведомления',
|
||||
send_immediately=True
|
||||
)
|
||||
|
||||
if notification:
|
||||
print(f"\n✅ Уведомление создано: ID={notification.id}")
|
||||
print(f"Статус отправки:")
|
||||
print(f" - WebSocket: {'✅' if notification.sent_web else '❌'}")
|
||||
print(f" - Email: {'✅' if notification.sent_email else '❌'}")
|
||||
print(f" - Telegram: {'✅' if notification.sent_telegram else '❌'}")
|
||||
else:
|
||||
print("\n❌ Ошибка: уведомление не создано")
|
||||
```
|
||||
|
||||
### Вариант B: Прямой вызов Web Push
|
||||
|
||||
```python
|
||||
from notifications.models import Notification, NotificationType
|
||||
from notifications.services import NotificationService
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
user = User.objects.get(email='nadein-igor-vladimirovich@05-04-1999.ru')
|
||||
|
||||
# Найти тип уведомления
|
||||
notif_type = NotificationType.objects.get(code='system_announcement')
|
||||
|
||||
# Создать уведомление вручную
|
||||
notification = Notification.objects.create(
|
||||
recipient=user,
|
||||
notification_type=notif_type,
|
||||
title='🧪 Тест Web Push (прямой вызов)',
|
||||
message='Проверка работы Web Push через прямой вызов send_web_push_notification()',
|
||||
short_message='Проверка Web Push',
|
||||
action_url='/notifications/'
|
||||
)
|
||||
|
||||
# Отправить только через Web Push
|
||||
push_count = NotificationService.send_web_push_notification(notification)
|
||||
print(f"\n📲 Web Push отправлено на {push_count} устройств")
|
||||
```
|
||||
|
||||
## 3. Тест с закрытым браузером
|
||||
|
||||
1. **Откройте сайт в браузере**
|
||||
- Перейдите на http://localhost:9000 (или ваш адрес)
|
||||
- Авторизуйтесь
|
||||
|
||||
2. **Проверьте разрешения** (F12 → Console)
|
||||
```javascript
|
||||
console.log('Notification permission:', Notification.permission);
|
||||
```
|
||||
Должно быть: `granted`
|
||||
|
||||
3. **Проверьте подписку**
|
||||
```javascript
|
||||
navigator.serviceWorker.ready.then(reg => {
|
||||
return reg.pushManager.getSubscription();
|
||||
}).then(sub => {
|
||||
if (sub) {
|
||||
console.log('✅ Подписка активна');
|
||||
} else {
|
||||
console.log('❌ Нет подписки, подпишитесь в настройках');
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
4. **ЗАКРОЙТЕ браузер полностью**
|
||||
- Ctrl+Shift+Q (Chrome) или Alt+F4
|
||||
- Убедитесь что все окна закрыты
|
||||
|
||||
5. **Отправьте уведомление из Django shell** (см. шаг 2)
|
||||
|
||||
6. **Проверьте результат**
|
||||
- Должно появиться нативное уведомление Windows/macOS/Linux
|
||||
- При клике на уведомление → откроется браузер на нужной странице
|
||||
|
||||
## 4. Проверка логов
|
||||
|
||||
### Логи Django
|
||||
|
||||
```bash
|
||||
# В отдельном терминале (если сервер запущен)
|
||||
tail -f backend/logs/django.log | grep -E "send_web_push|WebPush"
|
||||
```
|
||||
|
||||
Должны увидеть:
|
||||
```
|
||||
[NotificationService.send_notification] 📲 Отправка Web Push (offline)...
|
||||
[NotificationService.send_web_push_notification] НАЧАЛО: notification_id=XXX
|
||||
[NotificationService.send_web_push_notification] Найдено подписок: 1
|
||||
[NotificationService.send_web_push_notification] ✅ Завершено: 1/1 успешно
|
||||
```
|
||||
|
||||
### Логи браузера (Service Worker)
|
||||
|
||||
Откройте: `chrome://serviceworker-internals/` (Chrome) или `about:debugging#/runtime/this-firefox` (Firefox)
|
||||
|
||||
Найдите ваш Service Worker (`/sw.js`) и нажмите "Inspect" или "Console".
|
||||
|
||||
При получении push-уведомления должно быть:
|
||||
```
|
||||
[SW] Push received: PushEvent
|
||||
[SW] Push payload: {title: "...", body: "...", ...}
|
||||
```
|
||||
|
||||
## 5. Возможные проблемы
|
||||
|
||||
### ❌ Нет активных подписок
|
||||
|
||||
**Причина:** Пользователь не подписался на уведомления
|
||||
|
||||
**Решение:**
|
||||
1. Откройте сайт
|
||||
2. Перейдите в Настройки → Уведомления
|
||||
3. Нажмите "Разрешить уведомления" в браузере
|
||||
4. Убедитесь что канал "Web" включен
|
||||
|
||||
### ❌ VAPID ключи не настроены
|
||||
|
||||
**Лог:** `⚠️ VAPID ключи не настроены`
|
||||
|
||||
**Решение:**
|
||||
```bash
|
||||
.venv/Scripts/python -c "from py_vapid import Vapid; v = Vapid(); v.generate_keys(); print('Public:', v.public_key.decode()); print('Private:', v.private_key.decode())"
|
||||
```
|
||||
Добавьте ключи в `.env` или `settings.py`.
|
||||
|
||||
### ❌ pywebpush не установлен
|
||||
|
||||
**Решение:**
|
||||
```bash
|
||||
cd backend
|
||||
../.venv/Scripts/pip install pywebpush==2.1.2
|
||||
```
|
||||
|
||||
### ❌ Service Worker не регистрируется
|
||||
|
||||
**Проблема:** Файл `sw.js` недоступен
|
||||
|
||||
**Решение:**
|
||||
1. Проверьте что файл `backend/sw.js` существует
|
||||
2. Проверьте URL: http://localhost:9000/sw.js (должен открыться)
|
||||
3. HTTPS обязателен на production (на localhost работает без HTTPS)
|
||||
|
||||
### ❌ Error 410 Gone
|
||||
|
||||
**Причина:** Подписка устарела (браузер сбросил)
|
||||
|
||||
**Решение:**
|
||||
```python
|
||||
# В Django shell
|
||||
from notifications.models import WebPushSubscription
|
||||
WebPushSubscription.objects.filter(is_active=False).delete()
|
||||
```
|
||||
|
||||
Затем перезагрузите страницу и подпишитесь заново.
|
||||
|
||||
### ❌ Уведомления дублируются
|
||||
|
||||
**Это нормально** для онлайн пользователей:
|
||||
- WebSocket → мгновенное уведомление
|
||||
- Web Push → отложенное (2-5 секунд)
|
||||
|
||||
Браузер должен дедуплицировать по `tag`, но не все браузеры это делают корректно.
|
||||
|
||||
## 6. Статистика
|
||||
|
||||
```python
|
||||
from notifications.models import WebPushSubscription, Notification
|
||||
|
||||
# Всего подписок
|
||||
total_subs = WebPushSubscription.objects.count()
|
||||
active_subs = WebPushSubscription.objects.filter(is_active=True).count()
|
||||
with_errors = WebPushSubscription.objects.filter(error_count__gt=0).count()
|
||||
|
||||
print(f"Web Push подписки:")
|
||||
print(f" Всего: {total_subs}")
|
||||
print(f" Активных: {active_subs}")
|
||||
print(f" С ошибками: {with_errors}")
|
||||
|
||||
# Уведомления за сегодня
|
||||
from django.utils import timezone
|
||||
today = timezone.now().date()
|
||||
notifications_today = Notification.objects.filter(
|
||||
created_at__date=today
|
||||
).count()
|
||||
|
||||
print(f"\nУведомления за сегодня: {notifications_today}")
|
||||
```
|
||||
|
||||
## 7. Очистка неактивных подписок
|
||||
|
||||
```bash
|
||||
# Удалить старые и с ошибками
|
||||
.venv/Scripts/python manage.py cleanup_push_subscriptions
|
||||
|
||||
# Или вручную:
|
||||
```
|
||||
|
||||
```python
|
||||
from notifications.models import WebPushSubscription
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
# Удалить неактивные старше 90 дней
|
||||
old_date = timezone.now() - timedelta(days=90)
|
||||
inactive = WebPushSubscription.objects.filter(
|
||||
is_active=False,
|
||||
updated_at__lt=old_date
|
||||
)
|
||||
count = inactive.count()
|
||||
inactive.delete()
|
||||
print(f"Удалено старых подписок: {count}")
|
||||
|
||||
# Удалить с большим количеством ошибок
|
||||
with_errors = WebPushSubscription.objects.filter(error_count__gte=5)
|
||||
count = with_errors.count()
|
||||
with_errors.delete()
|
||||
print(f"Удалено с ошибками: {count}")
|
||||
```
|
||||
|
||||
## Готово!
|
||||
|
||||
Если всё работает, вы увидите:
|
||||
- ✅ Нативные уведомления даже с **закрытым браузером**
|
||||
- ✅ Клик по уведомлению открывает нужную страницу
|
||||
- ✅ В логах: `✅ Завершено: 1/1 успешно`
|
||||
338
backend/docs/troubleshooting/WEB_PUSH_OFFLINE_ISSUE.md
Normal file
338
backend/docs/troubleshooting/WEB_PUSH_OFFLINE_ISSUE.md
Normal file
@ -0,0 +1,338 @@
|
||||
# Проблема: Web Push уведомления не приходят при закрытом браузере
|
||||
|
||||
**Дата:** 5 января 2026 г.
|
||||
**Статус:** ✅ Решено
|
||||
**Приоритет:** Критический
|
||||
|
||||
## Описание проблемы
|
||||
|
||||
Пользователи сообщили, что уведомления не приходят, когда браузер закрыт. Уведомления начинают приходить только после открытия браузера и перехода на сайт.
|
||||
|
||||
## Анализ проблемы
|
||||
|
||||
### Ожидаемое поведение
|
||||
Web Push API должен отправлять уведомления **независимо** от того, открыт браузер или нет. Service Worker может получать и показывать уведомления даже когда все вкладки с сайтом закрыты.
|
||||
|
||||
### Фактическое поведение
|
||||
Уведомления отправлялись только через WebSocket, который работает только когда пользователь онлайн на сайте.
|
||||
|
||||
### Причина
|
||||
|
||||
В файле `backend/notifications/services.py`, метод `send_notification()` (строки ~170-230):
|
||||
|
||||
**Проблемный код:**
|
||||
```python
|
||||
# Отправить на веб (WebSocket)
|
||||
if settings.send_web:
|
||||
NotificationService.send_web_notification(notification)
|
||||
notification.sent_web = True
|
||||
```
|
||||
|
||||
Внутри метода `send_web_notification()` (строка ~391):
|
||||
```python
|
||||
# Также отправляем Web Push для offline уведомлений
|
||||
NotificationService.send_web_push_notification(notification)
|
||||
```
|
||||
|
||||
**Проблема:**
|
||||
- `send_web_push_notification()` вызывался **ВНУТРИ** `send_web_notification()`
|
||||
- Если пользователь офлайн → WebSocket не отправляет → `send_web_notification()` не выполняется полностью
|
||||
- Следовательно → `send_web_push_notification()` **НЕ вызывается**
|
||||
|
||||
## Решение
|
||||
|
||||
### Изменения в архитектуре
|
||||
|
||||
Разделили отправку на два независимых канала:
|
||||
|
||||
1. **WebSocket** (`send_web_socket`) - для онлайн пользователей
|
||||
2. **Web Push API** (`send_web_push_notification`) - для offline пользователей
|
||||
|
||||
### Изменения в коде
|
||||
|
||||
#### 1. Метод `send_notification()` - разделение каналов
|
||||
|
||||
**До:**
|
||||
```python
|
||||
# Отправить на веб (WebSocket)
|
||||
if settings.send_web:
|
||||
NotificationService.send_web_notification(notification)
|
||||
notification.sent_web = True
|
||||
```
|
||||
|
||||
**После:**
|
||||
```python
|
||||
# Отправить на веб (WebSocket) - для онлайн пользователей
|
||||
if settings.send_web:
|
||||
NotificationService.send_web_socket(notification)
|
||||
notification.sent_web = True
|
||||
|
||||
# Отправить Web Push (для offline пользователей)
|
||||
# ВАЖНО: отправляем независимо от WebSocket
|
||||
if settings.send_web:
|
||||
push_count = NotificationService.send_web_push_notification(notification)
|
||||
if push_count > 0:
|
||||
logger.info(f"Web Push: отправлено на {push_count} устройств")
|
||||
```
|
||||
|
||||
#### 2. Переименование метода для ясности
|
||||
|
||||
`send_web_notification()` → `send_web_socket()`
|
||||
|
||||
Убрали вызов `send_web_push_notification()` изнутри этого метода.
|
||||
|
||||
## Технические детали
|
||||
|
||||
### Архитектура Web Push
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Django Backend │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ NotificationService.send_notification() │ │
|
||||
│ │ │ │
|
||||
│ │ 1. WebSocket (онлайн) 2. Web Push (offline) │ │
|
||||
│ │ ↓ ↓ │ │
|
||||
│ │ send_web_socket() send_web_push_notification│ │
|
||||
│ │ ↓ ↓ │ │
|
||||
│ │ Django Channels pywebpush library │ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓ ↓
|
||||
┌───────────────────┐ ┌───────────────────────────┐
|
||||
│ User Online │ │ Push Service Provider │
|
||||
│ (WebSocket conn) │ │ (FCM, Safari, Mozilla) │
|
||||
└───────────────────┘ └───────────────────────────┘
|
||||
↓
|
||||
┌───────────────────────────┐
|
||||
│ Service Worker │
|
||||
│ (браузер закрыт/открыт) │
|
||||
│ │
|
||||
│ sw.js: │
|
||||
│ - push event handler │
|
||||
│ - показ уведомления │
|
||||
└───────────────────────────┘
|
||||
```
|
||||
|
||||
### Компоненты системы
|
||||
|
||||
#### 1. Service Worker (`backend/sw.js`)
|
||||
- Регистрируется при загрузке сайта
|
||||
- Работает в фоне даже когда все вкладки закрыты
|
||||
- Обрабатывает `push` события от браузера
|
||||
- Показывает нативные уведомления ОС
|
||||
|
||||
#### 2. Push Subscription (`WebPushSubscription` модель)
|
||||
- Хранит endpoint от браузера
|
||||
- Ключи шифрования (p256dh, auth)
|
||||
- Информация об устройстве
|
||||
|
||||
#### 3. VAPID ключи (настройки)
|
||||
```python
|
||||
# backend/eusrr_backend/settings.py
|
||||
VAPID_PUBLIC_KEY = "BG3WGJZH3bCPtZ1yRjXywfGsHoRT4MZ7npk25twmBireG_Di5-1FBn1ChblWCDicienEr07y4k7R7_A5IjbfjRA="
|
||||
VAPID_PRIVATE_KEY = "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQ..."
|
||||
VAPID_ADMIN_EMAIL = "admin@example.com"
|
||||
```
|
||||
|
||||
#### 4. Frontend модуль (`static/js/notifications/push-notifications.js`)
|
||||
```javascript
|
||||
class PushNotificationsManager {
|
||||
async init() {
|
||||
await this._fetchVapidKey(); // Получить публичный ключ
|
||||
await this._registerServiceWorker(); // Зарегистрировать SW
|
||||
await this._checkSubscription(); // Проверить подписку
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Проверка решения
|
||||
|
||||
### 1. Проверить наличие подписок
|
||||
```bash
|
||||
.venv/Scripts/python manage.py shell
|
||||
```
|
||||
|
||||
```python
|
||||
from notifications.models import WebPushSubscription
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
user = User.objects.get(email='ваш-email@example.com')
|
||||
|
||||
# Проверить активные подписки
|
||||
subs = WebPushSubscription.objects.filter(user=user, is_active=True)
|
||||
print(f"Активных подписок: {subs.count()}")
|
||||
for sub in subs:
|
||||
print(f"- Устройство: {sub.device_name}, Создана: {sub.created_at}")
|
||||
```
|
||||
|
||||
### 2. Тестовая отправка через Django shell
|
||||
|
||||
```python
|
||||
from notifications.services import NotificationService
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
user = User.objects.get(email='ваш-email@example.com')
|
||||
|
||||
# Создать тестовое уведомление
|
||||
notification = NotificationService.create_notification(
|
||||
recipient=user,
|
||||
notification_type_code='test_notification', # или любой существующий тип
|
||||
title='Тест Web Push',
|
||||
message='Это тестовое уведомление для проверки Web Push API',
|
||||
action_url='/notifications/',
|
||||
send_immediately=True
|
||||
)
|
||||
|
||||
print(f"Уведомление создано: {notification.id}")
|
||||
```
|
||||
|
||||
### 3. Проверка в логах
|
||||
|
||||
После отправки проверьте логи:
|
||||
```bash
|
||||
tail -f backend/logs/django.log | grep "send_web_push"
|
||||
```
|
||||
|
||||
Должны увидеть:
|
||||
```
|
||||
[NotificationService.send_notification] 📲 Отправка Web Push (offline)...
|
||||
[NotificationService.send_web_push_notification] НАЧАЛО: notification_id=123
|
||||
[NotificationService.send_web_push_notification] Найдено подписок: 1
|
||||
[NotificationService.send_web_push_notification] ✅ Завершено: 1/1 успешно
|
||||
```
|
||||
|
||||
### 4. Проверка в браузере
|
||||
|
||||
#### A. Проверить регистрацию Service Worker
|
||||
```javascript
|
||||
// В консоли браузера (F12)
|
||||
navigator.serviceWorker.getRegistrations().then(regs => {
|
||||
console.log('Service Workers:', regs);
|
||||
regs.forEach(reg => console.log('- Scope:', reg.scope));
|
||||
});
|
||||
```
|
||||
|
||||
#### B. Проверить подписку на Push
|
||||
```javascript
|
||||
navigator.serviceWorker.ready.then(reg => {
|
||||
return reg.pushManager.getSubscription();
|
||||
}).then(sub => {
|
||||
if (sub) {
|
||||
console.log('✅ Есть активная подписка:', sub.endpoint);
|
||||
} else {
|
||||
console.log('❌ Нет активной подписки');
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
#### C. Проверить разрешения
|
||||
```javascript
|
||||
console.log('Notification permission:', Notification.permission);
|
||||
// Должно быть: "granted"
|
||||
```
|
||||
|
||||
### 5. Тест с закрытым браузером
|
||||
|
||||
1. Откройте сайт, подпишитесь на уведомления (если еще не подписаны)
|
||||
2. **Закройте браузер полностью**
|
||||
3. Из Django shell отправьте тестовое уведомление (см. п.2)
|
||||
4. Должно появиться нативное уведомление ОС (даже с закрытым браузером!)
|
||||
|
||||
## Возможные проблемы и решения
|
||||
|
||||
### Проблема 1: "pywebpush не установлен"
|
||||
```bash
|
||||
cd backend
|
||||
../.venv/Scripts/pip install pywebpush==2.1.2
|
||||
```
|
||||
|
||||
### Проблема 2: VAPID ключи не настроены
|
||||
```bash
|
||||
# Сгенерировать новые ключи
|
||||
.venv/Scripts/python -c "from py_vapid import Vapid; v = Vapid(); v.generate_keys(); print('Public:', v.public_key.decode()); print('Private:', v.private_key.decode())"
|
||||
```
|
||||
|
||||
Добавить в `.env` или `settings.py`.
|
||||
|
||||
### Проблема 3: Service Worker не регистрируется
|
||||
- Проверить, что `sw.js` доступен по URL `/sw.js`
|
||||
- Проверить консоль браузера на ошибки
|
||||
- HTTPS обязателен (кроме localhost)
|
||||
|
||||
### Проблема 4: Подписка создается, но уведомления не приходят
|
||||
- Проверить endpoint в БД - должен начинаться с `https://`
|
||||
- Проверить счетчик ошибок: если `error_count >= 5`, подписка деактивируется
|
||||
- Проверить логи Django на ошибки `WebPushException`
|
||||
|
||||
### Проблема 5: Уведомления приходят дважды
|
||||
Это нормально для онлайн пользователей:
|
||||
- Один раз через WebSocket (мгновенно)
|
||||
- Второй раз через Web Push (с небольшой задержкой)
|
||||
|
||||
Браузер обычно дедуплицирует уведомления с одинаковым `tag`.
|
||||
|
||||
## Настройки пользователей
|
||||
|
||||
Пользователи могут управлять подписками в настройках уведомлений:
|
||||
- **Канал "Web"** - включает и WebSocket и Web Push
|
||||
- Отключение канала → не будут приходить уведомления в браузере
|
||||
|
||||
API endpoints:
|
||||
- `GET /api/v1/notifications/push/vapid-key/` - получить публичный ключ
|
||||
- `POST /api/v1/notifications/push/subscribe/` - подписаться
|
||||
- `DELETE /api/v1/notifications/push/unsubscribe/` - отписаться
|
||||
- `GET /api/v1/notifications/push/subscriptions/` - список устройств
|
||||
|
||||
## Мониторинг
|
||||
|
||||
### Команда для очистки неактивных подписок
|
||||
```bash
|
||||
.venv/Scripts/python manage.py cleanup_push_subscriptions
|
||||
```
|
||||
|
||||
Удаляет:
|
||||
- Неактивные подписки старше 90 дней
|
||||
- Подписки с 5+ ошибками
|
||||
- Подписки от несуществующих пользователей
|
||||
|
||||
### Статистика
|
||||
```python
|
||||
from notifications.models import WebPushSubscription
|
||||
|
||||
# Всего подписок
|
||||
total = WebPushSubscription.objects.count()
|
||||
active = WebPushSubscription.objects.filter(is_active=True).count()
|
||||
with_errors = WebPushSubscription.objects.filter(error_count__gt=0).count()
|
||||
|
||||
print(f"Всего: {total}, Активных: {active}, С ошибками: {with_errors}")
|
||||
```
|
||||
|
||||
## Заключение
|
||||
|
||||
Проблема решена разделением логики отправки WebSocket и Web Push уведомлений. Теперь:
|
||||
|
||||
✅ Web Push отправляются **независимо** от статуса WebSocket
|
||||
✅ Уведомления приходят даже с **закрытым браузером**
|
||||
✅ Service Worker обрабатывает push-события в фоне
|
||||
✅ Поддержка offline режима работает корректно
|
||||
|
||||
## Связанные файлы
|
||||
|
||||
- `backend/notifications/services.py` - основная логика отправки
|
||||
- `backend/notifications/models.py` - модель `WebPushSubscription`
|
||||
- `backend/notifications/api_views.py` - API для управления подписками
|
||||
- `backend/sw.js` - Service Worker для обработки push-событий
|
||||
- `backend/static/js/notifications/push-notifications.js` - frontend модуль
|
||||
- `backend/eusrr_backend/settings.py` - VAPID настройки
|
||||
|
||||
## Дальнейшие улучшения
|
||||
|
||||
- [ ] Добавить аналитику доставки уведомлений
|
||||
- [ ] Настройка расписания тихих часов (не беспокоить ночью)
|
||||
- [ ] Группировка уведомлений по категориям
|
||||
- [ ] Rich notifications с изображениями и действиями
|
||||
- [ ] Звуковые уведомления (custom sounds)
|
||||
5
backend/static/vendor/bootstrap-icons/bootstrap-icons.min.css
vendored
Normal file
5
backend/static/vendor/bootstrap-icons/bootstrap-icons.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
backend/static/vendor/bootstrap-icons/bootstrap-icons.woff2
vendored
Normal file
BIN
backend/static/vendor/bootstrap-icons/bootstrap-icons.woff2
vendored
Normal file
Binary file not shown.
BIN
backend/static/vendor/bootstrap-icons/fonts/bootstrap-icons.woff
vendored
Normal file
BIN
backend/static/vendor/bootstrap-icons/fonts/bootstrap-icons.woff
vendored
Normal file
Binary file not shown.
BIN
backend/static/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2
vendored
Normal file
BIN
backend/static/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2
vendored
Normal file
Binary file not shown.
7
backend/static/vendor/bootstrap/bootstrap.bundle.min.js
vendored
Normal file
7
backend/static/vendor/bootstrap/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
6
backend/static/vendor/bootstrap/bootstrap.min.css
vendored
Normal file
6
backend/static/vendor/bootstrap/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
9
backend/static/vendor/cropperjs/cropper.min.css
vendored
Normal file
9
backend/static/vendor/cropperjs/cropper.min.css
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
/*!
|
||||
* Cropper.js v1.6.1
|
||||
* https://fengyuanchen.github.io/cropperjs
|
||||
*
|
||||
* Copyright 2015-present Chen Fengyuan
|
||||
* Released under the MIT license
|
||||
*
|
||||
* Date: 2023-09-17T03:44:17.565Z
|
||||
*/.cropper-container{direction:ltr;font-size:0;line-height:0;position:relative;-ms-touch-action:none;touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.cropper-container img{backface-visibility:hidden;display:block;height:100%;image-orientation:0deg;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;width:100%}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{bottom:0;left:0;position:absolute;right:0;top:0}.cropper-canvas,.cropper-wrap-box{overflow:hidden}.cropper-drag-box{background-color:#fff;opacity:0}.cropper-modal{background-color:#000;opacity:.5}.cropper-view-box{display:block;height:100%;outline:1px solid #39f;outline-color:rgba(51,153,255,.75);overflow:hidden;width:100%}.cropper-dashed{border:0 dashed #eee;display:block;opacity:.5;position:absolute}.cropper-dashed.dashed-h{border-bottom-width:1px;border-top-width:1px;height:33.33333%;left:0;top:33.33333%;width:100%}.cropper-dashed.dashed-v{border-left-width:1px;border-right-width:1px;height:100%;left:33.33333%;top:0;width:33.33333%}.cropper-center{display:block;height:0;left:50%;opacity:.75;position:absolute;top:50%;width:0}.cropper-center:after,.cropper-center:before{background-color:#eee;content:" ";display:block;position:absolute}.cropper-center:before{height:1px;left:-3px;top:0;width:7px}.cropper-center:after{height:7px;left:0;top:-3px;width:1px}.cropper-face,.cropper-line,.cropper-point{display:block;height:100%;opacity:.1;position:absolute;width:100%}.cropper-face{background-color:#fff;left:0;top:0}.cropper-line{background-color:#39f}.cropper-line.line-e{cursor:ew-resize;right:-3px;top:0;width:5px}.cropper-line.line-n{cursor:ns-resize;height:5px;left:0;top:-3px}.cropper-line.line-w{cursor:ew-resize;left:-3px;top:0;width:5px}.cropper-line.line-s{bottom:-3px;cursor:ns-resize;height:5px;left:0}.cropper-point{background-color:#39f;height:5px;opacity:.75;width:5px}.cropper-point.point-e{cursor:ew-resize;margin-top:-3px;right:-3px;top:50%}.cropper-point.point-n{cursor:ns-resize;left:50%;margin-left:-3px;top:-3px}.cropper-point.point-w{cursor:ew-resize;left:-3px;margin-top:-3px;top:50%}.cropper-point.point-s{bottom:-3px;cursor:s-resize;left:50%;margin-left:-3px}.cropper-point.point-ne{cursor:nesw-resize;right:-3px;top:-3px}.cropper-point.point-nw{cursor:nwse-resize;left:-3px;top:-3px}.cropper-point.point-sw{bottom:-3px;cursor:nesw-resize;left:-3px}.cropper-point.point-se{bottom:-3px;cursor:nwse-resize;height:20px;opacity:1;right:-3px;width:20px}@media (min-width:768px){.cropper-point.point-se{height:15px;width:15px}}@media (min-width:992px){.cropper-point.point-se{height:10px;width:10px}}@media (min-width:1200px){.cropper-point.point-se{height:5px;opacity:.75;width:5px}}.cropper-point.point-se:before{background-color:#39f;bottom:-50%;content:" ";display:block;height:200%;opacity:0;position:absolute;right:-50%;width:200%}.cropper-invisible{opacity:0}.cropper-bg{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC")}.cropper-hide{display:block;height:0;position:absolute;width:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed}
|
||||
10
backend/static/vendor/cropperjs/cropper.min.js
vendored
Normal file
10
backend/static/vendor/cropperjs/cropper.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3
backend/static/vendor/emoji-picker/index.js
vendored
Normal file
3
backend/static/vendor/emoji-picker/index.js
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
import Picker from './picker.js'
|
||||
import Database from './database.js'
|
||||
export { Picker, Database }
|
||||
1666
backend/static/vendor/emoji-picker/picker.js
vendored
Normal file
1666
backend/static/vendor/emoji-picker/picker.js
vendored
Normal file
File diff suppressed because one or more lines are too long
6
backend/static/vendor/fullcalendar/fullcalendar.min.js
vendored
Normal file
6
backend/static/vendor/fullcalendar/fullcalendar.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
6
backend/static/vendor/fullcalendar/locales-all.min.js
vendored
Normal file
6
backend/static/vendor/fullcalendar/locales-all.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -141,8 +141,8 @@
|
||||
|
||||
{% block extra_js %}
|
||||
<!-- Cropper.js для обрезки аватара -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.1/cropper.min.css" integrity="sha512-hvNR0F/e2J7zPPfLC9auFe3/SE0yG4aJCOd/qxew74NN7eyiSKjr7xJJMu1Jy2wf7FXITpWS1E/RY8yzuXN7VA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.1/cropper.min.js" integrity="sha512-9KkIqdfN7ipEW6B6k+Aq20PV31bjODg4AA52W+tYtAE0jE0kMx49bjJ3FgvS56wzmyfMUHbQ4Km2b7l9+Y/+Eg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
<link rel="stylesheet" href="{% static 'vendor/cropperjs/cropper.min.css' %}">
|
||||
<script src="{% static 'vendor/cropperjs/cropper.min.js' %}"></script>
|
||||
|
||||
<script type="module">
|
||||
import { initPasswordToggle } from "{% static 'js/components/passwordToggleHandler.js' %}";
|
||||
|
||||
@ -26,13 +26,10 @@
|
||||
<link rel="preload" href="{% static 'css/components/common.css' %}" as="style">
|
||||
|
||||
<!-- Bootstrap / Icons / FullCalendar -->
|
||||
<!-- <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet"
|
||||
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
|
||||
crossorigin="anonymous"> -->
|
||||
<!-- Bootstrap CSS (локальная версия) -->
|
||||
<!-- Кастомный Bootstrap с нашей системой отступов -->
|
||||
<link rel="stylesheet" href="{% static 'css/bootstrap-custom.css' %}">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<link href="{% static 'vendor/bootstrap-icons/bootstrap-icons.min.css' %}" rel="stylesheet">
|
||||
|
||||
<!-- CSS Components -->
|
||||
<link rel="stylesheet" href="{% static 'css/variables.css' %}">
|
||||
@ -75,11 +72,12 @@
|
||||
</div>
|
||||
|
||||
<!-- JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
|
||||
crossorigin="anonymous" defer></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.8/index.global.min.js" defer></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@fullcalendar/core@6.1.8/locales-all.global.min.js" defer></script>
|
||||
<!-- Bootstrap JS (локальная версия) -->
|
||||
<script src="{% static 'vendor/bootstrap/bootstrap.bundle.min.js' %}"></script>
|
||||
|
||||
<!-- FullCalendar (локальные версии) -->
|
||||
<script src="{% static 'vendor/fullcalendar/fullcalendar.min.js' %}" defer></script>
|
||||
<script src="{% static 'vendor/fullcalendar/locales-all.min.js' %}" defer></script>
|
||||
|
||||
{# Универсальный модуль для автоматического расширения textarea #}
|
||||
<script src="{% static 'js/components/textareaAutoExpand.js' %}" defer></script>
|
||||
@ -91,7 +89,7 @@
|
||||
import { smoothScrollTo, scrollToTop, isElementInViewport } from '{% static "js/utils/scroll.js" %}';
|
||||
import { initNavbarHeight } from '{% static "js/components/navbarHeight.js" %}';
|
||||
import { initLikeHandler } from '{% static "js/components/likeHandler.js" %}';
|
||||
|
||||
|
||||
// Экспортируем в window для обратной совместимости с существующим кодом
|
||||
window.esc = esc;
|
||||
window.norm = norm;
|
||||
@ -109,7 +107,7 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const navbarHeight = initNavbarHeight();
|
||||
const likeHandler = initLikeHandler();
|
||||
|
||||
|
||||
if (navbarHeight) window.navbarHeight = navbarHeight;
|
||||
if (likeHandler) window.likeHandler = likeHandler;
|
||||
});
|
||||
@ -137,36 +135,38 @@
|
||||
|
||||
<!-- 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() === 'granted') {
|
||||
console.log('[WebPush] Разрешение есть, переподписываемся...');
|
||||
|
||||
// Если разрешение еще не спрошено - запрашиваем его
|
||||
if (pushNotifications.getPermissionStatus() === 'default' && !pushNotifications.isSubscribed) {
|
||||
console.log('[WebPush] Запрашиваем разрешение на уведомления...');
|
||||
|
||||
// Очищаем старый endpoint из localStorage
|
||||
localStorage.removeItem('push_endpoint');
|
||||
|
||||
// Подписываемся (метод subscribe сам удалит старую подписку)
|
||||
const subscribed = await pushNotifications.subscribe();
|
||||
|
||||
if (subscribed) {
|
||||
console.log('[WebPush] ✅ Успешно подписан');
|
||||
} else {
|
||||
console.warn('[WebPush] ⚠️ Не удалось подписаться');
|
||||
}
|
||||
} else if (pushNotifications.getPermissionStatus() === 'default') {
|
||||
console.log('[WebPush] Пользователь еще не дал разрешение на уведомления');
|
||||
// Небольшая задержка, чтобы пользователь успел сориентироваться
|
||||
setTimeout(async () => {
|
||||
const permission = await pushNotifications.requestPermission();
|
||||
if (permission === 'granted') {
|
||||
console.log('[WebPush] Разрешение получено, подписываемся...');
|
||||
await pushNotifications.subscribe();
|
||||
} else {
|
||||
console.log('[WebPush] Разрешение не получено');
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// Если разрешение дано, но нет подписки - подписываем автоматически
|
||||
if (pushNotifications.getPermissionStatus() === 'granted' && !pushNotifications.isSubscribed) {
|
||||
console.log('[WebPush] Разрешение есть, подписываемся...');
|
||||
await pushNotifications.subscribe();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -282,7 +282,7 @@
|
||||
{{ block.super }}
|
||||
|
||||
{# ========== THIRD-PARTY ========== #}
|
||||
<script type="module" src="https://cdn.jsdelivr.net/npm/emoji-picker-element@^1/index.js"></script>
|
||||
<script type="module" src="{% static 'vendor/emoji-picker/index.js' %}"></script>
|
||||
|
||||
{# ========== CHAT DETAIL - NEW ENHANCED VERSION ========== #}
|
||||
<script type="module" src="{% static 'js/chat-detail-enhanced.js' %}"></script>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{% load static %}
|
||||
{# Cropper.js для обрезки аватара #}
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.1/cropper.min.css" integrity="sha512-hvNR0F/e2J7zPPfLC9auFe3/SE0yG4aJCOd/qxew74NN7eyiSKjr7xJJMu1Jy2wf7FXITpWS1E/RY8yzuXN7VA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.1/cropper.min.js" integrity="sha512-9KkIqdfN7ipEW6B6k+Aq20PV31bjODg4AA52W+tYtAE0jE0kMx49bjJ3FgvS56wzmyfMUHbQ4Km2b7l9+Y/+Eg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
{# Cropper.js для обрезки аватара (локальная версия) #}
|
||||
<link rel="stylesheet" href="{% static 'vendor/cropperjs/cropper.min.css' %}">
|
||||
<script src="{% static 'vendor/cropperjs/cropper.min.js' %}"></script>
|
||||
|
||||
{# Подключаем модули для управления формой сотрудника #}
|
||||
<script type="module">
|
||||
|
||||
@ -530,6 +530,6 @@
|
||||
{% include "employees/components/employee_detail_scripts.html" %}
|
||||
|
||||
<!-- Emoji Picker Library -->
|
||||
<script type="module" src="https://cdn.jsdelivr.net/npm/emoji-picker-element@^1/index.js"></script>
|
||||
<script type="module" src="{% static 'vendor/emoji-picker/index.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@ -77,5 +77,5 @@
|
||||
{% block extra_js %}
|
||||
{{ block.super }}
|
||||
<!-- Emoji Picker Library -->
|
||||
<script type="module" src="https://cdn.jsdelivr.net/npm/emoji-picker-element@^1/index.js"></script>
|
||||
<script type="module" src="{% static 'vendor/emoji-picker/index.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@ -96,5 +96,5 @@
|
||||
</script>
|
||||
|
||||
<!-- Emoji Picker Library -->
|
||||
<script type="module" src="https://cdn.jsdelivr.net/npm/emoji-picker-element@^1/index.js"></script>
|
||||
<script type="module" src="{% static 'vendor/emoji-picker/index.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@ -374,7 +374,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script type="module" src="https://cdn.jsdelivr.net/npm/emoji-picker-element@^1/index.js"></script>
|
||||
<script type="module" src="{% static 'vendor/emoji-picker/index.js' %}"></script>
|
||||
<script>
|
||||
(function() {
|
||||
const requestId = {{ request_obj.id|default:0 }};
|
||||
|
||||
@ -425,5 +425,5 @@
|
||||
</script>
|
||||
<script src="{% static 'js/components/requestComments.js' %}"></script>
|
||||
<!-- Emoji Picker Library -->
|
||||
<script type="module" src="https://cdn.jsdelivr.net/npm/emoji-picker-element@^1/index.js"></script>
|
||||
<script type="module" src="{% static 'vendor/emoji-picker/index.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@ -229,7 +229,7 @@
|
||||
{{ block.super }}
|
||||
<script src="{% static 'js/components/requestComments.js' %}"></script>
|
||||
<!-- Emoji Picker Library -->
|
||||
<script type="module" src="https://cdn.jsdelivr.net/npm/emoji-picker-element@^1/index.js"></script>
|
||||
<script type="module" src="{% static 'vendor/emoji-picker/index.js' %}"></script>
|
||||
|
||||
<script>
|
||||
// Обработка формы решения без перезагрузки страницы
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user