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:
Игорь Надеин 2026-01-06 09:22:16 +03:00
parent 2a76ffacab
commit 74ee089809
25 changed files with 2614 additions and 45 deletions

View 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. ✅ **После подписки** - уведомления будут приходить даже с закрытым браузером
Ваш код **работает правильно**. Просто нужно **создать подписку** в браузере!

View 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 успешно`

View 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)

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,3 @@
import Picker from './picker.js'
import Database from './database.js'
export { Picker, Database }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -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' %}";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>
// Обработка формы решения без перезагрузки страницы