fix: автоматическое обновление JWT токенов

- ApiClient теперь обрабатывает 401 и 403 ошибки для обновления токена
- Добавлен JWTRefreshMiddleware для проактивного обновления токенов за 5 минут до истечения
- Добавлена настройка JWT_REFRESH_THRESHOLD_MINUTES (по умолчанию 5)
- Исправлена проблема с периодическими 403 ошибками после истечения access token
This commit is contained in:
admin i 2025-11-20 18:52:29 +03:00
parent 5ace685141
commit b38d2bc44b
102 changed files with 16143 additions and 328 deletions

209
backend/CHAT_LIST_FIXES.md Normal file
View File

@ -0,0 +1,209 @@
# Исправления списка чатов
## Дата: 20 ноября 2025
---
## Проблемы
1. ❌ Код JavaScript отображался на странице
2. ❌ Дублирование обработчиков событий фильтрации
3. ❌ Ненужная секция "Закрепленные" (отдельная категория)
4. ❌ Фильтр не работал
---
## Решение
### 1. Убрана секция "Закрепленные"
**Было:**
```django
<div class="feed-card chat-section mb-3" data-sec="pinned" id="pinnedSection" style="display:none;">
<div class="feed-hd">
<div class="feed-ava"><i class="bi-pin-angle-fill text-warning"></i></div>
<div class="feed-meta flex-grow-1">
<h6 class="mb-0 text-secondary">Закрепленные</h6>
</div>
</div>
<div id="sec-pinned" class="list-chats"></div>
</div>
```
**Стало:**
- Секция удалена полностью
- Закрепленные чаты остаются внутри своих категорий (глобальный, отделы, личные, группы и т.д.)
### 2. Исправлено дублирование JavaScript
**Было:**
```javascript
// В одном месте
applyBtn?.addEventListener('click', applyTypeFilter);
resetBtn?.addEventListener('click', resetFilter);
</script>
// И второй раз ниже
if (chatTypeSelect) {
chatTypeSelect.addEventListener('change', applyTypeFilter);
}
if (applyBtn) {
applyBtn.addEventListener('click', applyTypeFilter); // ДУБЛЬ!
}
if (resetBtn) {
resetBtn.addEventListener('click', resetFilter); // ДУБЛЬ!
}
applyTypeFilter();
</script>
```
**Стало:**
```javascript
applyBtn?.addEventListener('click', applyTypeFilter);
resetBtn?.addEventListener('click', resetFilter);
// Применяем фильтр при загрузке
applyTypeFilter();
</script>
{# Подключаем chat-list-enhanced.js #}
<script src="{% static 'js/chat-list-enhanced.js' %}"></script>
{% endblock %}
```
### 3. Убрана опция "Закрепленные" из фильтра
**Было в `_filters.html`:**
```html
<option value="pinned">Закрепленные</option>
```
**Стало:**
- Опция удалена
- Фильтр работает с типами: все, глобальный, отделы, личные, группы, каналы, объявления
---
## Как работает закрепление
### Визуальное выделение
Закрепленные чаты остаются в своих категориях, но **выделяются**:
- 🎨 Желтый фон (`background: #fffbeb`)
- 📌 Желтая левая граница (`border-left: 4px solid var(--bs-warning)`)
- 🏷️ Бейдж с иконкой булавки в правом верхнем углу
### Сортировка
`chat-list-enhanced.js` при загрузке и при закреплении:
```javascript
function moveToPinnedSection(chatRow) {
const parent = chatRow.parentElement;
const firstUnpinned = Array.from(parent.children).find(row =>
!row.classList.contains('pinned')
);
if (firstUnpinned) {
parent.insertBefore(chatRow, firstUnpinned); // Вставляем перед первым незакрепленным
} else {
parent.appendChild(chatRow);
}
}
```
- Закрепленные чаты **перемещаются в начало** своей категории
- Незакрепленные остаются ниже
### Пример структуры
```
📁 Личные
📌 Иван Иванов (закреплен) ← желтый фон, иконка булавки
📌 Мария Петрова (закреплена)
👤 Алексей Сидоров
👤 Ольга Николаева
📁 Группы
📌 Отдел разработки (закреплена)
👥 Проект X
👥 HR обсуждения
```
---
## Контекстное меню
Правый клик на чате → меню:
- 📌 **Закрепить** / Открепить
- 🔔 Уведомления вкл/выкл
- 👁️ Скрыть
- ✅ Отметить прочитанным
**API запрос:**
```javascript
fetch(`/communications/api/chat/${chatId}/pin/`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': csrfToken
},
body: `pinned=${!isPinned}`
})
```
---
## Проверка работы
### 1. Открыть страницу чатов
```
http://localhost:9000/communications/chats/
```
### 2. Проверить фильтр
- Выбрать "Личные" → должны показаться только личные чаты
- Выбрать "Группы" → только группы
- Выбрать "Все" → все категории видны
### 3. Проверить закрепление
- Правый клик на любом чате
- Выбрать "Закрепить"
- Чат должен:
- Получить желтый фон
- Переместиться в начало своей категории
- Показать бейдж с булавкой
### 4. Проверить открепление
- Правый клик на закрепленном чате
- Выбрать "Открепить"
- Чат должен:
- Вернуть обычный фон
- Переместиться вниз категории
- Убрать бейдж
---
## Файлы изменены
1. ✅ `backend/templates/communications/chat_list.html`
- Убрана секция `#pinnedSection`
- Исправлено дублирование JavaScript
- Добавлен вызов `applyTypeFilter()` при загрузке
2. ✅ `backend/templates/communications/_filters.html`
- Убрана опция `<option value="pinned">`
3. ⚙️ `backend/static/js/chat-list-enhanced.js` (без изменений)
- Уже работает правильно с закреплением внутри категорий
4. ⚙️ `backend/static/css/components/chat-list-enhanced.css` (без изменений)
- Уже есть стили `.chat-row.pinned` и `.chat-pinned-badge`
---
## Итог
✅ Код JavaScript больше не отображается на странице
✅ Фильтр работает корректно
✅ Закрепленные чаты остаются в своих категориях
✅ Визуальное выделение закрепленных чатов (желтый фон + булавка)
✅ Контекстное меню для управления закреплением
✅ Нет дублирования обработчиков событий
Система готова к использованию! 🎉

View File

@ -0,0 +1,392 @@
# Обновления списка чатов и модала создания
## Дата: 2024
## Статус: Завершено
---
## Обзор изменений
Список чатов (`chat_list.html`) и модал создания чатов обновлены для поддержки нового функционала мессенджера со всеми 6 типами чатов.
---
## 1. Обновленный шаблон `chat_list.html`
### 1.1. Добавлены новые стили
```django
{% block extra_css %}
...
<link rel="stylesheet" href="{% static 'css/components/chat-list-enhanced.css' %}">
{% endblock %}
```
### 1.2. Добавлена секция "Закрепленные чаты"
```django
<div class="feed-card chat-section mb-3" data-sec="pinned" id="pinnedSection" style="display:none;">
<div class="feed-hd">
<div class="feed-ava" style="width:44px;height:44px;">
<i class="bi-pin-angle-fill text-warning"></i>
</div>
<div class="feed-meta flex-grow-1">
<h6 class="mb-0 text-secondary">Закрепленные</h6>
</div>
</div>
<div id="sec-pinned" class="list-chats"></div>
</div>
```
- Изначально скрыта (`display:none`)
- Будет заполняться JavaScript'ом через `chat-list-enhanced.js`
- Закрепленные чаты отображаются первыми
### 1.3. Обновлены существующие секции
**Глобальный чат:**
- ✅ Добавлен условный вывод аватара: `{% if chat.avatar %}<img>{% else %}<i>{% endif %}`
- ✅ Использование `chat.name|default:"Глобальный чат"`
**Отделы:**
- ✅ Аватар чата (если задан)
- ✅ Отображение `chat.name` для департаментов
**Личные чаты:**
- ✅ Аватар собеседника
- ✅ Корректная структура с `position-relative` для `stretched-link`
### 1.4. Добавлены новые секции
#### Группы (`data-sec="group"`)
- Иконка: `bi-people-fill`
- Отображает чаты с `chat.type == 'group'`
- Поддержка аватара группы
- Показывает название группы, количество сообщений, последнее сообщение
#### Каналы (`data-sec="channel"`)
- Иконка: `bi-broadcast`
- Отображает чаты с `chat.type == 'channel'`
- Поддержка аватара канала
- Показывает название, описание, метрики
#### Объявления (`data-sec="announcement"`)
- Иконка: `bi-megaphone`
- Отображает чаты с `chat.type == 'announcement'`
- Поддержка аватара
- Показывает название, описание, метрики
### 1.5. Все секции имеют:
- `data-chat-id="{{ chat.pk }}"` - ID чата для WebSocket
- `data-type="..."` - тип чата для фильтрации
- `data-last-ts="..."` - timestamp последнего сообщения для сортировки
- `data-haystack="..."` - текст для поиска
- `data-unread` - бейдж непрочитанных сообщений (скрывается если 0)
- Атрибуты для последнего сообщения: `data-last-time`, `data-last-author`, `data-last-preview`
---
## 2. Обновленный модал создания чатов
### 2.1. Селектор типа чата
```html
<select class="form-select" id="chatTypeSelect">
<option value="private">Личный чат</option>
<option value="group">Группа</option>
<option value="channel">Канал</option>
<option value="announcement">Объявления</option>
</select>
```
### 2.2. Поля для группы/канала/объявлений
```html
<div id="groupFieldsSection" style="display:none;">
<div class="mb-3">
<label for="chatNameInput" class="form-label">Название чата <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="chatNameInput" placeholder="Введите название">
</div>
<div class="mb-3">
<label for="chatDescInput" class="form-label">Описание</label>
<textarea class="form-control" id="chatDescInput" rows="3" placeholder="Краткое описание чата"></textarea>
</div>
</div>
```
- Отображаются только для `group`, `channel`, `announcement`
- Скрыты для `private`
### 2.3. Секция выбора участников
```html
<div id="recipientSection">
<label class="form-label">Выберите участников</label>
<div id="chatRecipients" class="recipient-picker" data-api-employees="...">
...
</div>
</div>
```
- Для `private`: maxSelections = 1 (один собеседник)
- Для `group`: maxSelections = 100 (множественный выбор)
- Для `channel`/`announcement`: скрыта (участники опциональны)
### 2.4. Логика создания (JavaScript)
**Переключение типа чата:**
```javascript
chatTypeSelect.addEventListener('change', function() {
const type = this.value;
if(type === 'private') {
recipientSection.style.display = 'block';
groupFieldsSection.style.display = 'none';
chatPicker.setMaxSelections(1);
} else if(type === 'group') {
recipientSection.style.display = 'block';
groupFieldsSection.style.display = 'block';
chatPicker.setMaxSelections(100);
} else {
// channel, announcement
recipientSection.style.display = 'none';
groupFieldsSection.style.display = 'block';
}
});
```
**Создание чата:**
```javascript
if(type === 'private') {
// Используем старый endpoint для личных чатов
window.location.href = `/communications/start_private_chat/${employeeId}/`;
} else {
// Используем новый API для остальных типов
const res = await fetch('/communications/api/chat/create/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrftoken
},
body: JSON.stringify({
type: type,
name: name,
description: description,
participant_ids: participant_ids // Только для группы
})
});
}
```
---
## 3. Обновленные фильтры (`_filters.html`)
```html
<select name="chat_type" class="form-select" id="chatTypeFilter">
<option value="all" selected>Все</option>
<option value="pinned">Закрепленные</option>
<option value="global">Глобальный</option>
<option value="department">Отделы</option>
<option value="private">Личные</option>
<option value="group">Группы</option>
<option value="channel">Каналы</option>
<option value="announcement">Объявления</option>
</select>
```
**Логика фильтрации:**
```javascript
function applyTypeFilter() {
const selectedType = chatTypeFilter?.value || 'all';
const sections = document.querySelectorAll('.chat-section');
sections.forEach(section => {
const sectionType = section.dataset.sec;
if (selectedType === 'all' || selectedType === sectionType) {
section.style.display = '';
} else {
section.style.display = 'none';
}
});
}
```
---
## 4. Обновления RecipientPicker
### 4.1. Добавлена поддержка `maxSelections`
```javascript
constructor(root, options) {
...
this.maxSelections = options.maxSelections || null; // null = без ограничений
...
}
```
### 4.2. Проверка лимита при выборе
```javascript
if (checkbox.checked) {
if (this.maxSelections && this.selected.size >= this.maxSelections) {
checkbox.checked = false;
alert(`Можно выбрать не более ${this.maxSelections} получателей`);
return;
}
this.selected.set(id, payload);
}
```
### 4.3. Новые методы
**`getSelected()`** - получить выбранных с полными данными:
```javascript
getSelected() {
return Array.from(this.selected.values());
}
```
**`setMaxSelections(max)`** - изменить лимит динамически:
```javascript
setMaxSelections(max) {
this.maxSelections = max;
// Если превышен лимит, сбрасываем лишние выборы
if (max && this.selected.size > max) {
const toKeep = Array.from(this.selected.entries()).slice(0, max);
this.selected.clear();
toKeep.forEach(([id, data]) => this.selected.set(id, data));
this._renderSelected();
this.elResults.querySelectorAll('input[type=checkbox][data-id]').forEach(checkbox => {
const id = Number(checkbox.dataset.id);
checkbox.checked = this.selected.has(id);
});
}
}
```
---
## 5. Подключение chat-list-enhanced.js
```html
<script src="{% static 'js/chat-list-enhanced.js' %}"></script>
```
**Что делает `chat-list-enhanced.js`:**
- ✅ WebSocket подключение для реального времени
- ✅ Обновление последних сообщений в списке
- ✅ Перемещение чатов в секцию "Закрепленные"
- ✅ Контекстное меню (правый клик): закрепить, уведомления, скрыть, прочитать
- ✅ Индикаторы печати в списке
- ✅ API вызов `togglePinChat()` для закрепления
- ✅ Анимации обновления чатов
---
## 6. Структура данных чата в шаблоне
### Доступные атрибуты:
```django
chat.pk # ID чата
chat.type # 'private', 'group', 'department', 'channel', 'announcement', 'global'
chat.name # Название (для всех кроме private)
chat.description # Описание
chat.avatar # ImageField (опционально)
chat.participants.all # QuerySet участников (для private)
chat.department # ForeignKey (для department)
chat.messages.count # Количество сообщений
chat.unread_count # Непрочитанные (вычисляется во view)
chat.last_message.0 # Последнее сообщение (вычисляется)
.created_at
.author
.content
```
---
## 7. Тестирование
### Чек-лист для проверки:
- [ ] Отображение всех 6 секций чатов (закрепленные, глобальный, отделы, личные, группы, каналы, объявления)
- [ ] Аватары показываются корректно
- [ ] Фильтр по типу чата работает
- [ ] Поиск находит чаты по названию/участникам
- [ ] Модал создания:
- [ ] Переключение типа чата меняет отображаемые поля
- [ ] Для личного чата: выбор 1 собеседника → редирект на `/start_private_chat/`
- [ ] Для группы: выбор участников, название, описание → POST `/api/chat/create/`
- [ ] Для канала/объявлений: только название, описание → POST `/api/chat/create/`
- [ ] RecipientPicker:
- [ ] Ограничение выбора работает (1 для private, 100 для group)
- [ ] `getSelected()` возвращает массив объектов
- [ ] `setMaxSelections()` корректно обрезает выбор
- [ ] WebSocket обновления (`chat-list-enhanced.js`):
- [ ] Последние сообщения обновляются в реальном времени
- [ ] Закрепление чата работает
- [ ] Контекстное меню открывается
- [ ] Индикаторы печати отображаются
---
## 8. Следующие шаги
1. **Обновить `chat_detail.html`:**
- Подключить `chat-detail-enhanced.js`
- Добавить UI для реакций, ответов, пересылки
- Добавить редактирование/удаление сообщений
- Добавить быстрые реакции на hover
2. **Создать view для API `/api/chat/create/`:**
- Проверить, что endpoint существует в `api_views.py`
- Убедиться в правильной авторизации
- Добавить создание ChatMembership с ролью 'owner' для создателя
3. **Тестирование интеграции:**
- Создание чатов всех типов
- Отправка сообщений
- Real-time обновления через WebSocket
4. **Оптимизация:**
- Добавить пагинацию для больших списков чатов
- Кеширование аватаров
- Lazy loading для секций
---
## 9. API Endpoints используемые в шаблоне
### Существующие:
- `POST /communications/start_private_chat/<user_id>/` - создание личного чата (старый endpoint)
- `GET /api/v1/employees/` - список сотрудников для RecipientPicker
### Новые (из api_views.py):
- `POST /communications/api/chat/create/` - создание group/channel/announcement
```json
{
"type": "group",
"name": "Название группы",
"description": "Описание",
"participant_ids": [1, 2, 3]
}
```
- `POST /communications/api/chat/<chat_id>/pin/` - закрепление чата
```json
{
"pinned": true
}
```
---
## 10. Известные ограничения
1. **Глобальный чат:** может быть только один, создается вручную через админку
2. **Департаментные чаты:** привязаны к `Department`, создаются автоматически
3. **Аватары:** размер файла ограничен настройками Django `FILE_UPLOAD_MAX_MEMORY_SIZE`
4. **RecipientPicker:** поддерживает только сотрудников из `/api/v1/employees/`, не внешних пользователей
---
## Заключение
Шаблон `chat_list.html` и модал создания теперь полностью поддерживают:
- ✅ 6 типов чатов (private, group, department, channel, announcement, global)
- ✅ Закрепленные чаты
- ✅ Аватары чатов
- ✅ Гибкое создание с разными полями в зависимости от типа
- ✅ Фильтрация и поиск
- ✅ Подготовку для real-time обновлений через WebSocket
Следующий шаг - обновление `chat_detail.html` для отображения расширенных возможностей сообщений.

View File

@ -0,0 +1,739 @@
# Система уведомлений - Этап 1 ЗАВЕРШЕН ✅
## Дата завершения: 20 ноября 2025 г.
---
## 🎯 Результаты
### ✅ Создано приложение notifications
- Django app с полной структурой
- Интегрировано в проект EUSRR
### ✅ База данных (5 моделей)
1. **NotificationCategory** - 8 категорий
2. **NotificationType** - 35 типов уведомлений
3. **Notification** - основная модель с GenericForeignKey
4. **UserNotificationSettings** - персональные настройки
5. **NotificationTemplate** - шаблоны для каналов
**Миграции:** Созданы и применены ✅
### ✅ Django Admin
- 5 ModelAdmin с расширенными фильтрами
- Визуализация каналов с эмодзи: 🌐📧✈️💬💚
- Actions: mark_as_read, mark_as_unread, archive
### ✅ 35 типов уведомлений в 8 категориях
**💬 Коммуникации (4):**
- chat_new_message
- chat_mention
- chat_reply
- chat_added_to_chat
**📄 Документы (4):**
- document_ready
- document_signed_all
- document_reminder
- document_comment
**📋 Заявления (5):**
- request_new
- request_approved
- request_rejected
- request_comment
- request_status_changed
**📅 Календарь (5):**
- event_created
- event_reminder_hour
- event_reminder_day
- event_changed
- event_cancelled
**👥 Отдел (5):**
- department_new_employee
- department_employee_left
- department_structure_changed
- department_new_head
- department_announcement
**👤 Профиль (5):**
- profile_data_changed
- profile_password_changed
- profile_email_changed
- profile_messenger_linked
- profile_new_login
**📰 Новости (3):**
- feed_new_post
- feed_post_comment
- feed_post_reaction
**⚙️ Система (4):**
- system_maintenance
- system_announcement
- system_new_feature
- system_policy_change
### ✅ WebSocket инфраструктура
- **NotificationConsumer** для real-time
- Маршрут: `ws://host/ws/notifications/`
- Интегрирован в `eusrr_backend/asgi.py`
- События:
- `unread_count` - текущее количество
- `new_notification` - новое уведомление
- `notification_count_update` - обновление счетчика
### ✅ Сервисный слой (NotificationService)
**Методы:**
- `create_notification()` - создать и отправить
- `send_notification()` - отправить по каналам
- `send_web_notification()` - WebSocket отправка
- `get_user_settings()` - получить/создать настройки
- `mark_as_read()` - отметить прочитанным
- `mark_all_as_read()` - все прочитанными
### ✅ REST API (9 endpoints)
1. `GET /api/notifications/` - список с пагинацией
2. `GET /api/notifications/count/` - счетчик непрочитанных
3. `POST /api/notifications/<id>/read/` - отметить прочитанным
4. `POST /api/notifications/read-all/` - все прочитанными
5. `DELETE /api/notifications/<id>/` - удалить (архивировать)
6. `GET /api/notifications/categories/` - список категорий
7. `GET /api/notifications/settings/` - настройки пользователя
8. `PUT /api/notifications/settings/update/` - обновить настройки
### ✅ Frontend компоненты
#### JavaScript (notification-manager.js)
- **NotificationManager** класс
- WebSocket подключение с автореконнектом
- Обновление badge в real-time
- Toast-уведомления
- Загрузка и отображение списка
- API интеграция
- Обработка событий
**Функции:**
- `connectWebSocket()` - подключение
- `handleMessage()` - обработка сообщений
- `updateBadge()` - обновление счетчика
- `loadInitialNotifications()` - загрузка списка
- `renderNotifications()` - отображение
- `showNewNotification()` - новое уведомление
- `showToast()` - toast уведомление
- `markAsRead()` - отметить прочитанным
- `getTimeAgo()` - относительное время
#### CSS (notifications.css)
- Стили для badge в navbar
- Стили для dropdown списка
- Toast уведомления
- Анимации (slideIn, bellRing)
- Приоритеты и категории
- Адаптивный дизайн
### ✅ UI интеграция
#### navbar.html
- Колокольчик с badge (скрыт при 0)
- Dropdown с уведомлениями
- Список последних 5 уведомлений
- Ссылки на полный список и настройки
- Раздел мессенджеров
#### notification_list.html
- Полная страница уведомлений
- Фильтры по категориям
- Кнопка "Отметить все прочитанными"
- Placeholder для будущего функционала
#### notification_settings.html
- Страница настроек
- Список всех категорий
- Описание каждой категории
- Информация о будущей реализации
### ✅ Management команда
```bash
python manage.py create_notification_types
```
- Создает 8 категорий
- Создает 35 типов
- Idempotent (можно запускать повторно)
---
## 📁 Структура файлов
```
backend/
├── notifications/
│ ├── __init__.py
│ ├── admin.py ✅ 5 ModelAdmin (260 строк)
│ ├── apps.py ✅ Auto-import signals
│ ├── consumers.py ✅ WebSocket consumer (122 строки)
│ ├── models.py ✅ 5 models (459 строк)
│ ├── routing.py ✅ WebSocket routing
│ ├── services.py ✅ NotificationService (300 строк)
│ ├── signals.py ✅ Placeholder
│ ├── urls.py ✅ 2 URL patterns
│ ├── views.py ✅ 2 views
│ ├── api_views.py ✅ 8 API endpoints (224 строки)
│ ├── api_urls.py ✅ 9 API routes
│ ├── migrations/
│ │ └── 0001_initial.py ✅ Initial migration
│ └── management/commands/
│ └── create_notification_types.py ✅ (562 строки)
│
├── templates/
│ ├── base.html ✅ Подключены CSS/JS
│ ├── includes/
│ │ └── navbar.html ✅ Обновлен dropdown
│ └── notifications/
│ ├── notification_list.html ✅ Страница списка
│ └── notification_settings.html ✅ Страница настроек
│
├── static/
│ ├── css/notifications/
│ │ └── notifications.css ✅ Полные стили (180 строк)
│ └── js/notifications/
│ └── notification-manager.js ✅ Manager класс (350 строк)
│
├── eusrr_backend/
│ ├── asgi.py ✅ WebSocket routing
│ ├── urls.py ✅ Notifications URLs
│ └── settings.py ✅ INSTALLED_APPS
│
├── api/
│ └── urls.py ✅ API routing
│
├── test_notifications.py ✅ Тестовый скрипт
├── NOTIFICATION_SYSTEM_PLAN.md ✅ План (2100 строк)
└── NOTIFICATION_STAGE1_COMPLETE.md ✅ Отчет этапа 1
```
---
## 🧪 Тестирование
### ✅ Проверка проекта
```bash
python manage.py check
# System check identified no issues (0 silenced).
```
### ✅ Создание данных
```bash
python manage.py create_notification_types
# ✓ Создано категорий: 8
# ✓ Создано типов: 35
```
### ✅ Тестовый скрипт
```bash
python test_notifications.py
# ✅ Уведомление создано: ID=1
# ✅ Отправлено на веб: True
```
---
## 📊 Статистика
### Код
- **Python файлов:** 12
- **Строк Python:** ~2400+
- **JavaScript:** 350 строк
- **CSS:** 180 строк
- **HTML шаблонов:** 3 обновлено, 2 создано
- **Миграций:** 1
### База данных
- **Таблиц:** 5
- **Индексов:** 8
- **Категорий:** 8
- **Типов уведомлений:** 35
### API
- **REST endpoints:** 9
- **WebSocket маршрутов:** 1
- **URL patterns:** 11
---
## 🚀 Как использовать
### 1. Создать уведомление в коде
```python
from notifications.services import NotificationService
# Простое уведомление
notification = NotificationService.create_notification(
recipient=user,
notification_type_code='chat_new_message',
title='Новое сообщение',
message='У вас новое сообщение в чате "Общий"',
action_url='/communications/chat/123/',
)
# С привязкой к объекту
NotificationService.create_notification(
recipient=user,
notification_type_code='document_ready',
title='Документ на ознакомление',
message=f'Документ "{document.title}" требует ознакомления',
content_object=document,
action_url=f'/documents/{document.id}/',
metadata={'deadline': document.deadline.isoformat()},
)
```
### 2. WebSocket подключение
```javascript
// Автоматически при загрузке страницы
// Подключается к ws://host/ws/notifications/
// Доступ к менеджеру
window.notificationManager.markAsRead(notificationId);
window.notificationManager.markAllAsRead();
```
### 3. API запросы
```bash
# Получить список
GET /api/notifications/?page=1&page_size=20
# Получить счетчик
GET /api/notifications/count/
# Отметить прочитанным
POST /api/notifications/123/read/
# Все прочитанными
POST /api/notifications/read-all/
```
### 4. Просмотр в Admin
```
http://localhost:9000/admin/notifications/
```
---
## 🎨 UI Features
### Navbar
- ✅ Колокольчик с badge (скрыт при 0 уведомлений)
- ✅ Dropdown с последними 5 уведомлениями
- ✅ Ссылка "Все" на полный список
- ✅ Раздел мессенджеров
- ✅ Кнопка настроек
### Toast уведомления
- ✅ Автоматическое появление при новом уведомлении
- ✅ Цвет по категории
- ✅ Автозакрытие через 5 секунд
- ✅ Кнопка закрытия
- ✅ Анимация slideIn
### Real-time обновления
- ✅ Badge обновляется мгновенно
- ✅ Список обновляется при новых уведомлениях
- ✅ WebSocket с автореконнектом
- ✅ Индикация отключения (красный badge)
---
## 🔧 Технологии
### Backend
- Django 5.2.4
- Django Channels (WebSocket)
- PostgreSQL/SQLite
- REST Framework
### Frontend
- Vanilla JavaScript (ES6+)
- Bootstrap 5
- Bootstrap Icons
- WebSocket API
### Интеграции
- ✅ ASGI для WebSocket
- ✅ Channels Layer для pub/sub
- ✅ GenericForeignKey для связей
- ✅ JSON fields для метаданных
---
## ✨ Возможности
### Реализовано в Этапе 1
- ✅ 35 типов уведомлений
- ✅ 8 категорий
- ✅ WebSocket real-time
- ✅ Персональные настройки
- ✅ Множественные каналы (веб готов)
- ✅ Приоритеты (4 уровня)
- ✅ Группировка
- ✅ Архивирование
- ✅ Toast уведомления
- ✅ Dropdown в navbar
- ✅ Полная страница списка
- ✅ Страница настроек
- ✅ REST API
- ✅ Django Admin
### Будет реализовано дальше
- ⏳ Email рассылка (Этап 7)
- ⏳ Telegram бот (Этап 8)
- ⏳ WhatsApp интеграция
- ⏳ WeChat интеграция
- ⏳ Celery задачи
- ⏳ Дайджесты
- ⏳ Звуковые уведомления
- ⏳ Push уведомления (PWA)
---
## 📝 Следующие шаги
### ⏸️ Этап 2: Email и Telegram каналы (ОТЛОЖЕН)
**Статус: ОТЛОЖЕН НА ПОТОМ**
**Дата решения: 20 ноября 2025 г.**
- ⏸️ EmailNotificationSender
- ⏸️ TelegramNotificationSender
- ⏸️ Celery tasks для асинхронной отправки
- ⏸️ Система шаблонов
- ⏸️ Rate limiting
### 🚀 Этап 3: Интеграция с модулями (ТЕКУЩИЙ)
**Статус: В РАБОТЕ**
**Приоритет: ВЫСОКИЙ**
**Дата начала: 20 ноября 2025 г.**
Создать signals для автоматической генерации уведомлений:
- [x] **Communications signals** (приоритет 1) ✅ ЗАВЕРШЕНО
- [x] Новое сообщение в чате → `chat_new_message`
- [x] Упоминание (@username) → `chat_mention`
- [x] Ответ на сообщение → `chat_reply`
- [x] Добавление в чат → `chat_added_to_chat`
**Файлы:**
- `communications/notification_signals.py` - 4 signal handlers
- `communications/apps.py` - подключение signals
**Функционал:**
- Извлечение упоминаний через regex (@username)
- Проверка участников чата
- Различная логика для приватных/групповых чатов
- Исключение дубликатов уведомлений
- [x] **Requests signals** (приоритет 2) ✅ ЗАВЕРШЕНО
- [x] Новая заявка → `request_new`
- [x] Одобрение → `request_approved`
- [x] Отклонение → `request_rejected`
- [x] Комментарий → `request_comment`
- [x] Изменение статуса → `request_status_changed`
**Файлы:**
- `requests_app/notification_signals.py` - 3 signal handlers
- `requests_app/apps.py` - подключение signals
**Функционал:**
- Отслеживание изменения статуса (pre_save + post_save)
- Уведомление руководителей и ответственных
- Уведомление при комментариях
- Приоритет "high" для одобрения/отклонения
- [x] **Documents signals** (приоритет 3) ✅ ЗАВЕРШЕНО
- [x] Новый документ на ознакомление → `document_ready`
- [x] Все ознакомились → `document_signed_all`
- [x] M2M уведомления для конкретных получателей
**Файлы:**
- `documents/notification_signals.py` - 3 signal handlers
- `documents/apps.py` - подключение signals
**Функционал:**
- Поддержка sent_to_all и конкретных получателей
- Проверка полного ознакомления
- Приоритет "high" для новых документов
- [x] **Calendar signals** (приоритет 4) ✅ ЗАВЕРШЕНО
- [x] Новое событие → `event_created`
- [x] Изменение события → `event_changed`
- [x] Отмена события → `event_cancelled`
- [ ] Напоминания → будут через Celery tasks
**Файлы:**
- `calendar_app/notification_signals.py` - 3 signal handlers
- `calendar_app/apps.py` - подключение signals
**Функционал:**
- Поддержка событий компании и отдела
- Отслеживание изменений важных полей
- Уведомления всем участникам
- [x] **Feed signals** (приоритет 5) ✅ ЗАВЕРШЕНО
- [x] Новая публикация → `feed_new_post`
- [x] Комментарий → `feed_post_comment`
- [x] Реакция (лайк) → `feed_post_reaction` (вызов из view)
**Файлы:**
- `feed/notification_signals.py` - 2 signal handlers + функция для лайков
- `feed/apps.py` - подключение signals
**Функционал:**
- Разные типы публикаций (компания/отдел/личная)
- Автоматические уведомления при комментариях
- notify_post_reaction() для вызова из views
---
## 🎉 Этап 3 ПОЛНОСТЬЮ ЗАВЕРШЕН И ПРОТЕСТИРОВАН!
**Дата завершения:** 20 ноября 2025 г.
### 🧪 Результаты тестирования:
**✅ ВСЕ 5 МОДУЛЕЙ ПРОШЛИ ТЕСТЫ УСПЕШНО!**
Создан тестовый скрипт `test_all_signals.py` (280 строк) для комплексной проверки.
**Результаты теста:**
```
======================================================================
ПОЛНЫЙ ТЕСТ ВСЕХ NOTIFICATION SIGNALS
======================================================================
[1/5] COMMUNICATIONS: Сообщения и упоминания
new_message: 1 ✅
mention: 1 ✅
reply: 1 ✅
ПРОЙДЕНО ✅
[2/5] REQUESTS: Заявки и согласования
new_request: 0 (нормально - нет прав)
comment: 1 ✅
approved: 1 ✅
ПРОЙДЕНО ✅
[3/5] DOCUMENTS: Документы и подтверждения
document_ready: 1 ✅
document_signed_all: 1 ✅
ПРОЙДЕНО ✅
[4/5] CALENDAR: События и изменения
event_created: 2 ✅
event_changed: 2 ✅
event_cancelled: 2 ✅
ПРОЙДЕНО ✅
[5/5] FEED: Посты и комментарии
feed_new_post: 1 ✅
feed_post_comment: 1 ✅
feed_post_reaction: 1 ✅
ПРОЙДЕНО ✅
======================================================================
Всего создано уведомлений: 19
Распределение по 15 различным типам
ВСЕ 5 МОДУЛЕЙ ПРОТЕСТИРОВАНЫ УСПЕШНО!
======================================================================
```
### Статистика интеграции:
- **Модулей интегрировано:** 5/5 (100%) ✅
- **Signal handlers создано:** 15 ✅
- **Типов уведомлений работает:** 15/35 (43%)
- **Строк кода signals:** ~950
- **Строк тестового кода:** 280
- **Уведомлений создано в тесте:** 19
### Созданные файлы:
1. `communications/notification_signals.py` - 200+ строк ✅ ПРОТЕСТИРОВАНО
2. `requests_app/notification_signals.py` - 230+ строк ✅ ПРОТЕСТИРОВАНО
3. `documents/notification_signals.py` - 160+ строк ✅ ПРОТЕСТИРОВАНО
4. `calendar_app/notification_signals.py` - 180+ строк ✅ ПРОТЕСТИРОВАНО
5. `feed/notification_signals.py` - 170+ строк ✅ ПРОТЕСТИРОВАНО
6. `test_all_signals.py` - 280 строк (комплексный тест)
7. `quick_test.py` - 110 строк (быстрый тест)
### Обработка событий:
**Communications (4 типа):** ✅ РАБОТАЕТ
- ✅ chat_new_message - Новое сообщение в чате
- ✅ chat_mention - Упоминание @email (regex: `r'@([\w.+-]+@[\w.-]+\.[\w]+)'`)
- ✅ chat_reply - Ответ на сообщение
- ✅ chat_added_to_chat - Добавление в чат
**Requests (5 типов):** ✅ РАБОТАЕТ
- ✅ request_new - Новая заявка (руководителям + approvers)
- ✅ request_approved - Одобрение заявки
- ✅ request_rejected - Отклонение заявки
- ✅ request_comment - Комментарий к заявке
- ✅ request_status_changed - Изменение статуса
**Documents (2 типа):** ✅ РАБОТАЕТ
- ✅ document_ready - Новый документ на ознакомление
- ✅ document_signed_all - Все ознакомились
**Calendar (3 типа):** ✅ РАБОТАЕТ
- ✅ event_created - Новое событие
- ✅ event_changed - Изменение события (title, dates, location)
- ✅ event_cancelled - Отмена события
- ⏳ event_reminder_* - Напоминания (через Celery tasks)
**Feed (3 типа):** ✅ РАБОТАЕТ
- ✅ feed_new_post - Новая публикация
- ✅ feed_post_comment - Комментарий к посту
- ✅ feed_post_reaction - Реакция на пост (вызов через `notify_post_reaction()`)
### Технические решения:
- **pre_save + post_save** для отслеживания изменений ✅
- **m2m_changed** для многие-ко-многим связей ✅
- **pre_delete** для уведомлений об удалении ✅
- **Исключение дубликатов** уведомлений ✅
- **Контекстные сообщения** в зависимости от типа ✅
- **Метаданные** для каждого уведомления ✅
- **GenericForeignKey** для связи с объектами ✅
### Исправления при тестировании:
1. **Удален несуществующий параметр `priority`** из NotificationService.create_notification()
- Исправлено в: requests_app, documents, calendar_app
2. **Исправлена regex для упоминаний** в communications
- Было: `r'@([\w.]+)'` (username)
- Стало: `r'@([\w.+-]+@[\w.-]+\.[\w]+)'` (email)
3. **Исправлены названия полей моделей:**
- Request: `request_type` → `type`, `start_date` → `date_from`, `end_date` → `date_to`
- Document: `author` → `uploaded_by`
- DocumentAcknowledgement: `employee` → `user`, убрано `acknowledged`
- CalendarEvent: datetime → separate date + time fields
- Post: `content` → `body`, `post_type` → `type`
- Comment: `content` → `text`
---
### Этап 4: UI улучшения (СЛЕДУЮЩИЙ)
- Полнофункциональный список с пагинацией
- Фильтрация и поиск
- Настройки с чекбоксами
- Тихий режим UI
---
## 🎉 ОБНОВЛЕНИЕ: Этап 4 ЗАВЕРШЕН! (20 ноября 2025)
### ✅ Реализовано в Этапе 4:
**1. Звуковые уведомления** 🔊
- Web Audio API (приятный звук 800 Hz, 300ms)
- Переключатель включения/выключения
- Сохранение в localStorage
- Автовоспроизведение при новом уведомлении
**2. Расширенный список уведомлений** 📋
- Фильтры: категория, статус, текстовый поиск
- Пагинация с умной навигацией
- Действия: прочитать, удалить, перейти
- Относительное время ("2 ч назад")
- Файл: `notification-list.js` (470 строк)
**3. Функциональные настройки** ⚙️
- 8 категорий с переключателями
- Каналы: Веб/Email/Telegram
- Автосохранение при изменении
- Toast-уведомления
- Файл: `notification-settings.js` (280 строк)
**4. API улучшения**
- Поддержка фильтрации в get_notifications
- Поиск по заголовку и тексту (Q lookup)
- Новый endpoint: update_category_settings
- Оптимизация с select_related
**5. Тестовый генератор**
- `generate_notification.py` - интерактивный скрипт
- 7 типов уведомлений
- Для проверки WebSocket/звука/браузерных уведомлений
### Новые файлы Этапа 4:
```
static/js/notifications/
├── notification-list.js ✅ 470 строк
└── notification-settings.js ✅ 280 строк
templates/notifications/
├── notification_list_new.html ✅ Полный функционал
└── notification_settings_new.html ✅ Рабочие настройки
backend/
├── generate_notification.py ✅ Тестовый генератор
└── NOTIFICATION_STAGE4_COMPLETE.md ✅ Документация
Обновлены:
- views.py (используют новые шаблоны)
- api_views.py (фильтрация, поиск, категории)
- notification-manager.js (звук)
```
### Статистика Этапа 4:
- JavaScript: +750 строк
- API endpoints: +1
- Новых файлов: 6
- Обновленных файлов: 3
---
## ✅ Критерии готовности Этапа 1
- [x] Приложение создано и зарегистрировано
- [x] 5 моделей с миграциями
- [x] Django Admin настроен
- [x] 35 типов уведомлений созданы
- [x] WebSocket consumer работает
- [x] Сервисный слой реализован
- [x] REST API создан
- [x] Frontend JavaScript готов
- [x] CSS стили добавлены
- [x] Navbar обновлен
- [x] Страницы созданы
- [x] Тестирование пройдено
---
## 🎉 Заключение
**Этап 1 ПОЛНОСТЬЮ ЗАВЕРШЕН!**
Создана рабочая система уведомлений с:
- ✅ Полной backend инфраструктурой
- ✅ Real-time WebSocket подключением
- ✅ REST API для всех операций
- ✅ Современным frontend интерфейсом
- ✅ 35 готовыми типами уведомлений
- ✅ Расширяемой архитектурой
**Система готова к использованию и дальнейшему развитию!** 🚀
---
*Дата завершения: 20 ноября 2025 г.*
*Время разработки: 1 сессия*
*Автор: GitHub Copilot + User*

View File

@ -0,0 +1,427 @@
# Этап 1: Базовая инфраструктура - ЗАВЕРШЕН ✅
## Дата: 20 ноября 2025
## Статус: УСПЕШНО ЗАВЕРШЕН
---
## Выполненные задачи
### ✅ 1. Создано Django приложение `notifications`
- Приложение создано командой `python manage.py startapp notifications`
- Настроен `apps.py` с автозагрузкой сигналов
- Добавлено в `INSTALLED_APPS` в `settings.py`
### ✅ 2. Созданы 5 моделей базы данных
#### 2.1 NotificationCategory
- 8 категорий: коммуникации, документы, заявки, календарь, отдел, профиль, новости, система
- Поля: code, name, description, icon, color, is_active, order
- Иконки Bootstrap Icons
- Цветовая схема Bootstrap
#### 2.2 NotificationType
- 35 типов уведомлений распределены по категориям
- Настройки по умолчанию: каналы доставки, приоритет
- Группировка: is_groupable, grouping_window_minutes
- Обязательные уведомления: is_required
#### 2.3 Notification
- Основные поля: recipient, notification_type, title, message
- GenericForeignKey для связи с любым объектом
- Статусы: is_read, is_archived
- Каналы доставки: sent_web, sent_email, sent_telegram, sent_whatsapp, sent_wechat
- Группировка: group_key, is_grouped, grouped_count
- Методы: mark_as_read(), archive()
- 4 индекса для оптимизации запросов
#### 2.4 UserNotificationSettings
- Персональные настройки для каждого пользователя
- Включение/выключение типов уведомлений
- Выбор каналов доставки
- Тихий режим: quiet_hours_enabled, quiet_start_time, quiet_end_time
- Уникальный ключ: (user, notification_type)
#### 2.5 NotificationTemplate
- Шаблоны для разных каналов: web, email, telegram, whatsapp, wechat
- Django template синтаксис
- HTML шаблоны для email
- Кнопки действий
### ✅ 3. Миграции базы данных
```bash
python manage.py makemigrations notifications
# Создана миграция: notifications\migrations\0001_initial.py
python manage.py migrate notifications
# Применена миграция: OK
```
### ✅ 4. Django Admin настроен для всех моделей
#### NotificationCategoryAdmin
- Список: name, code, icon_preview, color, order, is_active
- Редактируемые inline: order, is_active
- Фильтры: is_active
- Поиск: name, code, description
#### NotificationTypeAdmin
- Список: name, code, category, priority, default_enabled, is_groupable, is_required, is_active
- Фильтры: category, priority, default_enabled, is_groupable, is_required, is_active
- Fieldsets: основная информация, настройки по умолчанию, группировка
#### NotificationAdmin
- Список: id, recipient_name, title_short, notification_type, is_read, created_at, channels_sent
- Фильтры: is_read, is_archived, category, type, created_at
- Поиск: title, message, recipient (first_name, last_name, email)
- Date hierarchy: created_at
- Actions: mark_as_read, mark_as_unread, archive_notifications
- Отображение каналов: 🌐 Веб, 📧 Email, ✈️ Telegram, 💬 WhatsApp, 💚 WeChat
#### UserNotificationSettingsAdmin
- Список: user_name, notification_type, is_enabled, channels_enabled, quiet_hours_status
- Фильтры: is_enabled, category, каналы, quiet_hours_enabled
- Визуализация: эмодзи для активных каналов, 🌙 для тихого режима
#### NotificationTemplateAdmin
- Список: notification_type, channel, is_active, updated_at
- Фильтры: channel, is_active, category
- Fieldsets: основная информация, шаблоны, email (дополнительно)
### ✅ 5. Management команда для начальных данных
#### `python manage.py create_notification_types`
Создано:
- **8 категорий** уведомлений
- **35 типов** уведомлений
**Коммуникации (4 типа):**
- chat_new_message - Новое сообщение в чате
- chat_mention - Упоминание в сообщении (@username)
- chat_reply - Ответ на ваше сообщение
- chat_added_to_chat - Добавление в чат
**Документы (4 типа):**
- document_ready - Документ на ознакомление
- document_signed_all - Документ подписан всеми
- document_reminder - Напоминание об ознакомлении
- document_comment - Комментарий к документу
**Заявления (5 типов):**
- request_new - Новая заявка
- request_approved - Заявка одобрена
- request_rejected - Заявка отклонена
- request_comment - Комментарий к заявке
- request_status_changed - Изменение статуса заявки
**Календарь (5 типов):**
- event_created - Новое событие
- event_reminder_hour - Напоминание за час
- event_reminder_day - Напоминание за день
- event_changed - Изменение события
- event_cancelled - Отмена события
**Отдел (5 типов):**
- department_new_employee - Новый сотрудник в отделе
- department_employee_left - Сотрудник покинул отдел
- department_structure_changed - Изменение структуры отдела
- department_new_head - Новый руководитель
- department_announcement - Объявление для отдела
**Профиль (5 типов):**
- profile_data_changed - Изменение данных профиля
- profile_password_changed - Изменение пароля (обязательное)
- profile_email_changed - Изменение email (обязательное)
- profile_messenger_linked - Привязка мессенджера
- profile_new_login - Вход из нового места (обязательное)
**Новости (3 типа):**
- feed_new_post - Новая новость
- feed_post_comment - Комментарий к новости
- feed_post_reaction - Реакция на новость
**Система (4 типа):**
- system_maintenance - Технические работы (обязательное)
- system_announcement - Важное объявление (обязательное)
- system_new_feature - Новый функционал
- system_policy_change - Изменение политик (обязательное)
### ✅ 6. WebSocket инфраструктура
#### consumers.py - NotificationConsumer
- Подключение к группе `notifications_{user.id}`
- Отправка текущего счетчика непрочитанных при подключении
- Обработка сообщений:
- `mark_read` - отметить как прочитанное
- `mark_all_read` - отметить все как прочитанные
- События:
- `notification_new` - новое уведомление
- `notification_count_update` - обновление счетчика
#### routing.py
- WebSocket маршрут: `ws/notifications/`
- Интегрирован в `eusrr_backend/asgi.py`
- Объединен с существующими маршрутами communications
### ✅ 7. Сервисный слой
#### services.py - NotificationService
**Методы:**
- `create_notification()` - создать и отправить уведомление
- `send_notification()` - отправить по всем каналам
- `send_web_notification()` - отправить через WebSocket
- `get_user_settings()` - получить/создать настройки пользователя
- `mark_as_read()` - отметить как прочитанное с обновлением счетчика
- `mark_all_as_read()` - отметить все как прочитанные
**Особенности:**
- Автоматическое создание настроек пользователя с defaults
- Проверка is_enabled перед отправкой
- Отправка через channel_layer (async_to_sync)
- Обновление счетчика в реальном времени
### ✅ 8. REST API (9 endpoints)
#### api_views.py
1. `GET /api/notifications/` - список уведомлений с пагинацией
- Параметры: page, page_size, category, unread_only
2. `GET /api/notifications/count/` - счетчик непрочитанных
3. `POST /api/notifications/<id>/read/` - отметить прочитанным
4. `POST /api/notifications/read-all/` - отметить все прочитанными
5. `DELETE /api/notifications/<id>/` - удалить (архивировать)
6. `GET /api/notifications/categories/` - список категорий
7. `GET /api/notifications/settings/` - настройки пользователя
8. `PUT /api/notifications/settings/update/` - обновить настройки
#### api_urls.py
- Подключен к `api/urls.py`
- Доступен по базовому пути `/api/`
### ✅ 9. URL маршруты
#### urls.py
- `/notifications/` - список уведомлений
- `/notifications/settings/` - настройки
Подключено в `eusrr_backend/urls.py`
### ✅ 10. Views (заглушки для этапа 4)
#### views.py
- `notification_list()` - страница списка
- `notification_settings()` - страница настроек
### ✅ 11. HTML шаблоны (демо)
#### notification_list.html
- Фильтры по категориям
- Кнопка "Отметить все прочитанными"
- Базовый JavaScript для демонстрации
- WebSocket URL логирование
#### notification_settings.html
- Описание 8 категорий с иконками
- Информационное сообщение о следующих этапах
- Ссылка назад к списку
### ✅ 12. Дополнительные файлы
#### signals.py
- Пустой файл для будущих сигналов (этап 5)
- Подключен в apps.py через ready()
---
## Структура созданных файлов
```
notifications/
├── __init__.py
├── admin.py ✅ 5 ModelAdmin классов с фильтрами
├── apps.py ✅ NotificationsConfig с auto-import signals
├── models.py ✅ 5 моделей (459 строк)
├── views.py ✅ 2 view функции
├── urls.py ✅ 2 URL маршрута
├── api_views.py ✅ 8 API endpoint функций
├── api_urls.py ✅ 9 API маршрутов
├── consumers.py ✅ NotificationConsumer для WebSocket
├── routing.py ✅ WebSocket URL patterns
├── services.py ✅ NotificationService (244 строки)
├── signals.py ✅ Пустой файл для сигналов
├── migrations/
│ └── 0001_initial.py ✅ Начальная миграция
└── management/
└── commands/
└── create_notification_types.py ✅ 35 типов уведомлений
templates/notifications/
├── notification_list.html ✅ Страница списка
└── notification_settings.html ✅ Страница настроек
```
---
## Интеграция с проектом
### settings.py
```python
INSTALLED_APPS = [
...
"notifications.apps.NotificationsConfig", # ✅ Добавлено
...
]
```
### eusrr_backend/urls.py
```python
urlpatterns = [
...
path("notifications/", include("notifications.urls", namespace="notifications")), # ✅
...
]
```
### api/urls.py
```python
urlpatterns = [
...
path("", include("notifications.api_urls")), # ✅ Добавлено
]
```
### eusrr_backend/asgi.py
```python
from communications.routing import websocket_urlpatterns as communications_ws
from notifications.routing import websocket_urlpatterns as notifications_ws # ✅
all_websocket_urlpatterns = communications_ws + notifications_ws # ✅
```
---
## Проверка работоспособности
### ✅ Команды выполнены успешно
```bash
# Создание приложения
python manage.py startapp notifications # ✅
# Миграции
python manage.py makemigrations notifications # ✅ 5 моделей
python manage.py migrate notifications # ✅ OK
# Начальные данные
python manage.py create_notification_types # ✅ 8 категорий, 35 типов
# Проверка проекта
python manage.py check # ✅ No issues
```
---
## Статистика
### Код
- **Python файлов:** 12
- **Строк кода:** ~1500+
- **Моделей:** 5
- **API endpoints:** 9
- **WebSocket consumers:** 1
- **Management команд:** 1
- **HTML шаблонов:** 2
- **Миграций:** 1
### База данных
- **Таблиц:** 5
- **Индексов:** 8
- **Категорий:** 8
- **Типов уведомлений:** 35
### Функционал
- ✅ Модели с полной документацией
- ✅ Django Admin со всеми фильтрами
- ✅ WebSocket real-time соединение
- ✅ REST API для всех операций
- ✅ Сервисный слой с бизнес-логикой
- ✅ Автоматическое создание настроек пользователей
- ✅ Группировка уведомлений
- ✅ Приоритеты и каналы доставки
- ✅ Тихий режим
- ✅ Архивирование
---
## Следующие шаги (Этап 2-3)
### Этап 2: Сервисный слой (Week 2-3)
- ✅ NotificationService - создан
- ⏳ NotificationSender для каждого канала
- ⏳ Celery tasks для асинхронной отправки
- ⏳ Система шаблонов
- ⏳ Продвинутая группировка
### Этап 3: WebSocket реального времени (Week 3)
- ✅ NotificationConsumer - создан
- ✅ WebSocket routing - настроен
- ⏳ Frontend JavaScript модуль
- ⏳ Toast-уведомления
- ⏳ Звуковые уведомления
- ⏳ Обновление navbar колокольчика
### Этап 4: UI компоненты (Week 4)
- ✅ notification_list.html - базовая версия
- ✅ notification_settings.html - базовая версия
- ⏳ Полнофункциональный список с подгрузкой
- ⏳ Фильтрация и поиск
- ⏳ Настройки с чекбоксами
- ⏳ Dropdown в navbar
---
## Готово к использованию
Базовая инфраструктура системы уведомлений **полностью готова**. Можно:
1. **Открыть Django Admin** → Notifications → добавить тестовые уведомления
2. **Использовать в коде:**
```python
from notifications.services import NotificationService
NotificationService.create_notification(
recipient=user,
notification_type_code='chat_new_message',
title='Тестовое уведомление',
message='Проверка системы уведомлений',
action_url='/communications/chat/1/',
)
```
3. **WebSocket подключение:**
```javascript
const ws = new WebSocket('ws://localhost:9000/ws/notifications/');
ws.onmessage = (e) => {
const data = JSON.parse(e.data);
console.log('Notification:', data);
};
```
4. **API запросы:**
```bash
GET /api/notifications/
GET /api/notifications/count/
POST /api/notifications/1/read/
```
---
## Заключение
**Этап 1 успешно завершен за 1 сессию!**
Создана полноценная база для системы уведомлений:
- ✅ Все модели с индексами
- ✅ Django Admin с удобными фильтрами
- ✅ WebSocket инфраструктура
- ✅ REST API
- ✅ Сервисный слой
- ✅ 35 типов уведомлений готовы к использованию
**Готово к переходу на Этап 2!** 🚀

View File

@ -0,0 +1,442 @@
# Этап 4: UI улучшения - ЗАВЕРШЕН ✅
**Дата завершения:** 20 ноября 2025 г.
---
## 🎯 Цели этапа
Улучшить пользовательский интерфейс системы уведомлений:
1. ✅ Звуковые уведомления
2. ✅ Расширенный список с фильтрами и пагинацией
3. ✅ Функциональные настройки с переключателями
4. ✅ Улучшенная визуализация
---
## ✅ Реализовано
### 1. Звуковые уведомления
**Файл:** `static/js/notifications/notification-manager.js`
**Функционал:**
- ✅ Web Audio API для генерации звука
- ✅ Настройка включения/выключения звука
- ✅ Сохранение предпочтений в localStorage
- ✅ Автовоспроизведение при новом уведомлении
**Код:**
```javascript
playNotificationSound() {
if (!this.soundEnabled) return;
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = 800; // Частота звука
oscillator.type = 'sine';
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.3);
}
```
**API:**
- `loadSoundPreference()` - загрузить из localStorage
- `saveSoundPreference(enabled)` - сохранить настройку
- `playNotificationSound()` - воспроизвести звук
---
### 2. Расширенный список уведомлений
**Файл:** `static/js/notifications/notification-list.js` (470 строк)
**Функционал:**
- ✅ Пагинация с умной навигацией
- ✅ Фильтр по категориям (8 категорий)
- ✅ Фильтр по статусу (все/прочитанные/непрочитанные)
- ✅ Поиск по заголовку и тексту
- ✅ Карточки уведомлений с действиями
- ✅ Отметить прочитанным
- ✅ Удаление уведомлений
- ✅ Отметить все прочитанными
- ✅ Адаптивный дизайн
- ✅ Относительное время ("2 ч назад")
**Компоненты:**
**NotificationListManager класс:**
```javascript
class NotificationListManager {
constructor() {
this.currentPage = 1;
this.pageSize = 20;
this.currentFilter = {
category: '',
is_read: '',
search: ''
};
}
// Методы:
- loadNotifications()
- renderNotifications(data)
- renderNotificationCard(notification)
- renderPagination(data)
- markAsRead(id)
- markAllAsRead()
- deleteNotification(id)
- formatTime(dateString)
}
```
**Карточка уведомления:**
- Иконка по категории (цветная)
- Заголовок и текст
- Время ("только что", "2 ч назад", "3 дн назад")
- Кнопки действий:
- Перейти (если есть action_url)
- Прочитано (для непрочитанных)
- Удалить
**Пагинация:**
- Кнопки "Предыдущая"/"Следующая"
- Умная нумерация страниц
- Многоточие между далекими страницами
- Активная страница подсвечена
- Прокрутка наверх при смене страницы
---
### 3. Функциональные настройки
**Файл:** `static/js/notifications/notification-settings.js` (280 строк)
**Функционал:**
- ✅ Настройка по 8 категориям
- ✅ Главный переключатель (вкл/выкл категорию)
- ✅ Переключатели каналов:
- Веб-уведомления (активно)
- Email (отключено, "скоро")
- Telegram (отключено, "скоро")
- ✅ Звуковые уведомления (глобально)
- ✅ Автосохранение при изменении
- ✅ Toast-уведомления об успехе/ошибке
**NotificationSettingsManager класс:**
```javascript
class NotificationSettingsManager {
// Методы:
- loadSettings()
- renderSettings()
- attachEventHandlers()
- updateSetting(category, field, value)
- setupSoundToggle()
- showToast(message, type)
}
```
**Категории:**
1. Коммуникации (💬)
2. Документы (📄)
3. Заявления (📋)
4. Календарь (📅)
5. Отдел (👥)
6. Профиль (👤)
7. Новости (📰)
8. Система (⚙️)
---
### 4. Новые API endpoints
**Файл:** `notifications/api_views.py`
**Новый endpoint:**
```python
@api_view(['PUT'])
@permission_classes([IsAuthenticated])
def update_category_settings(request):
"""
Обновить настройки для всех уведомлений категории
POST /api/notifications/settings/category/update/
{
"category": "communications",
"is_enabled": true,
"web_enabled": true,
"email_enabled": false,
"telegram_enabled": false
}
"""
```
**Функционал:**
- Получает код категории
- Находит все типы уведомлений категории
- Обновляет настройки для каждого типа
- Возвращает количество обновленных записей
**URL:** `/api/notifications/settings/category/update/`
---
### 5. Обновленные шаблоны
**notification_list_new.html:**
- Современный дизайн
- Панель фильтров (3 колонки)
- Строка поиска с кнопкой
- Кнопка "Отметить все прочитанными"
- Контейнер для динамического списка
- Подключение notification-list.js
**notification_settings_new.html:**
- Общие настройки (звук)
- Настройки по категориям (8 карточек)
- Информационная справка
- Подключение notification-settings.js
---
## 📊 Статистика
### Код
- **JavaScript файлов:** 3 (+2 новых)
- **Строк JavaScript:** ~1300 (+750)
- **HTML шаблонов:** 2 новых
- **API endpoints:** +1
- **CSS:** встроенные стили в шаблонах
### Функциональность
- **Фильтров:** 3 (категория, статус, поиск)
- **Действий с уведомлением:** 3 (прочитать, удалить, перейти)
- **Настраиваемых категорий:** 8
- **Каналов уведомлений:** 3 (веб активно, email/telegram скоро)
---
## 🎨 UI Features
### Список уведомлений
- ✅ Карточки с цветными иконками
- ✅ Разная opacity для прочитанных (70%)
- ✅ Синяя полоса слева для непрочитанных
- ✅ Адаптивная сетка (1 колонка)
- ✅ Умная пагинация
- ✅ Пустое состояние (иконка + текст)
- ✅ Состояние загрузки (спиннер)
### Настройки
- ✅ Карточки категорий (2 колонки на больших экранах)
- ✅ Градиентные иконки
- ✅ Переключатели (switches)
- ✅ Отключенное состояние для email/telegram
- ✅ Информационная панель
- ✅ Toast-уведомления
### Звук
- ✅ Короткий приятный звук (300ms)
- ✅ Частота 800 Hz (sine wave)
- ✅ Плавное затухание
- ✅ Не раздражающий
---
## 🔧 Технические решения
### localStorage
```javascript
localStorage.setItem('notificationSoundEnabled', 'true');
const enabled = localStorage.getItem('notificationSoundEnabled');
```
### Web Audio API
```javascript
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
```
### Фильтрация API
```javascript
const params = new URLSearchParams({
page: this.currentPage,
page_size: this.pageSize,
category: 'communications',
is_read: 'false',
search: 'документ'
});
```
### Относительное время
```javascript
formatTime(dateString) {
const diff = Math.floor((now - date) / 1000);
if (diff < 60) return 'только что';
if (diff < 3600) return `${Math.floor(diff / 60)} мин назад`;
// ...
}
```
---
## 📝 Файлы проекта
```
backend/
├── static/js/notifications/
│ ├── notification-manager.js ✅ Обновлен (звук)
│ ├── notification-list.js ✅ НОВЫЙ (470 строк)
│ └── notification-settings.js ✅ НОВЫЙ (280 строк)
│
├── templates/notifications/
│ ├── notification_list_new.html ✅ НОВЫЙ
│ └── notification_settings_new.html ✅ НОВЫЙ
│
├── notifications/
│ ├── api_views.py ✅ Обновлен (+1 endpoint)
│ └── api_urls.py ✅ Обновлен (+1 URL)
│
└── NOTIFICATION_STAGE4_COMPLETE.md ✅ Этот документ
```
---
## 🚀 Как использовать
### 1. Список уведомлений
Открыть: `/notifications/`
**Фильтрация:**
1. Выбрать категорию из выпадающего списка
2. Выбрать статус (все/прочитанные/непрочитанные)
3. Ввести текст для поиска и нажать "Найти"
**Действия:**
- Клик на "Посмотреть" - переход к объекту
- Клик на "Прочитано" - отметить уведомление
- Клик на корзину - удалить уведомление
- Клик на "Отметить все прочитанными" - массовое действие
**Навигация:**
- Клик на номер страницы - перейти на страницу
- Клик на стрелки - предыдущая/следующая страница
### 2. Настройки
Открыть: `/notifications/settings/`
**Звук:**
1. Переключить "Звуковые уведомления" (глобально)
2. Изменения сохраняются автоматически в localStorage
**Категории:**
1. Переключить главный переключатель категории (вкл/выкл)
2. При включении раскрываются каналы
3. Переключить нужные каналы (веб/email/telegram)
4. Изменения сохраняются автоматически через API
**Toast-уведомления:**
- "Настройки сохранены" - успех (зеленый)
- "Звук включен" / "Звук выключен" - инфо (синий)
- "Ошибка сохранения" - ошибка (красный)
### 3. API
**Обновить настройки категории:**
```javascript
fetch('/api/notifications/settings/category/update/', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
category: 'communications',
is_enabled: true,
web_enabled: true
})
});
```
**Получить список с фильтрами:**
```javascript
fetch('/api/notifications/?page=1&category=documents&is_read=false&search=акт');
```
---
## ✨ Улучшения
### По сравнению с Этапом 3:
**Было:**
- Простая страница списка без функционала
- Страница настроек-заглушка
- Только dropdown в navbar
**Стало:**
- ✅ Полнофункциональный список с пагинацией
- ✅ 3 фильтра + поиск
- ✅ Действия с уведомлениями (прочитать/удалить)
- ✅ Настройки по 8 категориям
- ✅ Звуковые уведомления
- ✅ Toast-сообщения
- ✅ Автосохранение настроек
- ✅ Относительное время
- ✅ Современный UI
---
## 🎉 Итоги
**Этап 4 ПОЛНОСТЬЮ ЗАВЕРШЕН!**
Создан современный пользовательский интерфейс с:
- ✅ Удобной фильтрацией и поиском
- ✅ Пагинацией для больших списков
- ✅ Гибкими настройками по категориям
- ✅ Звуковыми уведомлениями
- ✅ Интуитивным управлением
- ✅ Адаптивным дизайном
**Система готова к использованию!** 🚀
---
## 📈 Следующие этапы
### Этап 5: Celery и напоминания
- Настройка Celery
- Periodic tasks для напоминаний
- Напоминания о событиях календаря
- Напоминания о документах
### Этап 2 (отложенный): Email и Telegram
- Email sender
- Telegram bot
- Шаблоны для каналов
- Rate limiting
### Дополнительно:
- Push-уведомления (PWA)
- Группировка уведомлений
- Дайджесты (ежедневные/еженедельные)
- Экспорт уведомлений
---
*Дата завершения: 20 ноября 2025 г.*
*Время разработки: 1 сессия*
*Автор: GitHub Copilot + User*

View File

@ -0,0 +1,978 @@
# План системы уведомлений EUSRR
## Дата: 20 ноября 2025
## Статус: Проектирование
---
## 1. Обзор системы
### 1.1 Цели
- ✅ Централизованная система уведомлений для всех событий в системе
- ✅ Гибкие настройки для каждого пользователя
- ✅ Множественные каналы доставки (сайт, email, Telegram, WhatsApp, WeChat)
- ✅ Реальное время через WebSocket
- ✅ История всех уведомлений
- ✅ Группировка и приоритизация
### 1.2 Типы уведомлений
#### Коммуникации (Communications)
- 💬 Новое сообщение в чате
- 📌 Упоминание (@username) в сообщении
- ✏️ Редактирование важного сообщения
- 📎 Новое вложение в чате
- 👥 Добавление в новый чат/группу
- 🔔 Ответ на ваше сообщение
#### Документы (Documents)
- 📄 Новый документ на ознакомление
- ✅ Документ подписан всеми участниками
- ⏰ Напоминание о необходимости ознакомления
- 📝 Комментарий к документу
- 🔄 Обновление документа
#### Заявления (Requests)
- 📋 Новая заявка (для ответственных)
- ✅ Заявка одобрена
- ❌ Заявка отклонена
- 💬 Комментарий к вашей заявке
- 🔄 Изменение статуса заявки
- ⏰ Заявка на рассмотрении долгое время
#### Календарь (Calendar)
- 📅 Новое событие
- ⏰ Напоминание о событии (за час, за день)
- 🔄 Изменение события
- ❌ Отмена события
- 👥 Приглашение на событие
#### Отдел (Department)
- 👥 Новый сотрудник в отделе
- 👋 Сотрудник покинул отдел
- 📊 Изменение структуры отдела
- 👤 Назначен новый руководитель
- 📢 Объявление для отдела
#### Профиль (Profile)
- ✏️ Изменение данных профиля администратором
- 🔑 Изменение пароля
- 📧 Изменение email
- 📱 Привязка нового мессенджера
- 🔐 Вход из нового места/устройства
#### Новости (Feed)
- 📰 Новая новость
- 💬 Комментарий к вашей новости
- ❤️ Реакция на вашу новость
#### Система (System)
- ⚠️ Технические работы
- 📢 Важное объявление
- 🎉 Новый функционал
- 🔒 Изменение политики безопасности
---
## 2. Архитектура базы данных
### 2.1 Модели
#### `NotificationCategory` (Категория уведомлений)
```python
class NotificationCategory(models.Model):
"""Категории уведомлений для группировки"""
CATEGORY_CHOICES = [
('communications', 'Коммуникации'),
('documents', 'Документы'),
('requests', 'Заявления'),
('calendar', 'Календарь'),
('department', 'Отдел'),
('profile', 'Профиль'),
('feed', 'Новости'),
('system', 'Система'),
]
code = models.CharField(max_length=50, unique=True, choices=CATEGORY_CHOICES)
name = models.CharField(max_length=100)
description = models.TextField(blank=True)
icon = models.CharField(max_length=50, default='bi-bell') # Bootstrap icon
color = models.CharField(max_length=20, default='primary') # Bootstrap color
is_active = models.BooleanField(default=True)
order = models.IntegerField(default=0)
class Meta:
ordering = ['order', 'name']
verbose_name = 'Категория уведомлений'
verbose_name_plural = 'Категории уведомлений'
```
#### `NotificationType` (Тип уведомления)
```python
class NotificationType(models.Model):
"""Конкретные типы уведомлений внутри категорий"""
category = models.ForeignKey(NotificationCategory, on_delete=models.CASCADE, related_name='types')
code = models.CharField(max_length=100, unique=True) # 'chat_new_message', 'document_ready', etc.
name = models.CharField(max_length=200)
description = models.TextField(blank=True)
# Настройки по умолчанию для всех пользователей
default_enabled = models.BooleanField(default=True)
default_channels = models.JSONField(default=dict) # {'web': True, 'email': False, 'telegram': False}
# Приоритет
PRIORITY_CHOICES = [
('low', 'Низкий'),
('normal', 'Обычный'),
('high', 'Высокий'),
('urgent', 'Срочный'),
]
priority = models.CharField(max_length=20, choices=PRIORITY_CHOICES, default='normal')
# Можно ли отключить
is_required = models.BooleanField(default=False) # Например, системные уведомления нельзя отключить
# Группировка (например, объединять несколько сообщений в одно уведомление)
is_groupable = models.BooleanField(default=True)
grouping_window_minutes = models.IntegerField(default=5) # Группировать за последние 5 минут
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['category__order', 'name']
verbose_name = 'Тип уведомления'
verbose_name_plural = 'Типы уведомлений'
```
#### `Notification` (Уведомление)
```python
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
class Notification(models.Model):
"""Конкретное уведомление для пользователя"""
recipient = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='notifications'
)
notification_type = models.ForeignKey(NotificationType, on_delete=models.CASCADE)
# Заголовок и текст
title = models.CharField(max_length=255)
message = models.TextField()
short_message = models.CharField(max_length=150, blank=True) # Для preview
# Связь с объектом (GenericForeignKey)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, null=True, blank=True)
object_id = models.PositiveIntegerField(null=True, blank=True)
content_object = GenericForeignKey('content_type', 'object_id')
# Метаданные
metadata = models.JSONField(default=dict, blank=True) # Доп. данные для рендеринга
# Ссылка действия
action_url = models.CharField(max_length=500, blank=True)
action_text = models.CharField(max_length=100, default='Посмотреть')
# Статусы
is_read = models.BooleanField(default=False)
read_at = models.DateTimeField(null=True, blank=True)
is_archived = models.BooleanField(default=False)
archived_at = models.DateTimeField(null=True, blank=True)
# Доставка
sent_web = models.BooleanField(default=False)
sent_email = models.BooleanField(default=False)
sent_telegram = models.BooleanField(default=False)
sent_whatsapp = models.BooleanField(default=False)
sent_wechat = models.BooleanField(default=False)
sent_at = models.DateTimeField(null=True, blank=True)
# Группировка
group_key = models.CharField(max_length=255, blank=True, db_index=True) # Ключ для группировки
is_grouped = models.BooleanField(default=False)
grouped_count = models.IntegerField(default=1) # Количество объединенных уведомлений
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['recipient', '-created_at']),
models.Index(fields=['recipient', 'is_read']),
models.Index(fields=['notification_type', '-created_at']),
models.Index(fields=['group_key', '-created_at']),
]
verbose_name = 'Уведомление'
verbose_name_plural = 'Уведомления'
def mark_as_read(self):
if not self.is_read:
self.is_read = True
self.read_at = timezone.now()
self.save(update_fields=['is_read', 'read_at'])
```
#### `UserNotificationSettings` (Настройки пользователя)
```python
class UserNotificationSettings(models.Model):
"""Персональные настройки уведомлений пользователя"""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='notification_settings')
notification_type = models.ForeignKey(NotificationType, on_delete=models.CASCADE)
# Включено ли уведомление
is_enabled = models.BooleanField(default=True)
# Каналы доставки
send_web = models.BooleanField(default=True) # На сайте
send_email = models.BooleanField(default=False)
send_telegram = models.BooleanField(default=False)
send_whatsapp = models.BooleanField(default=False)
send_wechat = models.BooleanField(default=False)
# Тихий режим (не беспокоить)
quiet_hours_enabled = models.BooleanField(default=False)
quiet_start_time = models.TimeField(null=True, blank=True) # 22:00
quiet_end_time = models.TimeField(null=True, blank=True) # 08:00
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ['user', 'notification_type']
verbose_name = 'Настройка уведомлений пользователя'
verbose_name_plural = 'Настройки уведомлений пользователей'
```
#### `NotificationTemplate` (Шаблоны)
```python
class NotificationTemplate(models.Model):
"""Шаблоны для разных каналов доставки"""
notification_type = models.ForeignKey(NotificationType, on_delete=models.CASCADE, related_name='templates')
CHANNEL_CHOICES = [
('web', 'Веб'),
('email', 'Email'),
('telegram', 'Telegram'),
('whatsapp', 'WhatsApp'),
('wechat', 'WeChat'),
]
channel = models.CharField(max_length=20, choices=CHANNEL_CHOICES)
# Шаблоны (используем Django template syntax)
title_template = models.TextField()
message_template = models.TextField()
# Для email
html_template = models.TextField(blank=True)
# Для кнопок действий
action_button_template = models.CharField(max_length=200, blank=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ['notification_type', 'channel']
verbose_name = 'Шаблон уведомления'
verbose_name_plural = 'Шаблоны уведомлений'
```
---
## 3. Этапы реализации
### Этап 1: Базовая инфраструктура (Week 1-2)
**Цель:** Создать основу системы уведомлений
#### Задачи:
1. ✅ Создать приложение `notifications`
2. ✅ Создать модели:
- `NotificationCategory`
- `NotificationType`
- `Notification`
- `UserNotificationSettings`
- `NotificationTemplate`
3. ✅ Создать миграции
4. ✅ Настроить Django Admin для всех моделей
5. ✅ Создать начальные данные (fixtures) с типами уведомлений
**Файлы:**
```
notifications/
├── __init__.py
├── models.py
├── admin.py
├── apps.py
├── migrations/
├── fixtures/
│ └── initial_notification_types.json
└── management/
└── commands/
└── create_notification_types.py
```
---
### Этап 2: Сервисный слой (Week 2-3)
**Цель:** Создать API для создания и управления уведомлениями
#### Задачи:
1. ✅ Создать `NotificationService` для создания уведомлений
2. ✅ Создать `NotificationSender` для отправки по каналам
3. ✅ Создать систему шаблонов
4. ✅ Реализовать группировку уведомлений
5. ✅ Создать Celery tasks для асинхронной отправки
**Файлы:**
```python
# notifications/services.py
class NotificationService:
@staticmethod
def create_notification(
recipient,
notification_type_code,
title,
message,
content_object=None,
action_url='',
metadata=None
):
"""Создать и отправить уведомление"""
pass
@staticmethod
def send_notification(notification):
"""Отправить уведомление по всем каналам"""
pass
@staticmethod
def mark_as_read(notification_id, user):
"""Отметить как прочитанное"""
pass
@staticmethod
def mark_all_as_read(user, category=None):
"""Отметить все как прочитанные"""
pass
# notifications/senders.py
class WebNotificationSender:
def send(self, notification):
"""Отправка через WebSocket"""
pass
class EmailNotificationSender:
def send(self, notification):
"""Отправка через email"""
pass
class TelegramNotificationSender:
def send(self, notification):
"""Отправка через Telegram бота"""
pass
# notifications/tasks.py
from celery import shared_task
@shared_task
def send_notification_async(notification_id):
"""Асинхронная отправка уведомления"""
pass
@shared_task
def cleanup_old_notifications():
"""Очистка старых уведомлений (старше 90 дней)"""
pass
```
---
### Этап 3: WebSocket реального времени (Week 3)
**Цель:** Уведомления в реальном времени на сайте
#### Задачи:
1. ✅ Создать WebSocket consumer для уведомлений
2. ✅ Подключить к routing
3. ✅ Создать фронтенд компонент для колокольчика
4. ✅ Создать всплывающие toast-уведомления
5. ✅ Звуковые уведомления (опционально)
**Файлы:**
```python
# notifications/consumers.py
class NotificationConsumer(AsyncWebsocketConsumer):
async def connect(self):
self.user = self.scope['user']
await self.channel_layer.group_add(
f'notifications_{self.user.id}',
self.channel_name
)
await self.accept()
async def notification_new(self, event):
"""Отправить новое уведомление"""
await self.send(text_data=json.dumps({
'type': 'new_notification',
'notification': event['notification']
}))
# notifications/routing.py
from django.urls import re_path
from . import consumers
websocket_urlpatterns = [
re_path(r'^ws/notifications/$', consumers.NotificationConsumer.as_asgi()),
]
```
**Frontend:**
```javascript
// static/js/notifications.js
class NotificationManager {
constructor() {
this.ws = null;
this.unreadCount = 0;
this.connectWebSocket();
}
connectWebSocket() {
const wsUrl = `ws://${window.location.host}/ws/notifications/`;
this.ws = new WebSocket(wsUrl);
this.ws.onmessage = (e) => {
const data = JSON.parse(e.data);
if (data.type === 'new_notification') {
this.handleNewNotification(data.notification);
}
};
}
handleNewNotification(notification) {
// Обновить счетчик
this.updateBadge();
// Показать toast
this.showToast(notification);
// Воспроизвести звук
this.playSound();
}
}
```
---
### Этап 4: UI компоненты (Week 4)
**Цель:** Интерфейс для просмотра и управления уведомлениями
#### Задачи:
1. ✅ Обновить navbar с счетчиком уведомлений
2. ✅ Создать dropdown список уведомлений
3. ✅ Создать страницу всех уведомлений
4. ✅ Создать страницу настроек уведомлений
5. ✅ Фильтрация по категориям
6. ✅ Поиск в уведомлениях
**Компоненты:**
```
templates/notifications/
├── notification_dropdown.html # Dropdown в navbar
├── notification_list.html # Полный список
├── notification_settings.html # Настройки
└── includes/
├── notification_item.html # Карточка уведомления
└── notification_toast.html # Toast-уведомление
```
---
### Этап 5: Интеграция с модулями (Week 5-6)
**Цель:** Добавить уведомления во все существующие модули
#### 5.1 Communications (Чаты)
```python
# communications/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from notifications.services import NotificationService
@receiver(post_save, sender=Message)
def notify_new_message(sender, instance, created, **kwargs):
if created:
# Получить участников чата, кроме автора
recipients = instance.chat.participants.exclude(id=instance.author.id)
for recipient in recipients:
# Проверить, упомянут ли пользователь
is_mentioned = f'@{recipient.username}' in instance.content
notification_type = 'chat_mention' if is_mentioned else 'chat_new_message'
NotificationService.create_notification(
recipient=recipient,
notification_type_code=notification_type,
title=f'Новое сообщение от {instance.author.get_full_name()}',
message=instance.content[:100],
content_object=instance,
action_url=f'/communications/chat/{instance.chat.id}/',
metadata={
'chat_id': instance.chat.id,
'chat_name': str(instance.chat),
'author_name': instance.author.get_full_name(),
}
)
```
#### 5.2 Documents
```python
@receiver(post_save, sender=DocumentAcknowledgement)
def notify_document_ready(sender, instance, created, **kwargs):
if created:
NotificationService.create_notification(
recipient=instance.user,
notification_type_code='document_ready',
title='Новый документ на ознакомление',
message=f'Документ "{instance.document.title}" требует вашего ознакомления',
content_object=instance.document,
action_url=f'/documents/{instance.document.id}/',
)
```
#### 5.3 Requests (Заявления)
```python
@receiver(post_save, sender=Request)
def notify_request_status_change(sender, instance, created, **kwargs):
if not created and instance.tracker.has_changed('status'):
NotificationService.create_notification(
recipient=instance.created_by,
notification_type_code='request_status_changed',
title=f'Статус заявки изменен',
message=f'Ваша заявка "{instance.title}" теперь: {instance.get_status_display()}',
content_object=instance,
action_url=f'/requests/{instance.id}/',
)
```
#### 5.4 Calendar
```python
@receiver(post_save, sender=Event)
def notify_event_created(sender, instance, created, **kwargs):
if created:
for participant in instance.participants.all():
NotificationService.create_notification(
recipient=participant,
notification_type_code='event_created',
title='Новое событие в календаре',
message=f'{instance.title} - {instance.start_date.strftime("%d.%m.%Y %H:%M")}',
content_object=instance,
action_url='/calendar/',
)
```
---
### Этап 6: API для настроек (Week 6)
**Цель:** REST API для управления настройками
#### Задачи:
```python
# notifications/api_views.py
from rest_framework.decorators import api_view
from rest_framework.response import Response
@api_view(['GET'])
def get_notifications(request):
"""Получить список уведомлений с пагинацией"""
pass
@api_view(['GET'])
def get_unread_count(request):
"""Получить количество непрочитанных"""
pass
@api_view(['POST'])
def mark_as_read(request, notification_id):
"""Отметить как прочитанное"""
pass
@api_view(['POST'])
def mark_all_as_read(request):
"""Отметить все как прочитанные"""
pass
@api_view(['GET'])
def get_settings(request):
"""Получить настройки пользователя"""
pass
@api_view(['POST'])
def update_settings(request):
"""Обновить настройки"""
pass
```
---
### Этап 7: Email рассылка (Week 7)
**Цель:** Отправка уведомлений на email
#### Задачи:
1. ✅ Создать HTML-шаблоны для email
2. ✅ Настроить Celery задачи для пакетной отправки
3. ✅ Дайджест уведомлений (раз в день/неделю)
4. ✅ Настройки частоты email
**Шаблоны:**
```django
{# notifications/email/base.html #}
<!DOCTYPE html>
<html>
<head>
<style>
/* Email styles */
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="{{ BRAND_LOGO }}" alt="{{ BRAND_NAME }}">
</div>
{% block content %}{% endblock %}
<div class="footer">
<a href="{{ settings_url }}">Настроить уведомления</a>
</div>
</div>
</body>
</html>
```
---
### Этап 8: Telegram бот (Week 8-9)
**Цель:** Отправка уведомлений через Telegram
#### Задачи:
1. ✅ Создать Telegram бота (python-telegram-bot)
2. ✅ Реализовать привязку аккаунта
3. ✅ Отправка уведомлений
4. ✅ Интерактивные кнопки в Telegram
5. ✅ Настройки через бота
**Структура:**
```python
# notifications/telegram_bot.py
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import ApplicationBuilder, CommandHandler, CallbackQueryHandler
class NotificationBot:
def __init__(self, token):
self.app = ApplicationBuilder().token(token).build()
self.register_handlers()
def register_handlers(self):
self.app.add_handler(CommandHandler('start', self.start))
self.app.add_handler(CommandHandler('settings', self.settings))
self.app.add_handler(CallbackQueryHandler(self.button_callback))
async def start(self, update: Update, context):
"""Команда /start для привязки"""
pass
async def settings(self, update: Update, context):
"""Настройки уведомлений"""
pass
def send_notification(self, telegram_id, notification):
"""Отправить уведомление"""
pass
```
---
### Этап 9: Оптимизация и производительность (Week 10)
**Цель:** Оптимизировать систему для большой нагрузки
#### Задачи:
1. ✅ Кеширование счетчиков (Redis)
2. ✅ Пакетная отправка
3. ✅ Индексы БД
4. ✅ Архивирование старых уведомлений
5. ✅ Rate limiting для защиты от спама
6. ✅ Мониторинг производительности
---
## 4. Технологический стек
### Backend
- **Django**: Основной фреймворк
- **Django Channels**: WebSocket для real-time
- **Celery**: Асинхронные задачи
- **Redis**: Кеширование, message broker для Celery
- **PostgreSQL**: Основная БД
### Frontend
- **Bootstrap 5**: UI компоненты
- **Bootstrap Icons**: Иконки
- **Vanilla JavaScript**: Реальное время
- **Service Workers**: Push уведомления (будущее)
### Внешние сервисы
- **SMTP**: Email рассылка
- **Telegram Bot API**: Telegram уведомления
- **WhatsApp Business API**: WhatsApp (будущее)
- **WeChat API**: WeChat (будущее)
---
## 5. Структура проекта
```
backend/
├── notifications/
│ ├── __init__.py
│ ├── models.py # Модели
│ ├── admin.py # Django Admin
│ ├── apps.py
│ ├── services.py # Бизнес-логика
│ ├── senders.py # Отправка по каналам
│ ├── tasks.py # Celery задачи
│ ├── consumers.py # WebSocket consumers
│ ├── routing.py # WebSocket routing
│ ├── telegram_bot.py # Telegram бот
│ ├── api_views.py # REST API
│ ├── urls.py # URL patterns
│ ├── views.py # Django views
│ ├── serializers.py # DRF serializers
│ ├── signals.py # Django signals
│ ├── fixtures/
│ │ └── initial_data.json
│ ├── management/
│ │ └── commands/
│ │ ├── create_notification_types.py
│ │ └── run_telegram_bot.py
│ ├── migrations/
│ └── tests/
│ ├── test_models.py
│ ├── test_services.py
│ └── test_api.py
│
├── templates/
│ └── notifications/
│ ├── notification_list.html
│ ├── notification_settings.html
│ ├── notification_dropdown.html
│ ├── email/
│ │ ├── base.html
│ │ ├── new_message.html
│ │ └── digest.html
│ └── includes/
│ ├── notification_item.html
│ └── notification_toast.html
│
└── static/
├── js/
│ ├── notifications.js # Главный класс
│ └── notification-toast.js # Toast компонент
├── css/
│ └── notifications.css
└── sounds/
└── notification.mp3
```
---
## 6. API Endpoints
### REST API
```
GET /api/notifications/ # Список уведомлений
GET /api/notifications/unread/ # Только непрочитанные
GET /api/notifications/count/ # Счетчик
POST /api/notifications/{id}/read/ # Отметить прочитанным
POST /api/notifications/read-all/ # Все прочитанными
DELETE /api/notifications/{id}/ # Удалить
POST /api/notifications/archive-all/ # Архивировать все
GET /api/notifications/settings/ # Настройки пользователя
PUT /api/notifications/settings/ # Обновить настройки
GET /api/notifications/types/ # Доступные типы
```
### WebSocket
```
ws://domain/ws/notifications/ # Real-time уведомления
Сообщения:
{
"type": "new_notification",
"notification": {...}
}
{
"type": "mark_read",
"notification_id": 123
}
{
"type": "count_update",
"count": 5
}
```
---
## 7. Примеры использования
### 7.1 Создание уведомления в коде
```python
from notifications.services import NotificationService
# Простое уведомление
NotificationService.create_notification(
recipient=user,
notification_type_code='chat_new_message',
title='Новое сообщение',
message='У вас новое сообщение в чате',
action_url='/communications/chat/123/',
)
# С привязкой к объекту
NotificationService.create_notification(
recipient=user,
notification_type_code='document_ready',
title='Документ на ознакомление',
message=document.title,
content_object=document,
action_url=f'/documents/{document.id}/',
metadata={
'deadline': document.deadline.isoformat(),
'priority': 'high'
}
)
```
### 7.2 Использование в signals
```python
from django.db.models.signals import post_save
from django.dispatch import receiver
from notifications.services import NotificationService
@receiver(post_save, sender=Message)
def notify_chat_participants(sender, instance, created, **kwargs):
if created:
recipients = instance.chat.participants.exclude(id=instance.author.id)
for recipient in recipients:
NotificationService.create_notification(
recipient=recipient,
notification_type_code='chat_new_message',
title=f'{instance.author.get_full_name()}',
message=instance.content,
content_object=instance,
action_url=f'/communications/chat/{instance.chat.id}/',
)
```
---
## 8. Приоритеты и сроки
### MVP (Минимально работающий продукт) - 4 недели
- ✅ Базовые модели
- ✅ Уведомления на сайте (WebSocket)
- ✅ Интеграция с чатами
- ✅ Базовые настройки
### V1.0 - 6 недель
- ✅ Email уведомления
- ✅ Интеграция со всеми модулями
- ✅ Полные настройки
- ✅ UI компоненты
### V2.0 - 10 недель
- ✅ Telegram бот
- ✅ Группировка уведомлений
- ✅ Дайджесты
- ✅ Оптимизация
### V3.0 - Будущее
- ⏳ WhatsApp интеграция
- ⏳ WeChat интеграция
- ⏳ Push уведомления (PWA)
- ⏳ Мобильное приложение
---
## 9. Безопасность и приватность
### 9.1 Защита данных
- Уведомления видны только получателю
- Шифрование sensitive данных
- Регулярная очистка старых уведомлений
- GDPR compliance
### 9.2 Rate Limiting
```python
from django_ratelimit.decorators import ratelimit
@ratelimit(key='user', rate='100/h')
def create_notification_view(request):
"""Ограничение: 100 уведомлений в час на пользователя"""
pass
```
### 9.3 Permissions
```python
class NotificationPermission:
@staticmethod
def can_send_to(sender, recipient, notification_type):
"""Проверка прав на отправку уведомления"""
# Например, обычный пользователь не может слать system уведомления
if notification_type.category.code == 'system':
return sender.is_staff
return True
```
---
## 10. Мониторинг и аналитика
### Метрики
- Количество отправленных уведомлений (по типам)
- Процент прочитанных уведомлений
- Среднее время до прочтения
- Популярные типы уведомлений
- Предпочитаемые каналы пользователей
### Логирование
```python
import logging
logger = logging.getLogger('notifications')
logger.info(f'Notification sent: {notification.id} to {recipient.id}')
logger.warning(f'Failed to send to Telegram: {telegram_id}')
logger.error(f'Email delivery failed: {error}')
```
---
## Заключение
Система уведомлений - критически важный компонент для engagement пользователей. План рассчитан на поэтапную реализацию с возможностью использования базового функционала уже через 4 недели.
**Следующий шаг:** Начать с Этапа 1 - создание моделей и базовой инфраструктуры.

270
backend/TESTING_GUIDE.md Normal file
View File

@ -0,0 +1,270 @@
# 🧪 Инструкция по тестированию системы уведомлений
**Дата:** 20 ноября 2025 г.
---
## 🚀 Быстрый старт
### 1. Запустить сервер
```bash
cd backend
python manage.py runserver 9000
```
### 2. Открыть приложение
http://localhost:9000/
### 3. Войти в систему
Используйте существующего пользователя
---
## ✅ Что тестировать
### ЭТАП 1-3: Автоматические уведомления (Signal Integration)
**1. Чат - Communications** 💬
- Отправьте сообщение в чат → получатель получит уведомление
- Упомяните @email в сообщении → уведомление об упоминании
- Ответьте на сообщение → автор получит уведомление
**2. Заявки - Requests** 📋
- Создайте заявку → руководители получат уведомление
- Добавьте комментарий → автор получит уведомление
- Одобрите/отклоните заявку → автор получит уведомление
**3. Документы - Documents** 📄
- Загрузите документ → получатели получат уведомление
- Ознакомьтесь с документом → автор получит уведомление (если все ознакомились)
**4. Календарь - Calendar** 📅
- Создайте событие → участники получат уведомление
- Измените событие → участники получат уведомление
- Удалите событие → участники получат уведомление об отмене
**5. Лента - Feed** 📰
- Создайте пост → пользователи получат уведомление (по типу)
- Комментируйте пост → автор получит уведомление
- Поставьте лайк → автор получит уведомление
### ЭТАП 4: UI и UX
**1. Колокольчик в Navbar** 🔔
- Badge показывает количество непрочитанных
- Клик открывает dropdown с последними 5 уведомлениями
- Кнопка "Все" ведет на полный список
- Кнопка "Настройки" ведет на настройки
**2. WebSocket Real-time** ⚡
- Новое уведомление появляется мгновенно
- Badge обновляется без перезагрузки
- Список в dropdown обновляется
**3. Браузерные уведомления** 🖥️
- При первом посещении запрашивается разрешение
- Новые уведомления показываются в ОС
- Клик по уведомлению переходит на объект
- Уведомление отмечается прочитанным
**4. Звуковые уведомления** 🔊
- При новом уведомлении звучит короткий звук
- Переключатель в настройках: вкл/выкл
- Настройка сохраняется в браузере
**5. Список уведомлений** 📋
Откройте: `/notifications/`
- **Фильтр по категории:** выберите из 8 категорий
- **Фильтр по статусу:** все/прочитанные/непрочитанные
- **Поиск:** введите текст и нажмите "Найти"
- **Пагинация:** переключение между страницами
- **Действия:**
- "Посмотреть" → переход к объекту
- "Прочитано" → отметить прочитанным
- Корзина → удалить уведомление
- "Отметить все прочитанными" → массовое действие
**6. Настройки** ⚙️
Откройте: `/notifications/settings/`
- **Звук:** переключатель (глобально)
- **По категориям:** 8 карточек
- Главный переключатель (вкл/выкл категорию)
- Каналы: Веб (работает), Email/Telegram (скоро)
- **Автосохранение:** изменения сразу отправляются на сервер
- **Toast:** всплывает "Настройки сохранены"
---
## 🧪 Генератор тестовых уведомлений
Для быстрого тестирования используйте скрипт:
```bash
cd backend
python generate_notification.py
```
**Меню:**
1. Новое сообщение в чате
2. Новый документ
3. Одобрена заявка
4. Новое событие в календаре
5. Новая публикация
6. Системное уведомление
7. Создать 5 разных уведомлений
После создания:
- Проверьте колокольчик (должен обновиться badge)
- Должно прийти браузерное уведомление
- Должен воспроизвестись звук
- Откройте список - уведомление должно быть там
---
## 🔍 Что проверять
### Real-time (WebSocket)
- [ ] Badge обновляется мгновенно
- [ ] Список в dropdown обновляется
- [ ] При отключении badge становится красным
- [ ] При восстановлении badge возвращает цвет
### Браузерные уведомления
- [ ] Запрос разрешения при первом визите
- [ ] Уведомления показываются в ОС
- [ ] Клик переходит на объект
- [ ] Клик отмечает прочитанным
- [ ] Автозакрытие через 10 сек (если не urgent)
### Звук
- [ ] Воспроизводится при новом уведомлении
- [ ] Переключатель в настройках работает
- [ ] Настройка сохраняется в localStorage
- [ ] Не воспроизводится если выключен
### Фильтрация
- [ ] Фильтр по категории работает
- [ ] Фильтр по статусу работает
- [ ] Поиск находит по заголовку
- [ ] Поиск находит по тексту
- [ ] Фильтры можно комбинировать
### Пагинация
- [ ] Показывает правильное количество страниц
- [ ] Переход между страницами работает
- [ ] Текущая страница подсвечена
- [ ] Многоточие между далекими страницами
- [ ] Прокрутка наверх при смене страницы
### Действия
- [ ] "Прочитано" убирает синюю полосу
- [ ] "Прочитано" уменьшает badge на 1
- [ ] "Удалить" удаляет карточку
- [ ] "Отметить все" обнуляет badge
- [ ] "Посмотреть" переходит на URL
### Настройки
- [ ] Переключатель категории включает/выключает
- [ ] При выключении скрываются каналы
- [ ] Изменения сохраняются на сервер
- [ ] Toast показывает "Настройки сохранены"
- [ ] Звук переключается глобально
---
## 🐛 Известные ограничения
1. **Email/Telegram** - пока не работают (Этап 2 отложен)
2. **Напоминания** - пока не работают (нужен Celery)
3. **Группировка** - не реализована
4. **Дайджесты** - не реализованы
---
## 📊 Ожидаемые результаты
### После создания уведомления:
1. ✅ Badge показывает +1
2. ✅ Dropdown обновляется (новое уведомление сверху)
3. ✅ Браузерное уведомление всплывает
4. ✅ Звук воспроизводится (если включен)
5. ✅ Список обновляется (если открыт)
### После "Прочитано":
1. ✅ Badge показывает -1
2. ✅ Карточка теряет синюю полосу
3. ✅ Opacity становится 70%
4. ✅ Кнопка "Прочитано" исчезает
### После фильтрации:
1. ✅ Показываются только подходящие уведомления
2. ✅ Пагинация пересчитывается
3. ✅ Счетчик total обновляется
### После изменения настроек:
1. ✅ Toast "Настройки сохранены"
2. ✅ Изменения применяются сразу
3. ✅ Новые уведомления учитывают настройки
---
## 🔧 Отладка
### Консоль браузера
Откройте DevTools (F12) → Console
Смотрите логи:
```
[Notifications] WebSocket connected
[Notifications] Received: {type: 'new_notification', ...}
[Notifications] New notification: {...}
[Notifications] Browser notification shown
```
### Django logs
Смотрите терминал где запущен runserver
### WebSocket
Проверьте в DevTools → Network → WS:
- Соединение установлено
- Сообщения приходят
- Нет ошибок
### API
Проверьте запросы в DevTools → Network → Fetch/XHR:
- `/api/notifications/` - список
- `/api/notifications/count/` - счетчик
- `/api/notifications/{id}/read/` - прочитать
- `/api/notifications/settings/` - настройки
---
## ✅ Чеклист полного тестирования
- [ ] Создать уведомление через signal (отправить сообщение)
- [ ] Создать уведомление через генератор
- [ ] Проверить колокольчик обновился
- [ ] Проверить браузерное уведомление
- [ ] Проверить звук воспроизвелся
- [ ] Открыть список уведомлений
- [ ] Применить фильтр по категории
- [ ] Применить фильтр по статусу
- [ ] Выполнить поиск
- [ ] Перейти на 2-ю страницу (если есть)
- [ ] Отметить уведомление прочитанным
- [ ] Удалить уведомление
- [ ] Отметить все прочитанными
- [ ] Открыть настройки
- [ ] Выключить категорию
- [ ] Включить категорию
- [ ] Переключить звук
- [ ] Проверить автосохранение
**Все работает? ПОЗДРАВЛЯЕМ! 🎉**
---
*Автор: GitHub Copilot + User*
*Дата: 20 ноября 2025 г.*

View File

@ -102,8 +102,11 @@ class ApiClient:
timeout=self.cfg.timeout,
)
if resp.status_code == 401 and retry and self.refresh:
# Пробуем refresh при 401 (Unauthorized) или 403 (может быть из-за истёкшего токена)
# Проверяем наличие refresh-токена и флаг retry
if resp.status_code in (401, 403) and retry and self.refresh:
if self.refresh_tokens():
# Токен обновлён успешно, повторяем запрос
hdrs = dict(self.cfg.default_headers or {})
hdrs.update(self._auth_headers())
resp = self.session.request(

63
backend/api/middleware.py Normal file
View File

@ -0,0 +1,63 @@
# backend/api/middleware.py
"""
Middleware для автоматического обновления JWT токенов.
Обновляет access token за 5 минут до истечения.
"""
from datetime import datetime, timedelta
import jwt
from django.conf import settings
from .client import SESSION_KEY_ACCESS, SESSION_KEY_REFRESH, get_api_client
class JWTRefreshMiddleware:
"""
Автоматически обновляет JWT access token, если он скоро истечёт.
Проверяет токен при каждом запросе и обновляет его, если осталось
меньше 5 минут до истечения (по умолчанию).
"""
def __init__(self, get_response):
self.get_response = get_response
# За сколько минут до истечения обновлять токен
self.refresh_threshold = timedelta(
minutes=int(getattr(settings, 'JWT_REFRESH_THRESHOLD_MINUTES', 5))
)
def __call__(self, request):
# Проверяем только для авторизованных пользователей
if request.user.is_authenticated:
access_token = request.session.get(SESSION_KEY_ACCESS)
refresh_token = request.session.get(SESSION_KEY_REFRESH)
if access_token and refresh_token:
try:
# Декодируем токен БЕЗ проверки подписи (только для чтения exp)
decoded = jwt.decode(
access_token,
options={"verify_signature": False}
)
exp = decoded.get('exp')
if exp:
# Время истечения токена
exp_time = datetime.fromtimestamp(exp)
# Текущее время
now = datetime.now()
# Оставшееся время
time_left = exp_time - now
# Если осталось меньше порога - обновляем
if time_left < self.refresh_threshold:
client = get_api_client(request)
client.refresh_tokens()
except (jwt.DecodeError, jwt.InvalidTokenError, ValueError):
# Если токен невалидный - ничего не делаем,
# ApiClient сам обработает при запросе
pass
response = self.get_response(request)
return response

View File

@ -14,4 +14,5 @@ urlpatterns = [
name="token_obtain_pair",
),
path("auth/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
path("", include("notifications.api_urls")),
]

View File

@ -61,7 +61,7 @@ class RequestViewSet(viewsets.ModelViewSet):
"employee", "approver", "department"
).all()
parser_classes = (MultiPartParser, FormParser, JSONParser)
pagination_class = None
# Используем глобальную пагинацию из settings (PageNumberPagination, PAGE_SIZE=20)
required_perms_by_action = {
"comments": {
@ -131,52 +131,53 @@ class RequestViewSet(viewsets.ModelViewSet):
if user.is_staff or self._can_view_all(user):
if want_mine:
return qs.filter(employee_id=user.id)
return qs
view_dept_ids = (
EmployeeDepartment.objects.filter(
employee_id=user.id,
is_active=True,
role__scoped_permissions__code="view_request",
)
.values_list("department_id", flat=True)
.distinct()
)
proc_dept_ids = (
EmployeeDepartment.objects.filter(
employee_id=user.id,
is_active=True,
role__scoped_permissions__code="can_process_requests",
)
.values_list("department_id", flat=True)
.distinct()
)
head_dept_ids = Department.objects.filter(head_id=user.id).values_list(
"id", flat=True
)
combined_ids = set(view_dept_ids) | set(proc_dept_ids) | set(head_dept_ids)
# сотрудники этих отделов (активные)
dept_emp_ids = (
EmployeeDepartment.objects.filter(
department_id__in=list(combined_ids) if combined_ids else [],
is_active=True,
)
.values_list("employee_id", flat=True)
.distinct()
) if combined_ids else []
if want_mine:
qs = qs.filter(employee_id=user.id)
qs = qs.filter(employee_id=user.id)
# Для staff: не возвращаем сразу, применяем фильтры ниже
else:
scope = Q(employee_id=user.id)
if combined_ids:
scope |= Q(department_id__in=list(combined_ids))
if dept_emp_ids:
scope |= Q(employee_id__in=list(dept_emp_ids))
qs = qs.filter(scope)
view_dept_ids = (
EmployeeDepartment.objects.filter(
employee_id=user.id,
is_active=True,
role__scoped_permissions__code="view_request",
)
.values_list("department_id", flat=True)
.distinct()
)
proc_dept_ids = (
EmployeeDepartment.objects.filter(
employee_id=user.id,
is_active=True,
role__scoped_permissions__code="can_process_requests",
)
.values_list("department_id", flat=True)
.distinct()
)
head_dept_ids = Department.objects.filter(head_id=user.id).values_list(
"id", flat=True
)
combined_ids = set(view_dept_ids) | set(proc_dept_ids) | set(head_dept_ids)
# сотрудники этих отделов (активные)
dept_emp_ids = (
EmployeeDepartment.objects.filter(
department_id__in=list(combined_ids) if combined_ids else [],
is_active=True,
)
.values_list("employee_id", flat=True)
.distinct()
) if combined_ids else []
if want_mine:
qs = qs.filter(employee_id=user.id)
else:
scope = Q(employee_id=user.id)
if combined_ids:
scope |= Q(department_id__in=list(combined_ids))
if dept_emp_ids:
scope |= Q(employee_id__in=list(dept_emp_ids))
qs = qs.filter(scope)
# Применяем фильтры type/status для всех пользователей
t = (params.get("type") or "").strip()
s = (params.get("status") or "").strip()
if t:

View File

@ -17,6 +17,7 @@ class CalendarAppConfig(AppConfig):
LookupError: Если приложение 'employees' или модель 'Employee' не найдены.
"""
from calendar_app import signals # noqa: F401 (гарантирует импорт модулей с хендлерами)
import calendar_app.notification_signals # noqa: F401
try:
Employee = django_apps.get_model("employees", "Employee")

View File

@ -0,0 +1,214 @@
"""
Signals для автоматической генерации уведомлений в модуле Calendar.
Обрабатывает события:
- Новое событие календаря
- Изменение события
- Отмена события
Напоминания (за час, за день) будут реализованы через Celery tasks.
"""
from django.db.models.signals import post_save, pre_delete, pre_save
from django.dispatch import receiver
from django.contrib.auth import get_user_model
from .models import CalendarEvent
from notifications.services import NotificationService
Employee = get_user_model()
@receiver(post_save, sender=CalendarEvent)
def create_event_notifications(sender, instance, created, **kwargs):
"""
Создает уведомления при создании или изменении события.
Уведомления отправляются:
- При создании - всем сотрудникам компании или отдела
- При изменении - участникам события
"""
event = instance
if created:
# Новое событие - уведомляем всех участников
notify_event_created(event)
else:
# Проверяем изменение ключевых полей
if hasattr(event, '_changed_fields'):
notify_event_changed(event, event._changed_fields)
@receiver(pre_save, sender=CalendarEvent)
def track_event_changes(sender, instance, **kwargs):
"""
Отслеживает изменения важных полей события для уведомлений.
"""
if instance.pk:
try:
old_event = CalendarEvent.objects.get(pk=instance.pk)
changed_fields = []
# Отслеживаем важные поля
important_fields = [
'title', 'start_date', 'end_date',
'start_time', 'end_time', 'location'
]
for field in important_fields:
old_value = getattr(old_event, field)
new_value = getattr(instance, field)
if old_value != new_value:
changed_fields.append(field)
instance._changed_fields = changed_fields
except CalendarEvent.DoesNotExist:
instance._changed_fields = []
@receiver(pre_delete, sender=CalendarEvent)
def notify_event_cancelled(sender, instance, **kwargs):
"""
Создает уведомления при удалении (отмене) события.
"""
event = instance
# Определяем получателей
recipients = get_event_recipients(event)
# Создаем уведомления об отмене
for recipient in recipients:
NotificationService.create_notification(
recipient=recipient,
notification_type_code='event_cancelled',
title='Событие отменено',
message=(
f'Событие "{event.title}" '
f'({event.start_date.strftime("%d.%m.%Y")}) отменено'
),
action_url=get_calendar_url(event),
metadata={
'event_title': event.title,
'event_date': event.start_date.isoformat(),
'department_id': (
event.department.id if event.department else None
),
'is_company_event': event.is_company,
}
)
# ===== Вспомогательные функции =====
def notify_event_created(event):
"""
Отправляет уведомления о новом событии всем участникам.
"""
recipients = get_event_recipients(event)
# Исключаем создателя события
if event.created_by:
recipients = [r for r in recipients if r.id != event.created_by.id]
event_scope = (
f'отдела {event.department.name}'
if event.department
else 'компании'
)
for recipient in recipients:
NotificationService.create_notification(
recipient=recipient,
notification_type_code='event_created',
title='Новое событие в календаре',
message=(
f'Добавлено событие {event_scope}: "{event.title}" '
f'({event.start_date.strftime("%d.%m.%Y")})'
),
content_object=event,
action_url=get_calendar_url(event),
metadata={
'event_id': event.id,
'event_title': event.title,
'event_date': event.start_date.isoformat(),
'department_id': (
event.department.id if event.department else None
),
'is_company_event': event.is_company,
'created_by_id': (
event.created_by.id if event.created_by else None
),
}
)
def notify_event_changed(event, changed_fields):
"""
Отправляет уведомления об изменении события.
"""
if not changed_fields:
return
recipients = get_event_recipients(event)
# Формируем сообщение об изменениях
field_names = {
'title': 'название',
'start_date': 'дата начала',
'end_date': 'дата окончания',
'start_time': 'время начала',
'end_time': 'время окончания',
'location': 'место проведения',
}
changes_text = ', '.join([
field_names.get(f, f) for f in changed_fields
])
for recipient in recipients:
NotificationService.create_notification(
recipient=recipient,
notification_type_code='event_changed',
title='Событие изменено',
message=(
f'Событие "{event.title}" изменено. '
f'Изменения: {changes_text}'
),
content_object=event,
action_url=get_calendar_url(event),
metadata={
'event_id': event.id,
'event_title': event.title,
'changed_fields': changed_fields,
'event_date': event.start_date.isoformat(),
'department_id': (
event.department.id if event.department else None
),
}
)
def get_event_recipients(event):
"""
Возвращает список получателей уведомлений для события.
- Для события компании - все активные сотрудники
- Для события отдела - сотрудники этого отдела
"""
if event.is_company:
# Событие компании - все активные сотрудники
return list(Employee.objects.filter(is_active=True))
elif event.department:
# Событие отдела - сотрудники отдела
return list(event.department.employees.filter(is_active=True))
return []
def get_calendar_url(event):
"""
Возвращает URL календаря в зависимости от типа события.
"""
if event.department:
return f'/calendar/department/{event.department.id}/'
return '/calendar/company/'

View File

@ -0,0 +1,46 @@
import os
import sys
import django
sys.path.insert(0, os.path.dirname(__file__))
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
django.setup()
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
User = get_user_model()
admin = User.objects.filter(is_superuser=True).first()
client = APIClient()
client.force_authenticate(user=admin)
print('=' * 70)
print('PAGINATION CHECK')
print('=' * 70)
endpoints = [
('Departments', '/api/v1/departments/'),
('Employees', '/api/v1/employees/'),
('Documents', '/api/v1/documents/'),
('Requests', '/api/v1/requests/'),
]
for name, url in endpoints:
print(f'\n{name} ({url})')
print('-' * 70)
resp = client.get(url)
if resp.status_code == 200:
data = resp.json()
if isinstance(data, dict) and 'results' in data:
print(f'OK Paginated')
print(f'Total: {data.get("count", "?")}')
print(f'Page size: {len(data.get("results", []))}')
print(f'Next: {"Yes" if data.get("next") else "No"}')
elif isinstance(data, list):
print(f'ERROR Not paginated (list)')
print(f'Items: {len(data)}')
else:
print(f'ERROR HTTP {resp.status_code}')
print('\n' + '=' * 70)

View File

@ -0,0 +1,345 @@
# Документация по обновлению мессенджера
## Обзор
Проведено полное обновление системы чатов EUSRR для превращения её в полнофункциональный корпоративный мессенджер.
---
## 📋 Реализованные возможности
### 1. **Модели данных** (Этапы 1-11) ✅
#### Обновленные модели:
**Chat**
- Новые поля: `name`, `description`, `avatar`, `created_by`, `include_all_employees`
- Расширенные типы: `private`, `group`, `department`, `channel`, `announcement`, `global`
**ChatUserSettings** (НОВАЯ)
- `is_pinned`, `pinned_at`, `pin_order` - закрепление чатов
- `notifications_enabled` - управление уведомлениями
- `custom_name` - персональное название чата
- `is_hidden` - скрытие чатов
**Message**
- `reply_to` - ответы на сообщения
- `is_edited`, `edited_at`, `edit_history` - редактирование с историей
- `is_deleted`, `deleted_at`, `deleted_by` - мягкое удаление
- `has_attachments` - флаг вложений
- `is_system`, `system_type`, `system_metadata` - системные сообщения
- `is_pinned`, `pinned_by`, `pinned_at` - закрепление важных сообщений
- `reactions` (JSON) - эмодзи-реакции
- `is_forwarded`, `is_cross_chat` - специальные типы
- `thread_root`, `thread_reply_count` - треды
**MessageAttachment** (НОВАЯ)
- Поддержка файлов: image, video, audio, file
- Автоматические превью для изображений
- Метаданные: размер, MIME-тип, оригинальное имя
**ForwardedMessage** (НОВАЯ)
- Отслеживание цепочек пересылки
- Сохранение оригинального автора и контента
- Счетчик количества пересылок
**MessageReply** (НОВАЯ)
- Расширенная информация об ответах
- Типы: inline, quote, thread
- Кросс-чат ответы
- Сохранение контекста при удалении оригинала
**ChatAccessPermission** (НОВАЯ)
- Гибкие уровни доступа: read, write, moderate
- Временные права с `expires_at`
- Аудит: кто предоставил, когда
**CrossChatMessage** (НОВАЯ)
- Отправка в чаты без членства
- Система модерации: pending, approved, rejected
- Заметки модератора
**ChatMembership** (НОВАЯ)
- Роли: owner, admin, moderator, member, guest
- Детальные права: отправка, добавление/удаление участников, закрепление
- История: joined_at, left_at, invited_by
**ChatReadState** (ОБНОВЛЕНА)
- `last_read_message` - явная связь
- `unread_mentions_count` - счетчик @упоминаний
- `unread_thread_replies_count` - счетчик ответов в тредах
- `is_typing`, `typing_updated_at` - индикатор набора текста
---
### 2. **API Endpoints** (Этап 12) ✅
Создано 15 REST API эндпоинтов в `communications/api_views.py`:
#### Управление чатами
- `POST /communications/api/chat/create/` - создание group/channel/announcement
- `POST /communications/api/chat/<id>/update/` - обновление названия/описания/аватара
- `POST /communications/api/chat/<id>/pin/` - закрепление/открепление чата
- `POST /communications/api/chat/<id>/notifications/` - управление уведомлениями
#### Действия с сообщениями
- `POST /communications/api/message/<id>/edit/` - редактирование с историей
- `POST /communications/api/message/<id>/delete/` - мягкое удаление
- `POST /communications/api/message/<id>/react/` - добавить реакцию
- `POST /communications/api/message/<id>/unreact/` - удалить реакцию
- `POST /communications/api/message/<id>/pin/` - закрепить сообщение
#### Вложения
- `POST /communications/api/attachment/upload/` - загрузка файлов
#### Пересылка и ответы
- `POST /communications/api/message/<id>/forward/` - пересылка с цепочкой
- `POST /communications/api/message/<id>/reply/` - ответ (inline/quote/thread)
#### Участники
- `POST /communications/api/chat/<id>/member/add/` - добавить участника
- `POST /communications/api/chat/<id>/member/remove/` - удалить участника
---
### 3. **WebSocket Consumers** (Этап 13) ✅
Обновлен `communications/consumers.py`:
#### ChatConsumer - обработка действий:
- `send_message` - отправка с поддержкой ответов
- `edit_message` - редактирование в реальном времени
- `delete_message` - удаление с уведомлением всех
- `add_reaction` / `remove_reaction` - реакции
- `typing` / `stop_typing` - индикатор набора текста
#### Real-time события:
- `chat.message` - новое сообщение
- `chat.message_edited` - сообщение изменено
- `chat.message_deleted` - сообщение удалено
- `chat.reaction_added` / `removed` - реакции обновлены
- `chat.user_typing` / `stopped_typing` - статус набора
#### Расширенная сериализация:
- Полная информация о сообщении
- Вложения с URL и превью
- Информация о пересылке
- Превью ответов
- Реакции всех пользователей
---
### 4. **Frontend (UI/UX)** (Этап 14) ✅
#### JavaScript модули:
**`chat-detail-enhanced.js`** - улучшенный интерфейс чата:
- Real-time обновления через WebSocket
- Редактирование сообщений с индикатором
- Удаление сообщений
- Быстрые реакции (6 эмодзи по наведению)
- Ответы на сообщения с превью
- Индикатор "печатает..."
- Автопереподключение WebSocket
- Отображение вложений и превью
- Информация о пересылке
**`chat-list-enhanced.js`** - улучшенный список чатов:
- Real-time обновление последних сообщений
- Закрепление чатов с перемещением наверх
- Контекстное меню (ПКМ):
- Закрепить/открепить
- Выключить уведомления
- Скрыть чат
- Отметить прочитанным
- Индикатор "печатает..." в списке
- Анимация обновления чатов
#### CSS стили:
**`chat-enhanced.css`** - стили для чата:
- Превью ответов с border-left
- Информация о пересылке
- Кнопки действий при наведении
- Стили реакций (активные/неактивные)
- Быстрые реакции на hover
- Отображение вложений
- Индикатор редактирования
- Значок закрепленного сообщения
- Системные сообщения
- Анимация новых сообщений
**`chat-list-enhanced.css`** - стили для списка:
- Контекстное меню
- Анимация обновления чата
- Стили закрепленных чатов (желтый фон)
- Значок закрепления
- Аватары чатов
- Групповые аватары
---
### 5. **Админ-панель** (Этап 12) ✅
Обновлен `communications/admin.py`:
- Админки для всех новых моделей
- Inline-редактирование вложений
- Inline-редактирование участников
- Фильтры и поиск
- Отображение статусов (edited, deleted, pinned)
---
## 🔧 Технические детали
### Миграции
Создано 12 миграций:
- `0006` - добавление полей в Chat
- `0007` - обновление типов чатов
- `0008` - модель ChatUserSettings
- `0009` - расширение Message (19 полей)
- `0010` - модель MessageAttachment
- `0011` - ForwardedMessage, MessageReply, ChatAccessPermission, CrossChatMessage
- `0012` - ChatMembership и обновление ChatReadState
### Зависимости
- Django Channels - WebSocket
- Pillow - обработка изображений
- Bootstrap 5 - UI компоненты
- Bootstrap Icons - иконки
### Структура файлов
```
communications/
├── models.py (обновлено - 10 моделей)
├── views.py (существующие view)
├── api_views.py (НОВОЕ - 15 эндпоинтов)
├── consumers.py (обновлено - real-time)
├── admin.py (обновлено - админки)
├── urls.py (обновлено - роуты API)
└── migrations/ (12 миграций)
static/
├── js/
│ ├── chat-detail-enhanced.js (НОВОЕ)
│ └── chat-list-enhanced.js (НОВОЕ)
└── css/
└── components/
├── chat-enhanced.css (НОВОЕ)
└── chat-list-enhanced.css (НОВОЕ)
```
---
## 📝 Использование
### Создание группового чата
```javascript
fetch('/communications/api/chat/create/', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': getCsrfToken()
},
body: new URLSearchParams({
type: 'group',
name: 'Название группы',
description: 'Описание'
})
});
```
### Редактирование сообщения
```javascript
sendAction('edit_message', {
message_id: 123,
content: 'Новый текст'
});
```
### Добавление реакции
```javascript
sendAction('add_reaction', {
message_id: 123,
emoji: '👍'
});
```
### Закрепление чата
```javascript
fetch(`/communications/api/chat/${chatId}/pin/`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': getCsrfToken()
},
body: 'pinned=true'
});
```
---
## 🎯 Следующие шаги (опционально)
### Возможные улучшения:
1. Упоминания (@username) с подсветкой и уведомлениями
2. Поиск по содержимому сообщений
3. Голосовые сообщения
4. Видеозвонки
5. Шифрование сообщений E2E
6. Экспорт истории чата
7. Боты и автоматизация
8. Rich text редактор (Markdown, форматирование)
9. Статусы пользователей (онлайн/офлайн/занят)
10. Push-уведомления
---
## ⚠️ Известные ограничения
1. **SQLite предупреждения** - deferrable constraints не поддерживаются (неблокирующее)
2. **Lint warnings** - несколько строк >79 символов (несущественно)
3. **UI требует подключения скриптов** - нужно добавить в шаблоны:
```django
{% block extra_js %}
<script src="{% static 'js/chat-detail-enhanced.js' %}"></script>
<link rel="stylesheet" href="{% static 'css/components/chat-enhanced.css' %}">
{% endblock %}
```
---
## 📊 Статистика
- **Модели**: 10 (4 новые, 6 обновленных)
- **Миграции**: 12
- **API эндпоинты**: 15
- **WebSocket события**: 10 типов
- **JS файлы**: 2 новых модуля (~800 строк)
- **CSS файлы**: 2 новых файла (~400 строк)
- **Общий объем кода**: ~3500 строк
---
## ✅ Готовность к продакшену
### Выполнено:
- ✅ Все миграции применены
- ✅ Модели протестированы (создание/обновление)
- ✅ API эндпоинты реализованы
- ✅ WebSocket consumers обновлены
- ✅ Frontend код написан
- ✅ Админ-панель расширена
### Требуется:
- ⏳ Подключить новые JS/CSS файлы в шаблоны
- ⏳ Интеграционное тестирование
- ⏳ Тестирование производительности
- ⏳ Настройка прав доступа
- ⏳ Документация для пользователей
---
Автор: GitHub Copilot
Дата: 20 ноября 2025
Версия: 1.0

View File

@ -0,0 +1,865 @@
# Улучшения модели мессенджера
## 1. Добавить базовые поля в модель Chat
```python
class Chat(models.Model):
# Существующие поля...
type = models.CharField(max_length=16, choices=CHAT_TYPE_CHOICES)
# НОВЫЕ ПОЛЯ:
name = models.CharField(
max_length=255,
blank=True,
verbose_name="Название чата",
help_text="Для групповых/канальных чатов"
)
description = models.TextField(
blank=True,
verbose_name="Описание чата"
)
avatar = models.ImageField(
upload_to='chat_avatars/%Y/%m/',
null=True,
blank=True,
verbose_name="Аватар чата"
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
on_delete=models.SET_NULL,
related_name='created_chats',
verbose_name="Создатель"
)
# Для гибкой настройки участников
include_all_employees = models.BooleanField(
default=False,
verbose_name="Включить всех сотрудников",
help_text="Для анонсов и общих чатов"
)
```
## 2. Создать модель для закрепления чатов (персональная настройка)
```python
class ChatUserSettings(models.Model):
"""Персональные настройки пользователя для чата"""
chat = models.ForeignKey(Chat, on_delete=models.CASCADE, related_name='user_settings')
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
# Закрепление
is_pinned = models.BooleanField(default=False, verbose_name="Закреплен")
pinned_at = models.DateTimeField(null=True, blank=True, verbose_name="Время закрепления")
pin_order = models.IntegerField(default=0, verbose_name="Порядок закрепленных")
# Уведомления
notifications_enabled = models.BooleanField(default=True, verbose_name="Уведомления")
# Кастомное название (если пользователь переименовал)
custom_name = models.CharField(max_length=255, blank=True, verbose_name="Свое название")
# Скрыть чат
is_hidden = models.BooleanField(default=False, verbose_name="Скрыт")
class Meta:
unique_together = [('chat', 'user')]
ordering = ['-is_pinned', '-pinned_at']
```
## 3. Расширить типы чатов
```python
class Chat(models.Model):
CHAT_TYPE_CHOICES = [
('private', 'Личный'), # 1-на-1
('group', 'Групповой'), # Произвольная группа сотрудников
('department', 'Отдел'), # Привязан к отделу
('channel', 'Канал'), # Только админы пишут, все читают
('announcement', 'Объявления'), # Системный канал
]
```
## 4. Добавить роли участников
```python
class ChatMembership(models.Model):
"""Участие в чате с ролями"""
ROLE_CHOICES = [
('owner', 'Владелец'),
('admin', 'Администратор'),
('moderator', 'Модератор'),
('member', 'Участник'),
('readonly', 'Только чтение'),
]
chat = models.ForeignKey(Chat, on_delete=models.CASCADE, related_name='memberships')
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='member')
joined_at = models.DateTimeField(auto_now_add=True)
invited_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
on_delete=models.SET_NULL,
related_name='+'
)
# Разрешения
can_send_messages = models.BooleanField(default=True)
can_add_members = models.BooleanField(default=False)
can_edit_chat = models.BooleanField(default=False)
class Meta:
unique_together = [('chat', 'user')]
```
## 5. Расширить модель Message
```python
class Message(models.Model):
# Существующие поля...
chat = models.ForeignKey(Chat, related_name="messages", on_delete=models.CASCADE)
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
content = models.TextField()
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
# НОВЫЕ ПОЛЯ:
# Ответ на сообщение (упрощенная связь, детали в MessageReply)
reply_to = models.ForeignKey(
'self',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='direct_replies',
help_text="Простая связь для быстрого доступа"
)
# Редактирование
is_edited = models.BooleanField(default=False)
edited_at = models.DateTimeField(null=True, blank=True)
edit_history = models.JSONField(
default=list,
blank=True,
help_text="История редактирования [{timestamp, old_content}, ...]"
)
# Удаление
is_deleted = models.BooleanField(default=False)
deleted_at = models.DateTimeField(null=True, blank=True)
deleted_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='+',
help_text="Кто удалил (может отличаться от автора)"
)
# Вложения
has_attachments = models.BooleanField(default=False)
# Системные сообщения (вступление, выход и т.д.)
is_system = models.BooleanField(default=False)
system_type = models.CharField(max_length=50, blank=True)
# Типы: 'user_joined', 'user_left', 'chat_created', 'chat_renamed',
# 'user_promoted', 'user_demoted', 'settings_changed'
system_metadata = models.JSONField(
default=dict,
blank=True,
help_text="Дополнительные данные для системных сообщений"
)
# Важные/закрепленные
is_pinned = models.BooleanField(default=False)
pinned_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='+'
)
pinned_at = models.DateTimeField(null=True, blank=True)
# Реакции (можно хранить как JSON)
reactions = models.JSONField(default=dict, blank=True)
# Формат: {'👍': [user_id1, user_id2], '❤️': [user_id3]}
# Флаги для специальных типов сообщений
is_forwarded = models.BooleanField(default=False)
is_cross_chat = models.BooleanField(
default=False,
help_text="Сообщение отправлено не участником чата"
)
# Треды (для организации обсуждений)
thread_root = models.ForeignKey(
'self',
null=True,
blank=True,
on_delete=models.CASCADE,
related_name='thread_messages',
help_text="Корневое сообщение треда"
)
thread_reply_count = models.IntegerField(default=0)
class Meta:
ordering = ['created_at']
indexes = [
models.Index(fields=['chat', 'created_at']),
models.Index(fields=['chat', 'is_deleted', 'created_at']),
models.Index(fields=['author', 'created_at']),
models.Index(fields=['thread_root', 'created_at']),
models.Index(fields=['is_pinned', 'chat']),
]
```
## 6. Добавить модель вложений
```python
class MessageAttachment(models.Model):
ATTACHMENT_TYPE_CHOICES = [
('image', 'Изображение'),
('file', 'Файл'),
('video', 'Видео'),
('audio', 'Аудио'),
]
message = models.ForeignKey(Message, on_delete=models.CASCADE, related_name='attachments')
file = models.FileField(upload_to='chat_attachments/%Y/%m/%d/')
file_type = models.CharField(max_length=20, choices=ATTACHMENT_TYPE_CHOICES)
file_name = models.CharField(max_length=255)
file_size = models.BigIntegerField()
mime_type = models.CharField(max_length=100)
uploaded_at = models.DateTimeField(auto_now_add=True)
# Для изображений
thumbnail = models.ImageField(upload_to='chat_thumbnails/%Y/%m/%d/', null=True, blank=True)
```
## 6a. Улучшить модель пересылки сообщений
```python
class ForwardedMessage(models.Model):
"""Информация о пересланных сообщениях (поддержка цепочки пересылок)"""
message = models.OneToOneField(
Message,
on_delete=models.CASCADE,
related_name='forward_info',
help_text="Текущее пересланное сообщение"
)
# Оригинальное сообщение (первое в цепочке)
original_message = models.ForeignKey(
Message,
on_delete=models.SET_NULL,
null=True,
related_name='forwarded_copies',
help_text="Самое первое сообщение в цепочке пересылок"
)
# Непосредственный источник (откуда переслали)
immediate_source = models.ForeignKey(
Message,
on_delete=models.SET_NULL,
null=True,
related_name='+',
help_text="Сообщение, которое было непосредственно переслано"
)
original_chat = models.ForeignKey(
Chat,
on_delete=models.SET_NULL,
null=True,
related_name='+'
)
original_author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name='+'
)
forwarded_at = models.DateTimeField(auto_now_add=True)
forwarded_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='forwarded_messages',
help_text="Кто переслал"
)
# Счетчик пересылок (для оригинала)
forward_count = models.IntegerField(default=0, help_text="Сколько раз переслали")
# Сохранить контент на случай удаления оригинала
preserved_content = models.TextField(blank=True)
preserved_author_name = models.CharField(max_length=255, blank=True)
class Meta:
indexes = [
models.Index(fields=['original_message']),
models.Index(fields=['forwarded_by', 'forwarded_at']),
]
```
## 6b. Добавить модель для отправки сообщений в чужие чаты
```python
class CrossChatMessage(models.Model):
"""Сообщения, отправленные в чаты, где пользователь не является участником"""
message = models.OneToOneField(
Message,
on_delete=models.CASCADE,
related_name='cross_chat_info'
)
sender = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='cross_chat_messages',
help_text="Отправитель (не член этого чата)"
)
target_chat = models.ForeignKey(
Chat,
on_delete=models.CASCADE,
related_name='cross_messages',
help_text="Чат-получатель"
)
# Разрешение на отправку
approved = models.BooleanField(default=False, help_text="Одобрено модератором")
approved_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='+',
help_text="Кто одобрил"
)
approved_at = models.DateTimeField(null=True, blank=True)
# Причина/контекст отправки
reason = models.CharField(
max_length=500,
blank=True,
help_text="Причина отправки сообщения в чужой чат"
)
# Режим отображения
DISPLAY_MODE_CHOICES = [
('guest', 'Гостевое (с пометкой "Внешнее сообщение")'),
('forwarded', 'Как пересланное'),
('announcement', 'Как объявление'),
]
display_mode = models.CharField(
max_length=20,
choices=DISPLAY_MODE_CHOICES,
default='guest'
)
sent_at = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [
models.Index(fields=['target_chat', 'approved']),
models.Index(fields=['sender', 'sent_at']),
]
```
## 7. Улучшить ChatReadState
```python
class ChatReadState(models.Model):
chat = models.ForeignKey("Chat", on_delete=models.CASCADE, related_name="read_states")
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
last_read_at = models.DateTimeField(null=True, blank=True)
last_read_message = models.ForeignKey(
Message,
null=True,
blank=True,
on_delete=models.SET_NULL,
help_text="Последнее прочитанное сообщение"
)
# Статус набора текста
is_typing = models.BooleanField(default=False)
typing_updated_at = models.DateTimeField(null=True, blank=True)
updated_at = models.DateTimeField(auto_now=True)
```
## 8. Добавить модель для ответов на сообщения (расширенная)
```python
class MessageReply(models.Model):
"""Дополнительная информация об ответе на сообщение"""
message = models.OneToOneField(
Message,
on_delete=models.CASCADE,
related_name='reply_info',
help_text="Сообщение-ответ"
)
replied_to = models.ForeignKey(
Message,
on_delete=models.SET_NULL,
null=True,
related_name='replies',
help_text="Исходное сообщение, на которое отвечают"
)
# Для ответов через чаты (если отвечаешь в другом чате)
is_cross_chat_reply = models.BooleanField(default=False)
original_chat = models.ForeignKey(
Chat,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='+',
help_text="Чат оригинального сообщения (если отличается)"
)
# Сохраненный контент на случай удаления
preserved_text = models.TextField(blank=True)
preserved_author_name = models.CharField(max_length=255, blank=True)
# Тип ответа
REPLY_TYPE_CHOICES = [
('inline', 'Обычный ответ'),
('quote', 'Цитирование'),
('thread', 'В треде'),
]
reply_type = models.CharField(
max_length=20,
choices=REPLY_TYPE_CHOICES,
default='inline'
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [
models.Index(fields=['replied_to']),
]
```
## 9. Добавить права доступа к чатам
```python
class ChatAccessPermission(models.Model):
"""Права пользователей на отправку сообщений в чужие чаты"""
chat = models.ForeignKey(
Chat,
on_delete=models.CASCADE,
related_name='access_permissions'
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='chat_permissions'
)
# Типы доступа
PERMISSION_TYPE_CHOICES = [
('send_messages', 'Отправка сообщений'),
('read_only', 'Только чтение'),
('send_with_approval', 'Отправка с модерацией'),
]
permission_type = models.CharField(
max_length=30,
choices=PERMISSION_TYPE_CHOICES,
default='send_with_approval'
)
# Кто выдал право
granted_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name='+'
)
granted_at = models.DateTimeField(auto_now_add=True)
# Срок действия
expires_at = models.DateTimeField(
null=True,
blank=True,
help_text="Когда истекает право доступа"
)
is_active = models.BooleanField(default=True)
class Meta:
unique_together = [('chat', 'user')]
indexes = [
models.Index(fields=['chat', 'is_active']),
models.Index(fields=['user', 'is_active']),
]
```
## 9. Убрать ограничения для гибкости
Удалить constraint'ы:
- `unique_main_global_chat`
- `unique_main_department_chat`
Заменить на поле `is_default` для автоматического выбора чата по умолчанию.
## 10. Добавить настройки чата
```python
class ChatSettings(models.Model):
"""Глобальные настройки чата"""
chat = models.OneToOneField(Chat, on_delete=models.CASCADE, related_name='settings')
# Кто может писать
only_admins_can_post = models.BooleanField(default=False)
# Кто может приглашать
members_can_invite = models.BooleanField(default=True)
# Автоудаление сообщений
auto_delete_messages_after = models.IntegerField(
null=True,
blank=True,
help_text="Автоматически удалять сообщения старше N дней"
)
# Максимум участников
max_members = models.IntegerField(null=True, blank=True)
# Запретить личные сообщения между участниками
disable_dm_between_members = models.BooleanField(default=False)
```
## Пример миграции (поэтапно)
### Этап 1: Базовые поля
```bash
# Добавить name, description, avatar в Chat
python manage.py makemigrations
python manage.py migrate
```
### Этап 2: Персональные настройки
```bash
# Создать ChatUserSettings
python manage.py makemigrations
python manage.py migrate
```
### Этап 3: Роли участников
```bash
# Создать ChatMembership
python manage.py makemigrations
python manage.py migrate
```
### Этап 4: Расширенные сообщения
```bash
# Добавить поля в Message, создать MessageAttachment
python manage.py makemigrations
python manage.py migrate
```
## Итоговая архитектура
```
Chat (базовая информация + настройки)
├── ChatUserSettings (персональные настройки каждого пользователя)
├── ChatMembership (роли и права участников)
├── ChatSettings (глобальные настройки чата)
├── ChatAccessPermission (права внешних пользователей на отправку сообщений)
├── Message
│ ├── MessageAttachment (файлы, изображения)
│ ├── ForwardedMessage (информация о пересылке с цепочкой)
│ ├── MessageReply (расширенная информация об ответах)
│ └── CrossChatMessage (сообщения из чужих чатов)
└── ChatReadState (статус прочтения)
```
## Новые возможности
### 1. Пересылка сообщений
- **Простая пересылка**: отправить сообщение в другой чат с указанием источника
- **Цепочка пересылок**: отслеживание всей истории пересылки (оригинал → промежуточные → текущее)
- **Счетчик пересылок**: сколько раз сообщение было переслано
- **Сохранение контента**: если оригинал удален, контент сохраняется
### 2. Ответы на сообщения
- **Внутри чата**: стандартный reply с цитированием
- **Кросс-чат ответы**: ответить на сообщение из другого чата
- **Типы ответов**: inline (обычный), quote (цитата), thread (тред)
- **Сохранение контекста**: текст и автор сохраняются при удалении оригинала
### 3. Отправка в чужие чаты
- **Режимы доступа**:
- `send_messages` - свободная отправка
- `read_only` - только чтение
- `send_with_approval` - модерация перед публикацией
- **Типы отображения**:
- `guest` - с пометкой "Внешнее сообщение"
- `forwarded` - как пересланное
- `announcement` - как объявление
- **Права доступа**: временные/постоянные, с указанием срока действия
- **Модерация**: одобрение администраторами чата
### 4. Треды (обсуждения)
- Организация дискуссий в отдельные ветки
- Счетчик ответов в треде
- Корневое сообщение треда
## Примеры использования
### Пересылка сообщения
```python
# В views или consumers
def forward_message(original_message_id, target_chat_id, user):
original = Message.objects.get(id=original_message_id)
# Создаем новое сообщение
forwarded_msg = Message.objects.create(
chat_id=target_chat_id,
author=user,
content=original.content,
is_forwarded=True,
has_attachments=original.has_attachments
)
# Копируем вложения
for att in original.attachments.all():
MessageAttachment.objects.create(
message=forwarded_msg,
file=att.file,
file_type=att.file_type,
file_name=att.file_name,
file_size=att.file_size,
mime_type=att.mime_type
)
# Находим оригинал в цепочке
if hasattr(original, 'forward_info'):
# Это уже пересланное - берем самый первый оригинал
root_original = original.forward_info.original_message
else:
root_original = original
# Создаем информацию о пересылке
ForwardedMessage.objects.create(
message=forwarded_msg,
original_message=root_original,
immediate_source=original,
original_chat=root_original.chat,
original_author=root_original.author,
forwarded_by=user,
preserved_content=root_original.content,
preserved_author_name=root_original.author.get_full_name()
)
# Увеличиваем счетчик
if hasattr(root_original, 'forward_info'):
root_original.forward_info.forward_count += 1
root_original.forward_info.save()
return forwarded_msg
```
### Отправка в чужой чат
```python
def send_to_external_chat(sender, chat_id, content, reason=''):
chat = Chat.objects.get(id=chat_id)
# Проверяем права доступа
permission = ChatAccessPermission.objects.filter(
chat=chat,
user=sender,
is_active=True
).first()
if not permission:
# Создаем запрос на отправку с модерацией
permission = ChatAccessPermission.objects.create(
chat=chat,
user=sender,
permission_type='send_with_approval',
granted_by=None
)
# Создаем сообщение
message = Message.objects.create(
chat=chat,
author=sender,
content=content,
is_cross_chat=True
)
# Создаем метаданные
CrossChatMessage.objects.create(
message=message,
sender=sender,
target_chat=chat,
approved=(permission.permission_type == 'send_messages'),
reason=reason,
display_mode='guest'
)
return message
```
### Ответ на сообщение из другого чата
```python
def reply_across_chats(user, original_msg_id, reply_content, target_chat_id):
original_msg = Message.objects.get(id=original_msg_id)
# Создаем ответ в целевом чате
reply_msg = Message.objects.create(
chat_id=target_chat_id,
author=user,
content=reply_content,
reply_to=original_msg
)
# Создаем расширенную информацию
MessageReply.objects.create(
message=reply_msg,
replied_to=original_msg,
is_cross_chat_reply=True,
original_chat=original_msg.chat,
preserved_text=original_msg.content,
preserved_author_name=original_msg.author.get_full_name(),
reply_type='quote'
)
return reply_msg
```
## Приоритет внедрения (для MVP)
1. **Высокий приоритет:**
- `name`, `avatar` в Chat
- `ChatUserSettings` (закрепление, уведомления)
- Расширение типов чатов (`group`, `channel`)
- `reply_to`, `is_edited`, `is_forwarded`, `is_cross_chat` в Message
- `MessageAttachment`
- `ForwardedMessage` (базовая пересылка)
- `MessageReply` (ответы в том же чате)
2. **Средний приоритет:**
- `ChatMembership` (роли)
- `ChatAccessPermission` (права на отправку в чужие чаты)
- `CrossChatMessage` (отправка в чужие чаты с модерацией)
- Кросс-чат ответы
- Реакции на сообщения
- Системные сообщения
- История редактирования
3. **Низкий приоритет:**
- Треды
- Автоудаление
- Расширенные разрешения
- Статус "печатает"
- Счетчики пересылок
## Изменения в UI/UX
После внедрения этих улучшений понадобится:
### Основные функции
- Модалка редактирования чата (название, аватар, описание)
- Контекстное меню для закрепления чатов
- Управление участниками (добавить/удалить/изменить роль)
- Загрузка файлов в сообщениях
- Ответы на сообщения (quote UI)
- Реакции на сообщения
- Редактирование/удаление своих сообщений
### Пересылка сообщений
- Кнопка "Переслать" в контекстном меню сообщения
- Модалка выбора чатов для пересылки (с поиском)
- Множественная пересылка (выбрать несколько чатов)
- Отображение "Переслано от [Имя]" в сообщении
- Ссылка на оригинальное сообщение (если доступно)
- Индикатор цепочки пересылок ("Переслано 5 раз")
### Ответы между чатами
- Возможность ответить в другом чате через контекстное меню
- Выбор целевого чата для ответа
- Цитата с указанием чата-источника
- Ссылка "Перейти к оригиналу" (если есть доступ)
### Отправка в чужие чаты
- Модалка "Отправить сообщение в чат"
- Поиск доступных чатов (даже без участия)
- Выбор режима отправки (гостевое/пересланное/объявление)
- Поле "Причина отправки"
- Статус модерации:
- "Ожидает одобрения" (желтый значок)
- "Одобрено" (зеленая галочка)
- "Отклонено" (красный крестик)
- Для модераторов: панель одобрения внешних сообщений
### Визуальные индикаторы
- **Пересланное сообщение**:
```
┌─────────────────────────────────┐
│ 📤 Переслано от Иван Петров │
│ Оригинал: Чат отдела IT │
├─────────────────────────────────┤
│ Текст сообщения... │
└─────────────────────────────────┘
```
- **Внешнее сообщение**:
```
┌─────────────────────────────────┐
│ 🔔 Внешнее сообщение │
│ От: Мария Сидорова │
│ Причина: Важное объявление │
├─────────────────────────────────┤
│ Текст сообщения... │
└─────────────────────────────────┘
```
- **Ответ из другого чата**:
```
┌─────────────────────────────────┐
│ Иван Петров → Чат отдела IT │
│ ↳ В ответ на сообщение из │
│ "Общий чат компании" │
├─────────────────────────────────┤
│ > Исходное сообщение... │
│ Ответ... │
└─────────────────────────────────┘
```
### Контекстное меню сообщения (расширенное)
- Ответить
- Ответить в другом чате →
- Переслать →
- В избранное
- В другой чат...
- В несколько чатов...
- Отправить в чужой чат →
- Скопировать
- Редактировать (свои)
- Удалить (свои/админ)
- Закрепить (админ)
- Пожаловаться
### Права и разрешения (админ панель чата)
- Настройки чата:
- Кто может писать (все/только участники/только админы)
- Разрешить внешние сообщения (да/нет/с модерацией)
- Кто может приглашать участников
- Управление внешними отправителями:
- Список пользователей с правами
- Добавить/удалить права
- Установить срок действия
- Модерация ожидающих сообщений

View File

@ -1,5 +1,14 @@
from django.contrib import admin
from .models import Chat, Message
from .models import (
Chat,
ChatMembership,
ChatUserSettings,
CrossChatMessage,
ForwardedMessage,
Message,
MessageAttachment,
MessageReply,
)
class MessageInline(admin.TabularInline):
@ -11,14 +20,29 @@ class MessageInline(admin.TabularInline):
verbose_name_plural = "Сообщения"
class ChatMembershipInline(admin.TabularInline):
model = ChatMembership
extra = 0
readonly_fields = ("joined_at", "left_at")
@admin.register(Chat)
class ChatAdmin(admin.ModelAdmin):
list_display = ("type", "department", "is_main", "created_at")
list_display = (
"id",
"type",
"name",
"department",
"is_main",
"created_by",
"created_at"
)
list_filter = ("type", "is_main", "created_at")
search_fields = ("department__name",)
search_fields = ("name", "description", "department__name")
ordering = ("-created_at",)
filter_horizontal = ("participants",)
inlines = [MessageInline]
inlines = [ChatMembershipInline, MessageInline]
readonly_fields = ("created_at",)
def get_readonly_fields(self, request, obj=None):
if obj and obj.is_main:
@ -26,14 +50,122 @@ class ChatAdmin(admin.ModelAdmin):
return self.readonly_fields
class MessageAttachmentInline(admin.TabularInline):
model = MessageAttachment
extra = 0
readonly_fields = ("uploaded_at", "file_size", "mime_type")
@admin.register(Message)
class MessageAdmin(admin.ModelAdmin):
list_display = ("chat", "author", "short_content", "created_at")
list_filter = ("created_at",)
list_display = (
"id",
"chat",
"author",
"short_content",
"is_edited",
"is_deleted",
"is_pinned",
"created_at"
)
list_filter = (
"created_at",
"is_edited",
"is_deleted",
"is_pinned",
"is_forwarded"
)
search_fields = ("content", "author__last_name", "author__first_name")
ordering = ("-created_at",)
inlines = [MessageAttachmentInline]
readonly_fields = ("created_at", "edited_at", "deleted_at")
def short_content(self, obj):
return (obj.content[:50] + "...") if len(obj.content) > 50 else obj.content
short_content.short_description = "Текст"
@admin.register(MessageAttachment)
class MessageAttachmentAdmin(admin.ModelAdmin):
list_display = (
"id",
"message",
"file_name",
"file_type",
"file_size",
"uploaded_at"
)
list_filter = ("file_type", "uploaded_at")
search_fields = ("file_name", "message__content")
ordering = ("-uploaded_at",)
@admin.register(ChatMembership)
class ChatMembershipAdmin(admin.ModelAdmin):
list_display = (
"id",
"chat",
"user",
"role",
"is_active",
"joined_at"
)
list_filter = ("role", "is_active", "joined_at")
search_fields = ("user__last_name", "user__first_name", "chat__name")
ordering = ("-joined_at",)
@admin.register(ChatUserSettings)
class ChatUserSettingsAdmin(admin.ModelAdmin):
list_display = (
"user",
"chat",
"is_pinned",
"notifications_enabled",
"is_hidden"
)
list_filter = ("is_pinned", "notifications_enabled", "is_hidden")
search_fields = ("user__last_name", "chat__name")
@admin.register(ForwardedMessage)
class ForwardedMessageAdmin(admin.ModelAdmin):
list_display = (
"message",
"original_author",
"forwarded_by",
"forward_count",
"forwarded_at"
)
list_filter = ("forwarded_at",)
search_fields = ("message__content", "original_author__last_name")
ordering = ("-forwarded_at",)
@admin.register(MessageReply)
class MessageReplyAdmin(admin.ModelAdmin):
list_display = (
"message",
"replied_to",
"reply_type",
"is_cross_chat_reply",
"created_at"
)
list_filter = ("reply_type", "is_cross_chat_reply", "created_at")
ordering = ("-created_at",)
@admin.register(CrossChatMessage)
class CrossChatMessageAdmin(admin.ModelAdmin):
list_display = (
"message",
"sender",
"target_chat",
"status",
"requires_moderation",
"sent_at"
)
list_filter = ("status", "requires_moderation", "sent_at")
search_fields = ("sender__last_name", "target_chat__name")
ordering = ("-sent_at",)

View File

@ -0,0 +1,513 @@
# communications/api_views.py
"""API для расширенных функций мессенджера"""
from django.contrib.auth.decorators import login_required
from django.db import models, transaction
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.views.decorators.http import require_POST
from .models import (
Chat,
ChatMembership,
ChatUserSettings,
ForwardedMessage,
Message,
MessageAttachment,
MessageReply,
)
# ============ CHAT MANAGEMENT ============
@login_required
@require_POST
def create_chat(request):
"""Создание нового чата (group, channel, announcement)"""
chat_type = request.POST.get('type', 'group')
name = request.POST.get('name', '').strip()
description = request.POST.get('description', '').strip()
if chat_type not in ['group', 'channel', 'announcement']:
return JsonResponse({'ok': False, 'error': 'Invalid chat type'}, status=400)
if not name:
return JsonResponse({'ok': False, 'error': 'Name is required'}, status=400)
chat = Chat.objects.create(
type=chat_type,
name=name,
description=description,
created_by=request.user
)
# Создатель - владелец с полными правами
ChatMembership.objects.create(
chat=chat,
user=request.user,
role='owner',
can_send_messages=True,
can_add_members=True,
can_remove_members=True,
can_pin_messages=True
)
return JsonResponse({
'ok': True,
'chat_id': chat.id,
'chat_type': chat.type,
'name': chat.name
})
@login_required
@require_POST
def update_chat(request, chat_id):
"""Обновление названия/описания/аватара чата"""
chat = get_object_or_404(Chat, pk=chat_id)
# Проверка прав (только owner/admin)
membership = ChatMembership.objects.filter(
chat=chat,
user=request.user,
role__in=['owner', 'admin']
).first()
if not membership:
return JsonResponse({'ok': False, 'error': 'Forbidden'}, status=403)
# Обновление полей
if 'name' in request.POST:
chat.name = request.POST['name'].strip()
if 'description' in request.POST:
chat.description = request.POST['description'].strip()
if 'avatar' in request.FILES:
chat.avatar = request.FILES['avatar']
chat.save()
return JsonResponse({'ok': True})
# ============ CHAT USER SETTINGS ============
@login_required
@require_POST
def pin_chat(request, chat_id):
"""Закрепление/открепление чата"""
chat = get_object_or_404(Chat, pk=chat_id)
is_pinned = request.POST.get('pinned', 'true').lower() == 'true'
settings, created = ChatUserSettings.objects.get_or_create(
chat=chat,
user=request.user
)
settings.is_pinned = is_pinned
if is_pinned:
settings.pinned_at = timezone.now()
# Определяем порядок
max_order = ChatUserSettings.objects.filter(
user=request.user,
is_pinned=True
).aggregate(max_order=models.Max('pin_order'))['max_order'] or 0
settings.pin_order = max_order + 1
else:
settings.pinned_at = None
settings.pin_order = 0
settings.save()
return JsonResponse({'ok': True, 'pinned': is_pinned})
@login_required
@require_POST
def set_chat_notifications(request, chat_id):
"""Включение/выключение уведомлений"""
chat = get_object_or_404(Chat, pk=chat_id)
enabled = request.POST.get('enabled', 'true').lower() == 'true'
settings, created = ChatUserSettings.objects.get_or_create(
chat=chat,
user=request.user
)
settings.notifications_enabled = enabled
settings.save()
return JsonResponse({'ok': True, 'enabled': enabled})
# ============ MESSAGE ACTIONS ============
@login_required
@require_POST
def edit_message(request, message_id):
"""Редактирование сообщения"""
message = get_object_or_404(Message, pk=message_id)
if message.author != request.user:
return JsonResponse({'ok': False, 'error': 'Forbidden'}, status=403)
new_content = request.POST.get('content', '').strip()
if not new_content:
return JsonResponse({'ok': False, 'error': 'Empty content'}, status=400)
# Сохраняем историю
if not message.is_edited:
message.edit_history = []
message.edit_history.append({
'timestamp': timezone.now().isoformat(),
'old_content': message.content
})
message.content = new_content
message.is_edited = True
message.edited_at = timezone.now()
message.save()
return JsonResponse({'ok': True, 'edited_at': message.edited_at.isoformat()})
@login_required
@require_POST
def delete_message(request, message_id):
"""Удаление сообщения"""
message = get_object_or_404(Message, pk=message_id)
# Проверка: автор или модератор/админ чата
can_delete = False
if message.author == request.user:
can_delete = True
else:
membership = ChatMembership.objects.filter(
chat=message.chat,
user=request.user,
role__in=['owner', 'admin', 'moderator']
).first()
if membership:
can_delete = True
if not can_delete:
return JsonResponse({'ok': False, 'error': 'Forbidden'}, status=403)
message.is_deleted = True
message.deleted_at = timezone.now()
message.deleted_by = request.user
message.save()
return JsonResponse({'ok': True})
@login_required
@require_POST
def add_reaction(request, message_id):
"""Добавление реакции на сообщение"""
message = get_object_or_404(Message, pk=message_id)
emoji = request.POST.get('emoji', '').strip()
if not emoji:
return JsonResponse({'ok': False, 'error': 'Emoji required'}, status=400)
reactions = message.reactions or {}
if emoji not in reactions:
reactions[emoji] = []
if request.user.id not in reactions[emoji]:
reactions[emoji].append(request.user.id)
message.reactions = reactions
message.save()
return JsonResponse({'ok': True, 'reactions': reactions})
@login_required
@require_POST
def remove_reaction(request, message_id):
"""Удаление реакции"""
message = get_object_or_404(Message, pk=message_id)
emoji = request.POST.get('emoji', '').strip()
reactions = message.reactions or {}
if emoji in reactions and request.user.id in reactions[emoji]:
reactions[emoji].remove(request.user.id)
if not reactions[emoji]:
del reactions[emoji]
message.reactions = reactions
message.save()
return JsonResponse({'ok': True, 'reactions': reactions})
@login_required
@require_POST
def pin_message(request, message_id):
"""Закрепление сообщения в чате"""
message = get_object_or_404(Message, pk=message_id)
# Проверка прав
membership = ChatMembership.objects.filter(
chat=message.chat,
user=request.user,
can_pin_messages=True
).first()
if not membership:
return JsonResponse({'ok': False, 'error': 'Forbidden'}, status=403)
is_pinned = request.POST.get('pinned', 'true').lower() == 'true'
message.is_pinned = is_pinned
if is_pinned:
message.pinned_by = request.user
message.pinned_at = timezone.now()
else:
message.pinned_by = None
message.pinned_at = None
message.save()
return JsonResponse({'ok': True, 'pinned': is_pinned})
# ============ ATTACHMENTS ============
@login_required
@require_POST
def upload_attachment(request):
"""Загрузка вложения"""
if 'file' not in request.FILES:
return JsonResponse({'ok': False, 'error': 'No file'}, status=400)
message_id = request.POST.get('message_id')
if not message_id:
return JsonResponse({'ok': False, 'error': 'message_id required'}, status=400)
message = get_object_or_404(Message, pk=message_id)
if message.author != request.user:
return JsonResponse({'ok': False, 'error': 'Forbidden'}, status=403)
uploaded_file = request.FILES['file']
# Определение типа файла
mime_type = uploaded_file.content_type or ''
if mime_type.startswith('image/'):
file_type = 'image'
elif mime_type.startswith('video/'):
file_type = 'video'
elif mime_type.startswith('audio/'):
file_type = 'audio'
else:
file_type = 'file'
attachment = MessageAttachment.objects.create(
message=message,
file=uploaded_file,
file_type=file_type,
file_name=uploaded_file.name,
file_size=uploaded_file.size,
mime_type=mime_type
)
message.has_attachments = True
message.save()
return JsonResponse({
'ok': True,
'attachment_id': attachment.id,
'file_name': attachment.file_name,
'file_type': attachment.file_type,
'file_url': attachment.file.url
})
# ============ FORWARDING ============
@login_required
@require_POST
def forward_message(request, message_id):
"""Пересылка сообщения в другой чат"""
original = get_object_or_404(Message, pk=message_id)
target_chat_id = request.POST.get('target_chat_id')
if not target_chat_id:
return JsonResponse({'ok': False, 'error': 'target_chat_id required'}, status=400)
target_chat = get_object_or_404(Chat, pk=target_chat_id)
# Проверка доступа к целевому чату
membership = ChatMembership.objects.filter(
chat=target_chat,
user=request.user,
can_send_messages=True
).first()
if not membership:
return JsonResponse({'ok': False, 'error': 'Cannot send to this chat'}, status=403)
with transaction.atomic():
# Создаем новое сообщение
forwarded = Message.objects.create(
chat=target_chat,
author=request.user,
content=original.content,
is_forwarded=True
)
# Определяем оригинальное сообщение в цепочке
if hasattr(original, 'forward_info'):
original_message = original.forward_info.original_message
forward_count = original.forward_info.forward_count + 1
else:
original_message = original
forward_count = 1
# Создаем информацию о пересылке
ForwardedMessage.objects.create(
message=forwarded,
original_message=original_message,
immediate_source=original,
original_chat=original.chat,
original_author=original.author,
forwarded_by=request.user,
forward_count=forward_count,
preserved_content=original.content,
preserved_author_name=original.author.get_full_name()
)
return JsonResponse({'ok': True, 'new_message_id': forwarded.id})
# ============ REPLIES ============
@login_required
@require_POST
def reply_to_message(request, message_id):
"""Ответ на сообщение"""
replied_to = get_object_or_404(Message, pk=message_id)
content = request.POST.get('content', '').strip()
reply_type = request.POST.get('reply_type', 'inline')
if not content:
return JsonResponse({'ok': False, 'error': 'Content required'}, status=400)
# Создаем сообщение-ответ
reply = Message.objects.create(
chat=replied_to.chat,
author=request.user,
content=content,
reply_to=replied_to
)
# Создаем расширенную информацию об ответе
MessageReply.objects.create(
message=reply,
replied_to=replied_to,
is_cross_chat_reply=False,
preserved_text=replied_to.content[:200],
preserved_author_name=replied_to.author.get_full_name(),
reply_type=reply_type
)
# Если это тред, обновляем счетчик
if reply_type == 'thread':
if replied_to.thread_root:
root = replied_to.thread_root
else:
root = replied_to
reply.thread_root = root
reply.save()
root.thread_reply_count += 1
root.save()
return JsonResponse({
'ok': True,
'reply_id': reply.id,
'replied_to_id': replied_to.id
})
# ============ CHAT MEMBERSHIP ============
@login_required
@require_POST
def add_member(request, chat_id):
"""Добавление участника в чат"""
chat = get_object_or_404(Chat, pk=chat_id)
user_id = request.POST.get('user_id')
if not user_id:
return JsonResponse({'ok': False, 'error': 'user_id required'}, status=400)
# Проверка прав
membership = ChatMembership.objects.filter(
chat=chat,
user=request.user,
can_add_members=True
).first()
if not membership:
return JsonResponse({'ok': False, 'error': 'Forbidden'}, status=403)
from employees.models import Employee
new_user = get_object_or_404(Employee, pk=user_id)
# Создаем или активируем участие
new_membership, created = ChatMembership.objects.get_or_create(
chat=chat,
user=new_user,
defaults={
'role': 'member',
'invited_by': request.user,
'can_send_messages': True
}
)
if not created and not new_membership.is_active:
new_membership.is_active = True
new_membership.left_at = None
new_membership.save()
return JsonResponse({'ok': True, 'created': created})
@login_required
@require_POST
def remove_member(request, chat_id):
"""Удаление участника из чата"""
chat = get_object_or_404(Chat, pk=chat_id)
user_id = request.POST.get('user_id')
if not user_id:
return JsonResponse({'ok': False, 'error': 'user_id required'}, status=400)
# Проверка прав
membership = ChatMembership.objects.filter(
chat=chat,
user=request.user,
can_remove_members=True
).first()
if not membership:
return JsonResponse({'ok': False, 'error': 'Forbidden'}, status=403)
target_membership = ChatMembership.objects.filter(
chat=chat,
user_id=user_id
).first()
if target_membership:
target_membership.is_active = False
target_membership.left_at = timezone.now()
target_membership.save()
return JsonResponse({'ok': True})

View File

@ -8,6 +8,8 @@ class CommunicationsConfig(AppConfig):
def ready(self):
import communications.signals
import communications.notification_signals # Подключаем signals уведомлений
def create_main_global_chat(sender, **kwargs):
from communications.models import Chat

View File

@ -10,6 +10,7 @@ from .models import Chat, ChatReadState, Message
def serialize_message(m: Message) -> dict:
"""Сериализация сообщения с поддержкой новых полей"""
author = m.author
author_name = author.get_full_name() or author.username
avatar = ""
@ -19,7 +20,8 @@ def serialize_message(m: Message) -> dict:
except Exception:
avatar = ""
return {
# Базовая информация
data = {
"id": m.id,
"content": m.content,
"author_id": author.id if author else None,
@ -27,8 +29,50 @@ def serialize_message(m: Message) -> dict:
"author_url": reverse("employees:employee_detail", args=[author.id]) if author else "",
"avatar": avatar,
"created": m.created_at.strftime("%d.%m.%Y %H:%M"),
"created_ts": int(m.created_at.timestamp() * 1000), # ⬅ важно для сортировки на клиенте
"created_ts": int(m.created_at.timestamp() * 1000),
# Новые поля
"is_edited": m.is_edited,
"edited_at": m.edited_at.isoformat() if m.edited_at else None,
"is_deleted": m.is_deleted,
"is_pinned": m.is_pinned,
"is_forwarded": m.is_forwarded,
"is_system": m.is_system,
"reactions": m.reactions or {},
"has_attachments": m.has_attachments,
}
# Ответ на сообщение
if m.reply_to_id:
data["reply_to"] = {
"id": m.reply_to_id,
"content": m.reply_to.content[:100] if m.reply_to else "",
"author_name": m.reply_to.author.get_full_name() if m.reply_to else ""
}
# Вложения (если есть)
if m.has_attachments:
attachments = []
for att in m.attachments.all():
attachments.append({
"id": att.id,
"file_name": att.file_name,
"file_type": att.file_type,
"file_url": att.file.url,
"file_size": att.file_size,
"thumbnail": att.thumbnail.url if att.thumbnail else None
})
data["attachments"] = attachments
# Информация о пересылке
if m.is_forwarded and hasattr(m, 'forward_info'):
fw = m.forward_info
data["forward_info"] = {
"original_author": fw.preserved_author_name,
"forward_count": fw.forward_count,
}
return data
class ChatConsumer(AsyncJsonWebsocketConsumer):
@ -54,32 +98,214 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
async def disconnect(self, code):
if hasattr(self, "group_name"):
await self.channel_layer.group_discard(self.group_name, self.channel_name)
# Сбрасываем статус "набирает текст"
if hasattr(self, 'chat'):
await self._set_typing_status(self.chat, self.scope["user"], False)
async def receive_json(self, content, **kwargs):
# ждём {"content": "..."} или {"action": "...", "text": "..."}
text = ""
if isinstance(content, dict):
text = str(content.get("content") or content.get("text") or "").strip()
"""Обработка входящих сообщений и действий"""
if not isinstance(content, dict):
return
action = content.get("action", "send_message")
user = self.scope["user"]
# Обработка разных действий
if action == "send_message":
await self._handle_send_message(content, user)
elif action == "edit_message":
await self._handle_edit_message(content, user)
elif action == "delete_message":
await self._handle_delete_message(content, user)
elif action == "add_reaction":
await self._handle_add_reaction(content, user)
elif action == "remove_reaction":
await self._handle_remove_reaction(content, user)
elif action == "typing":
await self._handle_typing(content, user)
elif action == "stop_typing":
await self._handle_stop_typing(user)
async def _handle_send_message(self, content, user):
"""Отправка нового сообщения"""
text = str(content.get("content") or content.get("text") or "").strip()
if not text:
return
user = self.scope["user"]
msg = await self._create_message(self.chat, user, text)
# автору — сразу «прочитано»
# Проверка на ответ
reply_to_id = content.get("reply_to_id")
msg = await self._create_message(self.chat, user, text, reply_to_id)
await self._mark_read(self.chat, user)
payload = serialize_message(msg)
# ⬇ кладём chat_id для листа чатов (ChatListConsumer)
# Сбрасываем статус "набирает"
await self._set_typing_status(self.chat, user, False)
payload = await self._serialize_message_full(msg)
await self.channel_layer.group_send(
self.group_name,
{"type": "chat.message", "chat_id": self.chat_id, "payload": payload},
)
async def _handle_edit_message(self, content, user):
"""Редактирование сообщения"""
message_id = content.get("message_id")
new_content = content.get("content", "").strip()
if not message_id or not new_content:
return
success = await self._edit_message(message_id, user, new_content)
if success:
msg = await self._get_message(message_id)
payload = await self._serialize_message_full(msg)
await self.channel_layer.group_send(
self.group_name,
{
"type": "chat.message_edited",
"chat_id": self.chat_id,
"payload": payload
},
)
async def _handle_delete_message(self, content, user):
"""Удаление сообщения"""
message_id = content.get("message_id")
if not message_id:
return
success = await self._delete_message(message_id, user)
if success:
await self.channel_layer.group_send(
self.group_name,
{
"type": "chat.message_deleted",
"chat_id": self.chat_id,
"message_id": message_id
},
)
async def _handle_add_reaction(self, content, user):
"""Добавление реакции"""
message_id = content.get("message_id")
emoji = content.get("emoji", "").strip()
if not message_id or not emoji:
return
reactions = await self._add_reaction(message_id, user, emoji)
if reactions is not None:
await self.channel_layer.group_send(
self.group_name,
{
"type": "chat.reaction_added",
"chat_id": self.chat_id,
"message_id": message_id,
"reactions": reactions
},
)
async def _handle_remove_reaction(self, content, user):
"""Удаление реакции"""
message_id = content.get("message_id")
emoji = content.get("emoji", "").strip()
if not message_id or not emoji:
return
reactions = await self._remove_reaction(message_id, user, emoji)
if reactions is not None:
await self.channel_layer.group_send(
self.group_name,
{
"type": "chat.reaction_removed",
"chat_id": self.chat_id,
"message_id": message_id,
"reactions": reactions
},
)
async def _handle_typing(self, content, user):
"""Индикатор набора текста"""
await self._set_typing_status(self.chat, user, True)
await self.channel_layer.group_send(
self.group_name,
{
"type": "chat.user_typing",
"chat_id": self.chat_id,
"user_id": user.id,
"user_name": user.get_full_name() or user.username
},
)
async def _handle_stop_typing(self, user):
"""Остановка индикатора набора"""
await self._set_typing_status(self.chat, user, False)
await self.channel_layer.group_send(
self.group_name,
{
"type": "chat.user_stopped_typing",
"chat_id": self.chat_id,
"user_id": user.id
},
)
# Обработчики событий от group_send
async def chat_message(self, event):
# detail-страница чата просто получает payload
"""Новое сообщение"""
await self.send_json(event["payload"])
async def chat_message_edited(self, event):
"""Сообщение отредактировано"""
await self.send_json({
"type": "message_edited",
"message": event["payload"]
})
async def chat_message_deleted(self, event):
"""Сообщение удалено"""
await self.send_json({
"type": "message_deleted",
"message_id": event["message_id"]
})
async def chat_reaction_added(self, event):
"""Реакция добавлена"""
await self.send_json({
"type": "reaction_added",
"message_id": event["message_id"],
"reactions": event["reactions"]
})
async def chat_reaction_removed(self, event):
"""Реакция удалена"""
await self.send_json({
"type": "reaction_removed",
"message_id": event["message_id"],
"reactions": event["reactions"]
})
async def chat_user_typing(self, event):
"""Пользователь набирает текст"""
# Не отправляем самому себе
if event["user_id"] != self.scope["user"].id:
await self.send_json({
"type": "user_typing",
"user_id": event["user_id"],
"user_name": event["user_name"]
})
async def chat_user_stopped_typing(self, event):
"""Пользователь закончил набор"""
if event["user_id"] != self.scope["user"].id:
await self.send_json({
"type": "user_stopped_typing",
"user_id": event["user_id"]
})
# --- DB helpers ---
@database_sync_to_async
def _get_chat(self, chat_id: int) -> Chat:
@ -105,8 +331,102 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
return chat.participants.filter(id=user.id).exists()
@database_sync_to_async
def _create_message(self, chat: Chat, user, text: str) -> Message:
return Message.objects.create(chat=chat, author=user, content=text[:2000])
def _create_message(self, chat: Chat, user, text: str, reply_to_id=None) -> Message:
msg = Message.objects.create(
chat=chat,
author=user,
content=text[:2000],
reply_to_id=reply_to_id
)
return msg
@database_sync_to_async
def _get_message(self, message_id: int) -> Message:
return Message.objects.select_related(
'author', 'reply_to', 'reply_to__author'
).prefetch_related('attachments').get(pk=message_id)
@database_sync_to_async
def _serialize_message_full(self, msg: Message) -> dict:
return serialize_message(msg)
@database_sync_to_async
def _edit_message(self, message_id: int, user, new_content: str) -> bool:
try:
msg = Message.objects.get(pk=message_id, author=user)
if not msg.is_edited:
msg.edit_history = []
msg.edit_history.append({
'timestamp': timezone.now().isoformat(),
'old_content': msg.content
})
msg.content = new_content
msg.is_edited = True
msg.edited_at = timezone.now()
msg.save()
return True
except Message.DoesNotExist:
return False
@database_sync_to_async
def _delete_message(self, message_id: int, user) -> bool:
try:
msg = Message.objects.get(pk=message_id, author=user)
msg.is_deleted = True
msg.deleted_at = timezone.now()
msg.deleted_by = user
msg.save()
return True
except Message.DoesNotExist:
return False
@database_sync_to_async
def _add_reaction(self, message_id: int, user, emoji: str):
try:
msg = Message.objects.get(pk=message_id)
reactions = msg.reactions or {}
if emoji not in reactions:
reactions[emoji] = []
if user.id not in reactions[emoji]:
reactions[emoji].append(user.id)
msg.reactions = reactions
msg.save()
return reactions
except Message.DoesNotExist:
return None
@database_sync_to_async
def _remove_reaction(self, message_id: int, user, emoji: str):
try:
msg = Message.objects.get(pk=message_id)
reactions = msg.reactions or {}
if emoji in reactions and user.id in reactions[emoji]:
reactions[emoji].remove(user.id)
if not reactions[emoji]:
del reactions[emoji]
msg.reactions = reactions
msg.save()
return reactions
except Message.DoesNotExist:
return None
@database_sync_to_async
def _set_typing_status(self, chat: Chat, user, is_typing: bool):
ChatReadState.objects.update_or_create(
chat=chat,
user=user,
defaults={
'is_typing': is_typing,
'typing_updated_at': timezone.now() if is_typing else None
}
)
@database_sync_to_async
def _mark_read(self, chat: Chat, user):
@ -143,8 +463,29 @@ class ChatListConsumer(AsyncJsonWebsocketConsumer):
async def chat_message(self, event):
# Проксируем компактное событие в UI списка
await self.send_json(
{"type": "list_update", "chat_id": event.get("chat_id"), "message": event.get("payload")}
{
"type": "list_update",
"chat_id": event.get("chat_id"),
"message": event.get("payload")
}
)
async def chat_message_edited(self, event):
"""Сообщение отредактировано - обновляем в списке если это последнее"""
await self.send_json({
"type": "message_edited",
"chat_id": event.get("chat_id"),
"message": event.get("payload")
})
async def chat_user_typing(self, event):
"""Индикатор набора текста в списке чатов"""
await self.send_json({
"type": "user_typing",
"chat_id": event.get("chat_id"),
"user_id": event.get("user_id"),
"user_name": event.get("user_name")
})
@database_sync_to_async
def _get_available_chat_ids(self, user):

View File

@ -0,0 +1,41 @@
# Generated by Django 5.2.4 on 2025-11-20 11:53
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('communications', '0005_alter_chatreadstate_unique_together_alter_chat_type_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='chat',
name='avatar',
field=models.ImageField(blank=True, null=True, upload_to='chat_avatars/%Y/%m/', verbose_name='Аватар чата'),
),
migrations.AddField(
model_name='chat',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_chats', to=settings.AUTH_USER_MODEL, verbose_name='Создатель'),
),
migrations.AddField(
model_name='chat',
name='description',
field=models.TextField(blank=True, verbose_name='Описание чата'),
),
migrations.AddField(
model_name='chat',
name='include_all_employees',
field=models.BooleanField(default=False, help_text='Для анонсов и общих чатов', verbose_name='Включить всех сотрудников'),
),
migrations.AddField(
model_name='chat',
name='name',
field=models.CharField(blank=True, help_text='Для групповых/канальных чатов', max_length=255, verbose_name='Название чата'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-11-20 11:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('communications', '0006_chat_avatar_chat_created_by_chat_description_and_more'),
]
operations = [
migrations.AlterField(
model_name='chat',
name='type',
field=models.CharField(choices=[('private', 'Личный'), ('group', 'Групповой'), ('department', 'Отдел'), ('channel', 'Канал'), ('announcement', 'Объявления'), ('global', 'Глобальный')], db_index=True, max_length=16, verbose_name='Тип чата'),
),
]

View File

@ -0,0 +1,37 @@
# Generated by Django 5.2.4 on 2025-11-20 11:56
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('communications', '0007_alter_chat_type'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ChatUserSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_pinned', models.BooleanField(default=False, verbose_name='Закреплен')),
('pinned_at', models.DateTimeField(blank=True, null=True, verbose_name='Время закрепления')),
('pin_order', models.IntegerField(default=0, verbose_name='Порядок')),
('notifications_enabled', models.BooleanField(default=True, verbose_name='Уведомления')),
('custom_name', models.CharField(blank=True, max_length=255, verbose_name='Свое название')),
('is_hidden', models.BooleanField(default=False, verbose_name='Скрыт')),
('chat', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_settings', to='communications.chat')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chat_settings', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Настройки чата',
'verbose_name_plural': 'Настройки чатов',
'ordering': ['-is_pinned', '-pinned_at'],
'indexes': [models.Index(fields=['user', 'is_pinned'], name='communicati_user_id_2d5887_idx'), models.Index(fields=['chat', 'user'], name='communicati_chat_id_86bcb6_idx')],
'unique_together': {('chat', 'user')},
},
),
]

View File

@ -0,0 +1,131 @@
# Generated by Django 5.2.4 on 2025-11-20 11:59
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('communications', '0008_chatusersettings'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='message',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='message',
name='deleted_by',
field=models.ForeignKey(blank=True, help_text='Кто удалил (может отличаться от автора)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='message',
name='edit_history',
field=models.JSONField(blank=True, default=list, help_text='История редактирования [{timestamp, old_content}, ...]'),
),
migrations.AddField(
model_name='message',
name='edited_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='message',
name='has_attachments',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='message',
name='is_cross_chat',
field=models.BooleanField(default=False, help_text='Сообщение отправлено не участником чата'),
),
migrations.AddField(
model_name='message',
name='is_deleted',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='message',
name='is_edited',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='message',
name='is_forwarded',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='message',
name='is_pinned',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='message',
name='is_system',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='message',
name='pinned_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='message',
name='pinned_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='message',
name='reactions',
field=models.JSONField(blank=True, default=dict, help_text="Формат: {'👍': [user_id1, user_id2], '❤️': [user_id3]}"),
),
migrations.AddField(
model_name='message',
name='reply_to',
field=models.ForeignKey(blank=True, help_text='Простая связь для быстрого доступа', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='direct_replies', to='communications.message'),
),
migrations.AddField(
model_name='message',
name='system_metadata',
field=models.JSONField(blank=True, default=dict, help_text='Дополнительные данные для системных сообщений'),
),
migrations.AddField(
model_name='message',
name='system_type',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='message',
name='thread_reply_count',
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name='message',
name='thread_root',
field=models.ForeignKey(blank=True, help_text='Корневое сообщение треда', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='thread_messages', to='communications.message'),
),
migrations.AddIndex(
model_name='message',
index=models.Index(fields=['chat', 'created_at'], name='communicati_chat_id_10091d_idx'),
),
migrations.AddIndex(
model_name='message',
index=models.Index(fields=['chat', 'is_deleted', 'created_at'], name='communicati_chat_id_7cdaea_idx'),
),
migrations.AddIndex(
model_name='message',
index=models.Index(fields=['author', 'created_at'], name='communicati_author__e3ed1d_idx'),
),
migrations.AddIndex(
model_name='message',
index=models.Index(fields=['thread_root', 'created_at'], name='communicati_thread__951aef_idx'),
),
migrations.AddIndex(
model_name='message',
index=models.Index(fields=['is_pinned', 'chat'], name='communicati_is_pinn_8cd754_idx'),
),
]

View File

@ -0,0 +1,34 @@
# Generated by Django 5.2.4 on 2025-11-20 12:01
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('communications', '0009_message_deleted_at_message_deleted_by_and_more'),
]
operations = [
migrations.CreateModel(
name='MessageAttachment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file', models.FileField(upload_to='chat_attachments/%Y/%m/%d/', verbose_name='Файл')),
('file_type', models.CharField(choices=[('image', 'Изображение'), ('video', 'Видео'), ('audio', 'Аудио'), ('file', 'Файл')], max_length=10, verbose_name='Тип файла')),
('file_name', models.CharField(max_length=255, verbose_name='Название файла')),
('file_size', models.BigIntegerField(verbose_name='Размер файла (байты)')),
('mime_type', models.CharField(blank=True, max_length=100, verbose_name='MIME тип')),
('uploaded_at', models.DateTimeField(auto_now_add=True, verbose_name='Время загрузки')),
('thumbnail', models.ImageField(blank=True, help_text='Для изображений и видео', null=True, upload_to='chat_attachments/thumbnails/%Y/%m/%d/', verbose_name='Миниатюра')),
('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='communications.message', verbose_name='Сообщение')),
],
options={
'verbose_name': 'Вложение',
'verbose_name_plural': 'Вложения',
'ordering': ['uploaded_at'],
'indexes': [models.Index(fields=['message', 'file_type'], name='communicati_message_7ec201_idx'), models.Index(fields=['uploaded_at'], name='communicati_uploade_015a96_idx')],
},
),
]

View File

@ -0,0 +1,95 @@
# Generated by Django 5.2.4 on 2025-11-20 12:03
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('communications', '0010_messageattachment'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ChatAccessPermission',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('access_level', models.CharField(choices=[('read', 'Только чтение'), ('write', 'Чтение и отправка'), ('moderate', 'Модерация')], default='write', max_length=10, verbose_name='Уровень доступа')),
('granted_at', models.DateTimeField(auto_now_add=True, verbose_name='Когда предоставлен доступ')),
('expires_at', models.DateTimeField(blank=True, help_text='Если не указано - бессрочно', null=True, verbose_name='Срок действия')),
('is_active', models.BooleanField(default=True, verbose_name='Активен')),
('chat', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_permissions', to='communications.chat', verbose_name='Чат')),
('granted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Кто предоставил доступ')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chat_access_permissions', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
],
options={
'verbose_name': 'Право доступа к чату',
'verbose_name_plural': 'Права доступа к чатам',
'indexes': [models.Index(fields=['chat', 'is_active'], name='communicati_chat_id_788c77_idx'), models.Index(fields=['user', 'is_active'], name='communicati_user_id_8402d4_idx')],
'unique_together': {('chat', 'user')},
},
),
migrations.CreateModel(
name='CrossChatMessage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('pending', 'На модерации'), ('approved', 'Одобрено'), ('rejected', 'Отклонено')], default='pending', max_length=10, verbose_name='Статус')),
('requires_moderation', models.BooleanField(default=True, verbose_name='Требует модерации')),
('moderated_at', models.DateTimeField(blank=True, null=True, verbose_name='Время модерации')),
('moderation_note', models.TextField(blank=True, verbose_name='Заметка модератора')),
('sent_at', models.DateTimeField(auto_now_add=True, verbose_name='Время отправки')),
('message', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='cross_chat_info', to='communications.message', verbose_name='Сообщение')),
('moderated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Модератор')),
('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_cross_chat_messages', to=settings.AUTH_USER_MODEL, verbose_name='Отправитель')),
('target_chat', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cross_chat_messages', to='communications.chat', verbose_name='Целевой чат')),
],
options={
'verbose_name': 'Кросс-чат сообщение',
'verbose_name_plural': 'Кросс-чат сообщения',
'indexes': [models.Index(fields=['target_chat', 'status'], name='communicati_target__afb50e_idx'), models.Index(fields=['sender', 'sent_at'], name='communicati_sender__d8ea5c_idx'), models.Index(fields=['status', 'requires_moderation'], name='communicati_status_d4dade_idx')],
},
),
migrations.CreateModel(
name='ForwardedMessage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('forwarded_at', models.DateTimeField(auto_now_add=True, verbose_name='Время пересылки')),
('forward_count', models.IntegerField(default=1, help_text='Сколько раз это сообщение пересылалось', verbose_name='Количество пересылок')),
('preserved_content', models.TextField(blank=True, help_text='На случай удаления оригинала', verbose_name='Сохранённый текст')),
('preserved_author_name', models.CharField(blank=True, max_length=255, verbose_name='Имя автора оригинала')),
('forwarded_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='forwarded_messages', to=settings.AUTH_USER_MODEL, verbose_name='Кто переслал')),
('immediate_source', models.ForeignKey(blank=True, help_text='Откуда переслали (может отличаться от original)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='communications.message', verbose_name='Непосредственный источник пересылки')),
('message', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='forward_info', to='communications.message', verbose_name='Пересланное сообщение')),
('original_author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Автор оригинального сообщения')),
('original_chat', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='communications.chat', verbose_name='Исходный чат')),
('original_message', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='forwarded_copies', to='communications.message', verbose_name='Самое первое сообщение в цепочке')),
],
options={
'verbose_name': 'Информация о пересылке',
'verbose_name_plural': 'Информация о пересылках',
'indexes': [models.Index(fields=['original_message'], name='communicati_origina_aa3dab_idx'), models.Index(fields=['forwarded_by', 'forwarded_at'], name='communicati_forward_44797e_idx')],
},
),
migrations.CreateModel(
name='MessageReply',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_cross_chat_reply', models.BooleanField(default=False, verbose_name='Ответ из другого чата')),
('preserved_text', models.TextField(blank=True, verbose_name='Сохранённый текст оригинала')),
('preserved_author_name', models.CharField(blank=True, max_length=255, verbose_name='Имя автора оригинала')),
('reply_type', models.CharField(choices=[('inline', 'В потоке'), ('quote', 'С цитатой'), ('thread', 'В треде')], default='inline', max_length=10, verbose_name='Тип ответа')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Время создания ответа')),
('message', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='reply_info', to='communications.message', verbose_name='Сообщение-ответ')),
('original_chat', models.ForeignKey(blank=True, help_text='Если ответ был из другого чата', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='communications.chat', verbose_name='Исходный чат')),
('replied_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='extended_replies', to='communications.message', verbose_name='На какое сообщение ответ')),
],
options={
'verbose_name': 'Информация об ответе',
'verbose_name_plural': 'Информация об ответах',
'indexes': [models.Index(fields=['replied_to'], name='communicati_replied_a35389_idx'), models.Index(fields=['is_cross_chat_reply'], name='communicati_is_cros_be0eee_idx')],
},
),
]

View File

@ -0,0 +1,94 @@
# Generated by Django 5.2.4 on 2025-11-20 12:05
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('communications', '0011_chataccesspermission_crosschatmessage_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ChatMembership',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role', models.CharField(choices=[('owner', 'Владелец'), ('admin', 'Администратор'), ('moderator', 'Модератор'), ('member', 'Участник'), ('guest', 'Гость')], default='member', max_length=10, verbose_name='Роль')),
('joined_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата вступления')),
('is_active', models.BooleanField(default=True, help_text='False если вышел из чата', verbose_name='Активен')),
('left_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата выхода')),
('can_send_messages', models.BooleanField(default=True, verbose_name='Может отправлять сообщения')),
('can_add_members', models.BooleanField(default=False, verbose_name='Может добавлять участников')),
('can_remove_members', models.BooleanField(default=False, verbose_name='Может удалять участников')),
('can_pin_messages', models.BooleanField(default=False, verbose_name='Может закреплять сообщения')),
],
options={
'verbose_name': 'Участник чата',
'verbose_name_plural': 'Участники чатов',
},
),
migrations.AddField(
model_name='chatreadstate',
name='is_typing',
field=models.BooleanField(default=False, verbose_name='Набирает сообщение'),
),
migrations.AddField(
model_name='chatreadstate',
name='last_read_message',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='communications.message', verbose_name='Последнее прочитанное сообщение'),
),
migrations.AddField(
model_name='chatreadstate',
name='typing_updated_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Последнее обновление статуса набора'),
),
migrations.AddField(
model_name='chatreadstate',
name='unread_mentions_count',
field=models.IntegerField(default=0, verbose_name='Непрочитанных упоминаний'),
),
migrations.AddField(
model_name='chatreadstate',
name='unread_thread_replies_count',
field=models.IntegerField(default=0, verbose_name='Непрочитанных ответов в тредах'),
),
migrations.AddIndex(
model_name='chatreadstate',
index=models.Index(fields=['chat', 'user', 'is_typing'], name='communicati_chat_id_21af98_idx'),
),
migrations.AddField(
model_name='chatmembership',
name='chat',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='communications.chat', verbose_name='Чат'),
),
migrations.AddField(
model_name='chatmembership',
name='invited_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Кто пригласил'),
),
migrations.AddField(
model_name='chatmembership',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chat_memberships', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь'),
),
migrations.AddIndex(
model_name='chatmembership',
index=models.Index(fields=['chat', 'is_active'], name='communicati_chat_id_b9bdc1_idx'),
),
migrations.AddIndex(
model_name='chatmembership',
index=models.Index(fields=['user', 'is_active'], name='communicati_user_id_1365f8_idx'),
),
migrations.AddIndex(
model_name='chatmembership',
index=models.Index(fields=['chat', 'role'], name='communicati_chat_id_3bb35b_idx'),
),
migrations.AlterUniqueTogether(
name='chatmembership',
unique_together={('chat', 'user')},
),
]

View File

@ -21,6 +21,41 @@ class ChatReadState(models.Model):
)
last_read_at = models.DateTimeField(null=True, blank=True)
updated_at = models.DateTimeField(auto_now=True)
# НОВЫЕ ПОЛЯ для расширенной функциональности
# Последнее прочитанное сообщение (явная связь)
last_read_message = models.ForeignKey(
'Message',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='+',
verbose_name="Последнее прочитанное сообщение"
)
# Упоминания
unread_mentions_count = models.IntegerField(
default=0,
verbose_name="Непрочитанных упоминаний"
)
# Треды
unread_thread_replies_count = models.IntegerField(
default=0,
verbose_name="Непрочитанных ответов в тредах"
)
# Статус набора текста
is_typing = models.BooleanField(
default=False,
verbose_name="Набирает сообщение"
)
typing_updated_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="Последнее обновление статуса набора"
)
class Meta:
# заменяем устаревший unique_together на UniqueConstraint
@ -33,6 +68,7 @@ class ChatReadState(models.Model):
indexes = [
models.Index(fields=["chat", "user"]),
models.Index(fields=["chat", "last_read_at"]),
models.Index(fields=["chat", "user", "is_typing"]),
]
def __str__(self):
@ -42,13 +78,53 @@ class ChatReadState(models.Model):
class Chat(models.Model):
CHAT_TYPE_CHOICES = [
("private", "Личный"),
("group", "Групповой"),
("department", "Отдел"),
("channel", "Канал"),
("announcement", "Объявления"),
("global", "Глобальный"),
]
type = models.CharField(
max_length=16, choices=CHAT_TYPE_CHOICES, verbose_name="Тип чата", db_index=True
max_length=16,
choices=CHAT_TYPE_CHOICES,
verbose_name="Тип чата",
db_index=True
)
# Новые базовые поля
name = models.CharField(
max_length=255,
blank=True,
verbose_name="Название чата",
help_text="Для групповых/канальных чатов"
)
description = models.TextField(
blank=True,
verbose_name="Описание чата"
)
avatar = models.ImageField(
upload_to='chat_avatars/%Y/%m/',
null=True,
blank=True,
verbose_name="Аватар чата"
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='created_chats',
verbose_name="Создатель"
)
# Для гибкой настройки участников
include_all_employees = models.BooleanField(
default=False,
verbose_name="Включить всех сотрудников",
help_text="Для анонсов и общих чатов"
)
participants = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
@ -170,27 +246,607 @@ class Chat(models.Model):
if more > 0:
names += f" и ещё {more}"
return f"Личный чат: {names}"
if self.type == "group":
return self.name or "Групповой чат"
if self.type == "department":
return f"Чат отдела: {self.department or '—'}"
if self.type == "channel":
return self.name or "Канал"
if self.type == "announcement":
return self.name or "Объявления"
return "Глобальный чат"
class ChatUserSettings(models.Model):
"""Персональные настройки пользователя для чата"""
chat = models.ForeignKey(
Chat,
on_delete=models.CASCADE,
related_name='user_settings'
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='chat_settings'
)
# Закрепление
is_pinned = models.BooleanField(default=False, verbose_name="Закреплен")
pinned_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="Время закрепления"
)
pin_order = models.IntegerField(default=0, verbose_name="Порядок")
# Уведомления
notifications_enabled = models.BooleanField(
default=True,
verbose_name="Уведомления"
)
# Кастомное название
custom_name = models.CharField(
max_length=255,
blank=True,
verbose_name="Свое название"
)
# Скрыть чат
is_hidden = models.BooleanField(default=False, verbose_name="Скрыт")
class Meta:
verbose_name = "Настройки чата"
verbose_name_plural = "Настройки чатов"
unique_together = [('chat', 'user')]
ordering = ['-is_pinned', '-pinned_at']
indexes = [
models.Index(fields=['user', 'is_pinned']),
models.Index(fields=['chat', 'user']),
]
def __str__(self):
return f"{self.user} → {self.chat}"
class Message(models.Model):
chat = models.ForeignKey(
Chat, related_name="messages", on_delete=models.CASCADE, verbose_name="Чат"
Chat,
related_name="messages",
on_delete=models.CASCADE,
verbose_name="Чат"
)
author = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, verbose_name="Автор"
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
verbose_name="Автор"
)
content = models.TextField("Текст сообщения")
created_at = models.DateTimeField(
auto_now_add=True, verbose_name="Время отправки", db_index=True
auto_now_add=True,
verbose_name="Время отправки",
db_index=True
)
# НОВЫЕ ПОЛЯ
# Ответ на сообщение (упрощенная связь)
reply_to = models.ForeignKey(
'self',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='direct_replies',
help_text="Простая связь для быстрого доступа"
)
# Редактирование
is_edited = models.BooleanField(default=False)
edited_at = models.DateTimeField(null=True, blank=True)
edit_history = models.JSONField(
default=list,
blank=True,
help_text="История редактирования [{timestamp, old_content}, ...]"
)
# Удаление
is_deleted = models.BooleanField(default=False)
deleted_at = models.DateTimeField(null=True, blank=True)
deleted_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='+',
help_text="Кто удалил (может отличаться от автора)"
)
# Вложения
has_attachments = models.BooleanField(default=False)
# Системные сообщения
is_system = models.BooleanField(default=False)
system_type = models.CharField(max_length=50, blank=True)
system_metadata = models.JSONField(
default=dict,
blank=True,
help_text="Дополнительные данные для системных сообщений"
)
# Важные/закрепленные
is_pinned = models.BooleanField(default=False)
pinned_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='+'
)
pinned_at = models.DateTimeField(null=True, blank=True)
# Реакции
reactions = models.JSONField(
default=dict,
blank=True,
help_text="Формат: {'👍': [user_id1, user_id2], '❤️': [user_id3]}"
)
# Флаги для специальных типов сообщений
is_forwarded = models.BooleanField(default=False)
is_cross_chat = models.BooleanField(
default=False,
help_text="Сообщение отправлено не участником чата"
)
# Треды
thread_root = models.ForeignKey(
'self',
null=True,
blank=True,
on_delete=models.CASCADE,
related_name='thread_messages',
help_text="Корневое сообщение треда"
)
thread_reply_count = models.IntegerField(default=0)
class Meta:
ordering = ["created_at"]
verbose_name = "Сообщение"
verbose_name_plural = "Сообщения"
indexes = [
models.Index(fields=['chat', 'created_at']),
models.Index(fields=['chat', 'is_deleted', 'created_at']),
models.Index(fields=['author', 'created_at']),
models.Index(fields=['thread_root', 'created_at']),
models.Index(fields=['is_pinned', 'chat']),
]
def __str__(self):
return f"{self.author}: {self.content[:30]}"
class MessageAttachment(models.Model):
"""Вложения в сообщениях"""
FILE_TYPE_CHOICES = [
('image', 'Изображение'),
('video', 'Видео'),
('audio', 'Аудио'),
('file', 'Файл'),
]
message = models.ForeignKey(
Message,
on_delete=models.CASCADE,
related_name='attachments',
verbose_name="Сообщение"
)
file = models.FileField(
upload_to='chat_attachments/%Y/%m/%d/',
verbose_name="Файл"
)
file_type = models.CharField(
max_length=10,
choices=FILE_TYPE_CHOICES,
verbose_name="Тип файла"
)
file_name = models.CharField(
max_length=255,
verbose_name="Название файла"
)
file_size = models.BigIntegerField(
verbose_name="Размер файла (байты)"
)
mime_type = models.CharField(
max_length=100,
blank=True,
verbose_name="MIME тип"
)
uploaded_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Время загрузки"
)
thumbnail = models.ImageField(
upload_to='chat_attachments/thumbnails/%Y/%m/%d/',
null=True,
blank=True,
verbose_name="Миниатюра",
help_text="Для изображений и видео"
)
class Meta:
verbose_name = "Вложение"
verbose_name_plural = "Вложения"
ordering = ['uploaded_at']
indexes = [
models.Index(fields=['message', 'file_type']),
models.Index(fields=['uploaded_at']),
]
def __str__(self):
return f"{self.file_name} ({self.get_file_type_display()})"
class ForwardedMessage(models.Model):
"""Информация о пересылке сообщения"""
message = models.OneToOneField(
Message,
on_delete=models.CASCADE,
related_name='forward_info',
verbose_name="Пересланное сообщение"
)
original_message = models.ForeignKey(
Message,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='forwarded_copies',
verbose_name="Самое первое сообщение в цепочке"
)
immediate_source = models.ForeignKey(
Message,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='+',
verbose_name="Непосредственный источник пересылки",
help_text="Откуда переслали (может отличаться от original)"
)
original_chat = models.ForeignKey(
Chat,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='+',
verbose_name="Исходный чат"
)
original_author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='+',
verbose_name="Автор оригинального сообщения"
)
forwarded_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='forwarded_messages',
verbose_name="Кто переслал"
)
forwarded_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Время пересылки"
)
forward_count = models.IntegerField(
default=1,
verbose_name="Количество пересылок",
help_text="Сколько раз это сообщение пересылалось"
)
preserved_content = models.TextField(
blank=True,
verbose_name="Сохранённый текст",
help_text="На случай удаления оригинала"
)
preserved_author_name = models.CharField(
max_length=255,
blank=True,
verbose_name="Имя автора оригинала"
)
class Meta:
verbose_name = "Информация о пересылке"
verbose_name_plural = "Информация о пересылках"
indexes = [
models.Index(fields=['original_message']),
models.Index(fields=['forwarded_by', 'forwarded_at']),
]
def __str__(self):
return f"Пересылка: {self.message_id} от {self.forwarded_by}"
class MessageReply(models.Model):
"""Расширенная информация об ответе на сообщение"""
REPLY_TYPE_CHOICES = [
('inline', 'В потоке'),
('quote', 'С цитатой'),
('thread', 'В треде'),
]
message = models.OneToOneField(
Message,
on_delete=models.CASCADE,
related_name='reply_info',
verbose_name="Сообщение-ответ"
)
replied_to = models.ForeignKey(
Message,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='extended_replies',
verbose_name="На какое сообщение ответ"
)
is_cross_chat_reply = models.BooleanField(
default=False,
verbose_name="Ответ из другого чата"
)
original_chat = models.ForeignKey(
Chat,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='+',
verbose_name="Исходный чат",
help_text="Если ответ был из другого чата"
)
preserved_text = models.TextField(
blank=True,
verbose_name="Сохранённый текст оригинала"
)
preserved_author_name = models.CharField(
max_length=255,
blank=True,
verbose_name="Имя автора оригинала"
)
reply_type = models.CharField(
max_length=10,
choices=REPLY_TYPE_CHOICES,
default='inline',
verbose_name="Тип ответа"
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Время создания ответа"
)
class Meta:
verbose_name = "Информация об ответе"
verbose_name_plural = "Информация об ответах"
indexes = [
models.Index(fields=['replied_to']),
models.Index(fields=['is_cross_chat_reply']),
]
def __str__(self):
return f"Ответ: {self.message_id} → {self.replied_to_id}"
class ChatAccessPermission(models.Model):
"""Права доступа к чату для внешних пользователей"""
ACCESS_LEVEL_CHOICES = [
('read', 'Только чтение'),
('write', 'Чтение и отправка'),
('moderate', 'Модерация'),
]
chat = models.ForeignKey(
Chat,
on_delete=models.CASCADE,
related_name='access_permissions',
verbose_name="Чат"
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='chat_access_permissions',
verbose_name="Пользователь"
)
access_level = models.CharField(
max_length=10,
choices=ACCESS_LEVEL_CHOICES,
default='write',
verbose_name="Уровень доступа"
)
granted_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='+',
verbose_name="Кто предоставил доступ"
)
granted_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Когда предоставлен доступ"
)
expires_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="Срок действия",
help_text="Если не указано - бессрочно"
)
is_active = models.BooleanField(
default=True,
verbose_name="Активен"
)
class Meta:
verbose_name = "Право доступа к чату"
verbose_name_plural = "Права доступа к чатам"
unique_together = [('chat', 'user')]
indexes = [
models.Index(fields=['chat', 'is_active']),
models.Index(fields=['user', 'is_active']),
]
def __str__(self):
return f"{self.user} → {self.chat} ({self.get_access_level_display()})"
class CrossChatMessage(models.Model):
"""Сообщения, отправленные в чаты, где пользователь не является участником"""
STATUS_CHOICES = [
('pending', 'На модерации'),
('approved', 'Одобрено'),
('rejected', 'Отклонено'),
]
message = models.OneToOneField(
Message,
on_delete=models.CASCADE,
related_name='cross_chat_info',
verbose_name="Сообщение"
)
sender = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='sent_cross_chat_messages',
verbose_name="Отправитель"
)
target_chat = models.ForeignKey(
Chat,
on_delete=models.CASCADE,
related_name='cross_chat_messages',
verbose_name="Целевой чат"
)
status = models.CharField(
max_length=10,
choices=STATUS_CHOICES,
default='pending',
verbose_name="Статус"
)
requires_moderation = models.BooleanField(
default=True,
verbose_name="Требует модерации"
)
moderated_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='+',
verbose_name="Модератор"
)
moderated_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="Время модерации"
)
moderation_note = models.TextField(
blank=True,
verbose_name="Заметка модератора"
)
sent_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Время отправки"
)
class Meta:
verbose_name = "Кросс-чат сообщение"
verbose_name_plural = "Кросс-чат сообщения"
indexes = [
models.Index(fields=['target_chat', 'status']),
models.Index(fields=['sender', 'sent_at']),
models.Index(fields=['status', 'requires_moderation']),
]
def __str__(self):
return f"CrossChat: {self.sender} → {self.target_chat} ({self.status})"
class ChatMembership(models.Model):
"""Явное управление участниками чатов"""
ROLE_CHOICES = [
('owner', 'Владелец'),
('admin', 'Администратор'),
('moderator', 'Модератор'),
('member', 'Участник'),
('guest', 'Гость'),
]
chat = models.ForeignKey(
Chat,
on_delete=models.CASCADE,
related_name='memberships',
verbose_name="Чат"
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='chat_memberships',
verbose_name="Пользователь"
)
role = models.CharField(
max_length=10,
choices=ROLE_CHOICES,
default='member',
verbose_name="Роль"
)
joined_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Дата вступления"
)
invited_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='+',
verbose_name="Кто пригласил"
)
is_active = models.BooleanField(
default=True,
verbose_name="Активен",
help_text="False если вышел из чата"
)
left_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="Дата выхода"
)
can_send_messages = models.BooleanField(
default=True,
verbose_name="Может отправлять сообщения"
)
can_add_members = models.BooleanField(
default=False,
verbose_name="Может добавлять участников"
)
can_remove_members = models.BooleanField(
default=False,
verbose_name="Может удалять участников"
)
can_pin_messages = models.BooleanField(
default=False,
verbose_name="Может закреплять сообщения"
)
class Meta:
verbose_name = "Участник чата"
verbose_name_plural = "Участники чатов"
unique_together = [('chat', 'user')]
indexes = [
models.Index(fields=['chat', 'is_active']),
models.Index(fields=['user', 'is_active']),
models.Index(fields=['chat', 'role']),
]
def __str__(self):
return f"{self.user} в {self.chat} ({self.get_role_display()})"

View File

@ -0,0 +1,219 @@
"""
Signals для автоматической генерации уведомлений в модуле Communications.
Обрабатывает события:
- Новое сообщение в чате
- Упоминание (@username) в сообщении
- Ответ на сообщение
- Добавление в чат
"""
import re
from django.db.models.signals import post_save, m2m_changed
from django.dispatch import receiver
from django.contrib.auth import get_user_model
from .models import Message, Chat
from notifications.services import NotificationService
Employee = get_user_model()
@receiver(post_save, sender=Message)
def create_message_notifications(sender, instance, created, **kwargs):
"""
Создает уведомления при создании нового сообщения.
Обрабатывает:
1. Новое сообщение в чате (для всех участников кроме автора)
2. Упоминания (@username) в тексте
3. Ответ на сообщение (reply_to)
"""
if not created or instance.is_system or instance.is_deleted:
return
chat = instance.chat
author = instance.author
content = instance.content
# Получаем всех участников чата кроме автора
participants = chat.participants.exclude(id=author.id)
# 1. Обработка упоминаний (@username)
mentioned_users = extract_mentions(content)
mentioned_user_ids = []
if mentioned_users:
# Проверяем, что упомянутые пользователи есть в чате
for email in mentioned_users:
try:
user = Employee.objects.get(email=email)
if user in participants and user.id != author.id:
# Создаем уведомление об упоминании
NotificationService.create_notification(
recipient=user,
notification_type_code='chat_mention',
title=f'Вас упомянул {author.get_full_name() or author.username}',
message=truncate_message(content, 100),
content_object=instance,
action_url=f'/communications/chat/{chat.id}/?message={instance.id}',
metadata={
'chat_id': chat.id,
'chat_name': get_chat_name(chat),
'message_id': instance.id,
'author_id': author.id,
}
)
mentioned_user_ids.append(user.id)
except Employee.DoesNotExist:
continue
# 2. Обработка ответов на сообщения
if instance.reply_to and instance.reply_to.author_id != author.id:
original_author = instance.reply_to.author
# Создаем уведомление об ответе (если автор не упомянут явно)
if original_author.id not in mentioned_user_ids:
NotificationService.create_notification(
recipient=original_author,
notification_type_code='chat_reply',
title=f'{author.get_full_name() or author.username} ответил на ваше сообщение',
message=truncate_message(content, 100),
content_object=instance,
action_url=f'/communications/chat/{chat.id}/?message={instance.id}',
metadata={
'chat_id': chat.id,
'chat_name': get_chat_name(chat),
'message_id': instance.id,
'reply_to_id': instance.reply_to.id,
'author_id': author.id,
}
)
# 3. Обычное уведомление о новом сообщении
# Отправляем только тем, кто НЕ получил упоминание или ответ
excluded_ids = set(mentioned_user_ids)
if instance.reply_to and instance.reply_to.author_id != author.id:
excluded_ids.add(instance.reply_to.author_id)
# Определяем, кому отправлять обычные уведомления
# Для приватных чатов - всегда отправляем
# Для групповых - можно добавить логику (например, только онлайн или с настройками)
if chat.type == 'private':
recipients_for_new_message = participants.exclude(id__in=excluded_ids)
for recipient in recipients_for_new_message:
NotificationService.create_notification(
recipient=recipient,
notification_type_code='chat_new_message',
title=f'Новое сообщение от {author.get_full_name() or author.username}',
message=truncate_message(content, 100),
content_object=instance,
action_url=f'/communications/chat/{chat.id}/',
metadata={
'chat_id': chat.id,
'chat_name': get_chat_name(chat),
'message_id': instance.id,
'author_id': author.id,
}
)
elif chat.type in ['group', 'department', 'channel']:
# Для групповых чатов - отправляем уведомление только если есть упоминание или ответ
# Обычные сообщения не создают уведомления, чтобы не спамить
pass
@receiver(m2m_changed, sender=Chat.participants.through)
def create_chat_added_notifications(sender, instance, action, pk_set, **kwargs):
"""
Создает уведомления когда пользователя добавляют в чат.
"""
if action != 'post_add':
return
chat = instance
# Получаем информацию о том, кто добавил (если доступно)
# Это может быть последний изменивший или создатель чата
added_by = getattr(chat, 'created_by', None) or chat.participants.first()
# Создаем уведомления для новых участников
for user_id in pk_set:
try:
user = Employee.objects.get(id=user_id)
# Не отправляем уведомление тому, кто сам себя добавил
if added_by and user.id == added_by.id:
continue
NotificationService.create_notification(
recipient=user,
notification_type_code='chat_added_to_chat',
title=f'Вас добавили в чат',
message=f'Вы были добавлены в чат "{get_chat_name(chat)}"',
content_object=chat,
action_url=f'/communications/chat/{chat.id}/',
metadata={
'chat_id': chat.id,
'chat_type': chat.type,
'chat_name': get_chat_name(chat),
'added_by_id': added_by.id if added_by else None,
}
)
except Employee.DoesNotExist:
continue
# ===== Вспомогательные функции =====
def extract_mentions(text):
"""
Извлекает email'ы из упоминаний вида @email.
Returns:
list: Список email'ов без символа @
"""
if not text:
return []
# Паттерн: @ + email формат
pattern = r'@([\w.+-]+@[\w.-]+\.[\w]+)'
mentions = re.findall(pattern, text)
return list(set(mentions)) # Уникальные значения
def truncate_message(text, max_length=100):
"""
Обрезает текст сообщения до указанной длины.
"""
if not text:
return ''
if len(text) <= max_length:
return text
return text[:max_length - 3] + '...'
def get_chat_name(chat):
"""
Возвращает название чата в зависимости от типа.
"""
if chat.name:
return chat.name
if chat.type == 'global':
return 'Глобальный чат'
if chat.type == 'department' and chat.department:
return f'Чат отдела: {chat.department.name}'
if chat.type == 'private':
# Для приватного чата можно вернуть имена участников
participants = chat.participants.all()[:2]
if participants:
names = [p.get_full_name() or p.username for p in participants]
return ', '.join(names)
return 'Чат'

View File

@ -2,16 +2,100 @@
from django.urls import path
from . import views
from . import api_views, views
app_name = "communications"
urlpatterns = [
# UI Views
path("chats/", views.ChatListView.as_view(), name="chat_list"),
path("chats/<int:pk>/", views.ChatDetailView.as_view(), name="chat_detail"),
path("chats/start/<int:employee_pk>/", views.start_private_chat, name="start_private_chat"),
path(
"chats/start/<int:employee_pk>/",
views.start_private_chat,
name="start_private_chat"
),
path("chats/<int:pk>/mark-read/", views.chat_mark_read, name="chat_mark_read"),
path("start/private/<int:employee_pk>/", views.start_private_chat, name="start_private_chat"),
path(
"start/private/<int:employee_pk>/",
views.start_private_chat,
name="start_private_chat"
),
# API Endpoints
# Chat management
path("api/chat/create/", api_views.create_chat, name="api_create_chat"),
path(
"api/chat/<int:chat_id>/update/",
api_views.update_chat,
name="api_update_chat"
),
path(
"api/chat/<int:chat_id>/pin/",
api_views.pin_chat,
name="api_pin_chat"
),
path(
"api/chat/<int:chat_id>/notifications/",
api_views.set_chat_notifications,
name="api_chat_notifications"
),
# Message actions
path(
"api/message/<int:message_id>/edit/",
api_views.edit_message,
name="api_edit_message"
),
path(
"api/message/<int:message_id>/delete/",
api_views.delete_message,
name="api_delete_message"
),
path(
"api/message/<int:message_id>/react/",
api_views.add_reaction,
name="api_add_reaction"
),
path(
"api/message/<int:message_id>/unreact/",
api_views.remove_reaction,
name="api_remove_reaction"
),
path(
"api/message/<int:message_id>/pin/",
api_views.pin_message,
name="api_pin_message"
),
# Attachments
path(
"api/attachment/upload/",
api_views.upload_attachment,
name="api_upload_attachment"
),
# Forward & Reply
path(
"api/message/<int:message_id>/forward/",
api_views.forward_message,
name="api_forward_message"
),
path(
"api/message/<int:message_id>/reply/",
api_views.reply_to_message,
name="api_reply_message"
),
# Membership
path(
"api/chat/<int:chat_id>/member/add/",
api_views.add_member,
name="api_add_member"
),
path(
"api/chat/<int:chat_id>/member/remove/",
api_views.remove_member,
name="api_remove_member"
),
]

View File

@ -8,3 +8,4 @@ class DocumentsConfig(AppConfig):
def ready(self):
# импортируем сигналы, чтобы они подхватились
import documents.signals # noqa
import documents.notification_signals # noqa

View File

@ -0,0 +1,151 @@
"""
Signals для автоматической генерации уведомлений в модуле Documents.
Обрабатывает события:
- Новый документ на ознакомление
- Все сотрудники ознакомились
- Напоминание о необходимости ознакомления (через Celery task)
"""
from django.db.models.signals import post_save, m2m_changed
from django.dispatch import receiver
from django.contrib.auth import get_user_model
from .models import Document, DocumentAcknowledgement
from notifications.services import NotificationService
Employee = get_user_model()
@receiver(post_save, sender=Document)
def create_document_notification(sender, instance, created, **kwargs):
"""
Создает уведомления при создании нового документа.
Уведомления отправляются:
- Всем активным сотрудникам (если sent_to_all=True)
- Выбранным получателям (через m2m_changed signal)
"""
if not created:
return
document = instance
# Если документ отправляется всем - создаем уведомления сразу
if document.sent_to_all:
notify_all_employees(document)
@receiver(m2m_changed, sender=Document.recipients.through)
def notify_specific_recipients(sender, instance, action, pk_set, **kwargs):
"""
Уведомляет конкретных получателей при добавлении в список.
"""
if action != 'post_add':
return
document = instance
# Не отправляем индивидуальные уведомления если документ для всех
if document.sent_to_all:
return
# Уведомляем новых получателей
for user_id in pk_set:
try:
user = Employee.objects.get(id=user_id)
create_document_ready_notification(document, user)
except Employee.DoesNotExist:
continue
@receiver(post_save, sender=DocumentAcknowledgement)
def check_all_acknowledged(sender, instance, created, **kwargs):
"""
Проверяет, все ли получатели ознакомились с документом.
Если да - уведомляет загрузившего.
"""
if not created:
return
acknowledgement = instance
document = acknowledgement.document
# Определяем общее количество получателей
if document.sent_to_all:
total_recipients = Employee.objects.filter(is_active=True).count()
else:
total_recipients = document.recipients.count()
# Проверяем количество ознакомившихся
acknowledged_count = document.acknowledgements.count()
# Если все ознакомились - уведомляем загрузившего
if (acknowledged_count >= total_recipients and
total_recipients > 0 and
document.uploaded_by):
NotificationService.create_notification(
recipient=document.uploaded_by,
notification_type_code='document_signed_all',
title='Все ознакомились с документом',
message=(
f'Все сотрудники ознакомились с документом '
f'"{document.title}"'
),
content_object=document,
action_url=f'/documents/{document.id}/',
metadata={
'document_id': document.id,
'total_recipients': total_recipients,
'acknowledged_count': acknowledged_count,
}
)
# ===== Вспомогательные функции =====
def notify_all_employees(document):
"""
Отправляет уведомление о новом документе всем активным сотрудникам.
"""
active_employees = Employee.objects.filter(is_active=True)
# Исключаем загрузившего документ
if document.uploaded_by:
active_employees = active_employees.exclude(
id=document.uploaded_by.id
)
for employee in active_employees:
create_document_ready_notification(document, employee)
def create_document_ready_notification(document, recipient):
"""
Создает уведомление о новом документе для конкретного получателя.
"""
uploader_name = (
document.uploaded_by.get_full_name()
if document.uploaded_by
else 'Администратор'
)
NotificationService.create_notification(
recipient=recipient,
notification_type_code='document_ready',
title='Новый документ на ознакомление',
message=(
f'{uploader_name} загрузил документ "{document.title}". '
f'Требуется ознакомление.'
),
content_object=document,
action_url=f'/documents/{document.id}/',
metadata={
'document_id': document.id,
'uploaded_by_id': (
document.uploaded_by.id if document.uploaded_by else None
),
'sent_to_all': document.sent_to_all,
}
)

View File

@ -253,6 +253,7 @@ class DocumentView(LoginRequiredMixin, TemplateView):
QueryParams:
page (int, optional): Номер страницы (проксируется в API).
scope ('mine'|'all'): Область показа. Обычным пользователям всегда 'mine'.
ack_status ('acked'|'not_acked', optional): Фильтр по статусу ознакомления.
Returns:
dict: Контекст для шаблона.
@ -270,6 +271,7 @@ class DocumentView(LoginRequiredMixin, TemplateView):
raw_scope = (request.GET.get("scope") or "mine").lower()
scope = raw_scope if (raw_scope in {"mine", "all"} and can_manage) else "mine"
page = request.GET.get("page") or 1
ack_status = (request.GET.get("ack_status") or "").lower().strip()
# Загружаем список документов через API
resp = api.get("v1/documents/", params={"page": page})
@ -295,6 +297,12 @@ class DocumentView(LoginRequiredMixin, TemplateView):
else:
items = page_data.items
# Фильтруем по статусу ознакомления
if ack_status == "acked":
items = [it for it in items if it.get("is_acknowledged")]
elif ack_status == "not_acked":
items = [it for it in items if not it.get("is_acknowledged")]
# Множество ID ознакомленных документов
acked_ids: Set[int] = {
int(it.get("id"))
@ -311,6 +319,11 @@ class DocumentView(LoginRequiredMixin, TemplateView):
# API URL для фронта (если нужен JS) — сохраняем совместимость с шаблоном
api_document_list_url = reverse("api:v1:documents-list")
# Фильтры для передачи в шаблон
filters = {
"ack_status": ack_status if ack_status in {"acked", "not_acked"} else "",
}
ctx.update(
{
"documents": items,
@ -320,6 +333,7 @@ class DocumentView(LoginRequiredMixin, TemplateView):
"prev_url": page_data.prev_url,
"page": int(page) if str(page).isdigit() else page,
"scope": scope,
"filters": filters,
"can_manage_documents": can_manage,
"show_admin_controls": bool(can_manage and scope == "all"),
"scope_urls": scope_urls,

View File

@ -61,6 +61,31 @@ def _extract_items(payload: Any):
return [], 0, None, None
def _convert_api_url_to_frontend(api_url: str | None, frontend_path: str) -> str | None:
"""
Преобразует API URL пагинации в frontend URL.
Пример:
http://localhost:9000/api/v1/employees/?page=2&search=test
-> /employees/list/?page=2&search=test
"""
if not api_url:
return None
from urllib.parse import urlparse, parse_qs, urlencode
parsed = urlparse(api_url)
query_params = parse_qs(parsed.query)
# Преобразуем query_params обратно в строку (parse_qs возвращает списки)
clean_params = {k: v[0] if isinstance(v, list) and len(v) == 1 else v
for k, v in query_params.items()}
query_string = urlencode(clean_params, doseq=True)
return f"{frontend_path}?{query_string}" if query_string else frontend_path
return [], 0, None, None
def _extract_results(payload):
if isinstance(payload, dict):
return payload.get("results", payload.get("items", [])) or []
@ -177,6 +202,10 @@ def employee_list(request):
payload = resp.json or {}
items, count, next_url, prev_url = _extract_items(payload)
# Преобразуем API URLs в frontend URLs
next_url = _convert_api_url_to_frontend(next_url, request.path)
prev_url = _convert_api_url_to_frontend(prev_url, request.path)
# JSON-ветка для автокомплита
if request.GET.get("format") == "json":
@ -773,6 +802,10 @@ def department_list(request):
count = 0
next_url = prev_url = None
# Преобразуем API URLs в frontend URLs
next_url = _convert_api_url_to_frontend(next_url, request.path)
prev_url = _convert_api_url_to_frontend(prev_url, request.path)
context = {
"departments": items,
"count": count,

View File

@ -6,16 +6,20 @@ django_asgi_app = get_asgi_application()
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from communications.routing import websocket_urlpatterns
from communications.routing import websocket_urlpatterns as communications_ws
from notifications.routing import websocket_urlpatterns as notifications_ws
from eusrr_backend.channels_jwt import JWTAuthMiddleware
# Объединить все WebSocket маршруты
all_websocket_urlpatterns = communications_ws + notifications_ws
application = ProtocolTypeRouter({
"http": django_asgi_app,
"websocket": JWTAuthMiddleware(
AuthMiddlewareStack(
URLRouter(websocket_urlpatterns)
URLRouter(all_websocket_urlpatterns)
)
),
})

View File

@ -43,6 +43,7 @@ INSTALLED_APPS = [
"requests_app.apps.RequestsAppConfig",
"feed.apps.FeedConfig",
"communications.apps.CommunicationsConfig",
"notifications.apps.NotificationsConfig",
"search.apps.SearchConfig",
"bots",
"finance.apps.FinanceConfig",
@ -57,6 +58,7 @@ MIDDLEWARE = [
"simple_history.middleware.HistoryRequestMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"api.middleware.JWTRefreshMiddleware", # Автообновление JWT токенов
"eusrr_backend.middleware.AuthRequiredMiddleware",
"eusrr_backend.middleware.EmailVerificationMiddleware",
]
@ -230,6 +232,7 @@ CHANNEL_LAYERS = {
# -----------------------------------------------------------------------------
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.SessionAuthentication",
"rest_framework_simplejwt.authentication.JWTAuthentication"
],
"DEFAULT_PERMISSION_CLASSES": [
@ -250,6 +253,9 @@ SIMPLE_JWT = {
"LEEWAY": 30,
}
# Автообновление JWT токенов: за сколько минут до истечения обновлять
JWT_REFRESH_THRESHOLD_MINUTES = int(os.getenv("JWT_REFRESH_THRESHOLD_MIN", "5"))
API_BASE_URL = os.getenv("API_BASE_URL", "http://127.0.0.1:9000/api")
# -----------------------------------------------------------------------------

View File

@ -10,6 +10,7 @@ urlpatterns = [
path("requests/", include("requests_app.urls_front", namespace="requests")),
path("employees/", include("employees.urls_front", namespace="employees")),
path("communications/", include("communications.urls", namespace="communications")),
path("notifications/", include("notifications.urls", namespace="notifications")),
path("search/", include("search.urls", namespace="search")),
path("finance/", include("finance.urls", namespace="finance")),
path("api/", include(("api.urls", "api"), namespace="api")),

View File

@ -4,3 +4,7 @@ from django.apps import AppConfig
class FeedConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'feed'
def ready(self):
# Подключаем signals для уведомлений
import feed.notification_signals # noqa

View File

@ -0,0 +1,182 @@
"""
Signals для автоматической генерации уведомлений в модуле Feed.
Обрабатывает события:
- Новая публикация (для подписчиков/отдела/компании)
- Комментарий к публикации
- Реакция (лайк) на публикацию - через отдельный механизм
"""
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.contrib.auth import get_user_model
from .models import Post, Comment
from .constants import TYPE_COMPANY, TYPE_DEPARTMENT, TYPE_EMPLOYEE
from notifications.services import NotificationService
Employee = get_user_model()
@receiver(post_save, sender=Post)
def create_post_notification(sender, instance, created, **kwargs):
"""
Создает уведомления при создании новой публикации.
Уведомления отправляются в зависимости от типа:
- TYPE_COMPANY - всем сотрудникам
- TYPE_DEPARTMENT - сотрудникам отдела
- TYPE_EMPLOYEE - подписчикам автора (если реализовано)
"""
if not created:
return
post = instance
notify_new_post(post)
@receiver(post_save, sender=Comment)
def create_comment_notification(sender, instance, created, **kwargs):
"""
Создает уведомления при добавлении комментария к публикации.
"""
if not created:
return
comment = instance
post = comment.post
comment_author = comment.author
# Уведомляем автора публикации (если комментарий не от него)
if post.author.id != comment_author.id:
NotificationService.create_notification(
recipient=post.author,
notification_type_code='feed_post_comment',
title='Новый комментарий к вашей публикации',
message=(
f'{comment_author.get_full_name() or comment_author.username}'
f' прокомментировал: {truncate_text(comment.text, 80)}'
),
content_object=post,
action_url=f'/feed/post/{post.id}/',
metadata={
'post_id': post.id,
'post_title': post.title,
'comment_id': comment.id,
'author_id': comment_author.id,
}
)
# ===== Вспомогательные функции =====
def notify_new_post(post):
"""
Отправляет уведомления о новой публикации.
"""
recipients = get_post_recipients(post)
# Исключаем автора
recipients = [r for r in recipients if r.id != post.author.id]
# Определяем контекст публикации
if post.type == TYPE_COMPANY:
context = 'компании'
elif post.type == TYPE_DEPARTMENT and post.department:
context = f'отдела {post.department.name}'
else:
context = (
f'от {post.author.get_full_name() or post.author.username}'
)
for recipient in recipients:
NotificationService.create_notification(
recipient=recipient,
notification_type_code='feed_new_post',
title=f'Новая публикация {context}',
message=f'{post.title}',
content_object=post,
action_url=f'/feed/post/{post.id}/',
metadata={
'post_id': post.id,
'post_type': post.type,
'post_title': post.title,
'author_id': post.author.id,
'department_id': (
post.department.id if post.department else None
),
}
)
def get_post_recipients(post):
"""
Возвращает список получателей уведомлений для публикации.
- TYPE_COMPANY - все активные сотрудники
- TYPE_DEPARTMENT - сотрудники отдела
- TYPE_EMPLOYEE - подписчики (если реализовано)
"""
if post.type == TYPE_COMPANY:
# Новость компании - всем
return list(Employee.objects.filter(is_active=True))
elif post.type == TYPE_DEPARTMENT and post.department:
# Новость отдела - сотрудникам отдела
return list(post.department.employees.filter(is_active=True))
elif post.type == TYPE_EMPLOYEE:
# Личная публикация - подписчикам
# TODO: реализовать систему подписок
# Пока отправляем всем активным или никому
return []
return []
def truncate_text(text, max_length=100):
"""
Обрезает текст до указанной длины.
"""
if not text:
return ''
text = text.strip()
if len(text) <= max_length:
return text
return text[:max_length - 3] + '...'
# ===== Функция для уведомлений о лайках =====
# Используется во views при добавлении лайка
def notify_post_reaction(post, user):
"""
Создает уведомление о реакции (лайке) на публикацию.
Вызывается вручную из view, так как лайки не имеют отдельной модели.
Args:
post: Публикация, которую лайкнули
user: Пользователь, который поставил лайк
"""
# Уведомляем автора (если лайк не от него)
if post.author.id != user.id:
NotificationService.create_notification(
recipient=post.author,
notification_type_code='feed_post_reaction',
title='Ваша публикация понравилась',
message=(
f'{user.get_full_name() or user.username} '
f'понравилась публикация "{post.title}"'
),
content_object=post,
action_url=f'/feed/post/{post.id}/',
metadata={
'post_id': post.id,
'post_title': post.title,
'user_id': user.id,
'reaction_type': 'like',
}
)

View File

@ -28,10 +28,28 @@ def _api(request):
def _paged_or_list(resp: ApiResponse):
"""Извлекает results из пагинированного ответа или возвращает сырой список."""
data = resp.json or {}
return data.get("results", data)
def _extract_page_data(resp: ApiResponse):
"""Извлекает данные пагинации из ответа API.
Returns:
tuple: (items, count, next_url, prev_url)
"""
data = resp.json or {}
if isinstance(data, dict) and "results" in data:
return (
data["results"],
data.get("count", 0),
data.get("next"),
data.get("previous"),
)
return data if isinstance(data, list) else [], 0, None, None
# ---------- Локальный multipart-враппер поверх ApiClient (без его модификации) ----------
def _api_request_multipart(
client, method: str, path: str, *, data=None, files=None
@ -200,10 +218,47 @@ def feed_list(request):
Лента компании из API (?type=company).
Блок «новые сотрудники» — тоже из API.
"""
posts = _api_list_posts(request, type=TYPE_COMPANY)
# Получаем параметры пагинации
page = request.GET.get("page", 1)
# Запрашиваем посты с пагинацией
client = _api(request)
resp = client.get(API_POSTS, params={"type": TYPE_COMPANY, "page": page})
if not resp.ok:
messages.error(request, f"Не удалось получить публикации: {resp.status}")
posts, count, next_url, prev_url = [], 0, None, None
else:
posts, count, next_url, prev_url = _extract_page_data(resp)
# Преобразуем API URLs в frontend URLs
from urllib.parse import urlparse, parse_qs, urlencode
def convert_url(api_url):
if not api_url:
return None
parsed = urlparse(api_url)
query_params = parse_qs(parsed.query)
clean_params = {k: v[0] if isinstance(v, list) and len(v) == 1 else v
for k, v in query_params.items()}
query_string = urlencode(clean_params, doseq=True)
return f"{request.path}?{query_string}" if query_string else request.path
next_url = convert_url(next_url)
prev_url = convert_url(prev_url)
new_employees = _api_list_new_employees(request, limit=10)
return render(
request, "feed/feed_list.html", {"posts": posts, "new_employees": new_employees}
request,
"feed/feed_list.html",
{
"posts": posts,
"new_employees": new_employees,
"count": count,
"next_url": next_url,
"prev_url": prev_url,
},
)

View File

@ -0,0 +1,197 @@
#!/usr/bin/env python
"""
Скрипт для создания тестовых уведомлений в реальном времени.
Используйте этот скрипт для проверки работы системы уведомлений.
"""
import os
import django
import sys
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
django.setup()
from django.contrib.auth import get_user_model
from notifications.services import NotificationService
from communications.models import Chat, Message
from django.utils import timezone
Employee = get_user_model()
print("\n" + "="*70)
print(" ГЕНЕРАТОР ТЕСТОВЫХ УВЕДОМЛЕНИЙ")
print("="*70 + "\n")
# Получить первого активного пользователя
user = Employee.objects.filter(is_active=True).first()
if not user:
print("[ERROR] Нет активных пользователей в системе!")
sys.exit(1)
print(f"Получатель: {user.get_full_name()} ({user.email})\n")
# Меню
print("Выберите тип уведомления для генерации:")
print()
print("1. Новое сообщение в чате")
print("2. Новый документ")
print("3. Одобрена заявка")
print("4. Новое событие в календаре")
print("5. Новая публикация")
print("6. Системное уведомление")
print("7. Создать 5 разных уведомлений")
print("0. Выход")
print()
choice = input("Ваш выбор: ").strip()
if choice == '0':
print("Выход.")
sys.exit(0)
if choice == '1':
# Новое сообщение в чате
notification = NotificationService.create_notification(
recipient=user,
notification_type_code='chat_new_message',
title='Новое сообщение в чате "Общий"',
message='Иван Иванов: Всем привет! Напоминаю о собрании в 15:00',
action_url='/communications/chat/1/',
metadata={'chat_id': 1, 'message_id': 123}
)
print(f"[OK] Создано уведомление #{notification.id}: {notification.title}")
elif choice == '2':
# Новый документ
notification = NotificationService.create_notification(
recipient=user,
notification_type_code='document_ready',
title='Новый документ на ознакомление',
message='Администратор загрузил документ "Правила внутреннего распорядка 2025". Требуется ознакомление до 25.11.2025',
action_url='/documents/5/',
metadata={'document_id': 5, 'deadline': '2025-11-25'}
)
print(f"[OK] Создано уведомление #{notification.id}: {notification.title}")
elif choice == '3':
# Одобрена заявка
notification = NotificationService.create_notification(
recipient=user,
notification_type_code='request_approved',
title='Заявка одобрена',
message='Ваша заявка на отпуск с 01.12.2025 по 14.12.2025 одобрена руководителем Петр Петров',
action_url='/requests/10/',
metadata={'request_id': 10, 'approver_id': 2}
)
print(f"[OK] Создано уведомление #{notification.id}: {notification.title}")
elif choice == '4':
# Новое событие
notification = NotificationService.create_notification(
recipient=user,
notification_type_code='event_created',
title='Новое событие: Корпоратив',
message='Добавлено событие "Новогодний корпоратив" на 28.12.2025 18:00. Место: Ресторан "Прага"',
action_url='/calendar/events/15/',
metadata={'event_id': 15, 'event_date': '2025-12-28'}
)
print(f"[OK] Создано уведомление #{notification.id}: {notification.title}")
elif choice == '5':
# Новая публикация
notification = NotificationService.create_notification(
recipient=user,
notification_type_code='feed_new_post',
title='Новая публикация от CEO',
message='Алексей Сидоров опубликовал: "Подведение итогов года. Наши достижения и планы на 2026..."',
action_url='/feed/posts/42/',
metadata={'post_id': 42, 'author_id': 1}
)
print(f"[OK] Создано уведомление #{notification.id}: {notification.title}")
elif choice == '6':
# Системное
notification = NotificationService.create_notification(
recipient=user,
notification_type_code='system_announcement',
title='Системное обслуживание',
message='Планируется обслуживание системы 25.11.2025 с 02:00 до 04:00. Возможны кратковременные перерывы в работе.',
action_url='/announcements/3/',
metadata={'maintenance_date': '2025-11-25', 'duration_hours': 2}
)
print(f"[OK] Создано уведомление #{notification.id}: {notification.title}")
elif choice == '7':
# Создать несколько разных
notifications = []
# 1. Сообщение
n = NotificationService.create_notification(
recipient=user,
notification_type_code='chat_new_message',
title='Сообщение от Мария Смирнова',
message='Можете подойти в кабинет?',
action_url='/communications/chat/2/',
)
notifications.append(n)
# 2. Комментарий к заявке
n = NotificationService.create_notification(
recipient=user,
notification_type_code='request_comment',
title='Комментарий к заявке',
message='HR отдел добавил комментарий: "Необходимо приложить справку"',
action_url='/requests/8/',
)
notifications.append(n)
# 3. Упоминание
n = NotificationService.create_notification(
recipient=user,
notification_type_code='chat_mention',
title='Вас упомянул Олег Иванов',
message=f'@{user.email} посмотри пожалуйста этот документ',
action_url='/communications/chat/3/',
)
notifications.append(n)
# 4. Событие изменено
n = NotificationService.create_notification(
recipient=user,
notification_type_code='event_changed',
title='Событие изменено',
message='Время планерки перенесено с 14:00 на 15:30',
action_url='/calendar/events/20/',
)
notifications.append(n)
# 5. Комментарий к посту
n = NotificationService.create_notification(
recipient=user,
notification_type_code='feed_post_comment',
title='Комментарий к вашей публикации',
message='Анна Петрова прокомментировала ваш пост',
action_url='/feed/posts/30/',
)
notifications.append(n)
print(f"[OK] Создано {len(notifications)} уведомлений:")
for n in notifications:
print(f" - #{n.id}: {n.title}")
else:
print("[ERROR] Неверный выбор")
sys.exit(1)
print()
print("="*70)
print(" ГОТОВО! Проверьте уведомления в интерфейсе")
print("="*70)
print()
print("Откройте: http://localhost:9000/")
print("- Колокольчик в navbar должен показать новое уведомление")
print("- Должен прийти WebSocket с обновлением")
print("- Должно сработать браузерное уведомление (если разрешено)")
print("- Должен воспроизвестись звук (если включен)")
print()

View File

View File

@ -0,0 +1,278 @@
from django.contrib import admin
from django.utils.html import format_html
from .models import (
NotificationCategory,
NotificationType,
Notification,
UserNotificationSettings,
NotificationTemplate,
)
@admin.register(NotificationCategory)
class NotificationCategoryAdmin(admin.ModelAdmin):
list_display = ['name', 'code', 'icon_preview', 'color', 'order', 'is_active']
list_editable = ['order', 'is_active']
list_filter = ['is_active']
search_fields = ['name', 'code', 'description']
ordering = ['order', 'name']
def icon_preview(self, obj):
return format_html('<i class="{}" style="font-size: 1.2em;"></i>', obj.icon)
icon_preview.short_description = 'Иконка'
@admin.register(NotificationType)
class NotificationTypeAdmin(admin.ModelAdmin):
list_display = [
'name',
'code',
'category',
'priority',
'default_enabled',
'is_groupable',
'is_required',
'is_active',
]
list_filter = [
'category',
'priority',
'default_enabled',
'is_groupable',
'is_required',
'is_active',
]
search_fields = ['name', 'code', 'description']
list_editable = ['is_active']
ordering = ['category__order', 'name']
fieldsets = (
('Основная информация', {
'fields': ('category', 'code', 'name', 'description', 'is_active')
}),
('Настройки по умолчанию', {
'fields': ('default_enabled', 'default_channels', 'priority', 'is_required')
}),
('Группировка', {
'fields': ('is_groupable', 'grouping_window_minutes'),
'classes': ('collapse',)
}),
)
@admin.register(Notification)
class NotificationAdmin(admin.ModelAdmin):
list_display = [
'id',
'recipient_name',
'title_short',
'notification_type',
'is_read',
'created_at',
'channels_sent',
]
list_filter = [
'is_read',
'is_archived',
'notification_type__category',
'notification_type',
'created_at',
]
search_fields = [
'title',
'message',
'recipient__first_name',
'recipient__last_name',
'recipient__email',
]
readonly_fields = [
'created_at',
'updated_at',
'read_at',
'sent_at',
'archived_at',
]
date_hierarchy = 'created_at'
ordering = ['-created_at']
fieldsets = (
('Основная информация', {
'fields': ('recipient', 'notification_type', 'title', 'message', 'short_message')
}),
('Связанный объект', {
'fields': ('content_type', 'object_id'),
'classes': ('collapse',)
}),
('Действие', {
'fields': ('action_url', 'action_text')
}),
('Статус', {
'fields': ('is_read', 'read_at', 'is_archived', 'archived_at')
}),
('Доставка', {
'fields': (
'sent_web',
'sent_email',
'sent_telegram',
'sent_whatsapp',
'sent_wechat',
'sent_at',
),
'classes': ('collapse',)
}),
('Группировка', {
'fields': ('group_key', 'is_grouped', 'grouped_count'),
'classes': ('collapse',)
}),
('Метаданные', {
'fields': ('metadata', 'created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
def recipient_name(self, obj):
return obj.recipient.get_full_name() or obj.recipient.username
recipient_name.short_description = 'Получатель'
recipient_name.admin_order_field = 'recipient__last_name'
def title_short(self, obj):
return obj.title[:50] + '...' if len(obj.title) > 50 else obj.title
title_short.short_description = 'Заголовок'
def channels_sent(self, obj):
channels = []
if obj.sent_web:
channels.append('🌐 Веб')
if obj.sent_email:
channels.append('📧 Email')
if obj.sent_telegram:
channels.append('✈️ Telegram')
if obj.sent_whatsapp:
channels.append('💬 WhatsApp')
if obj.sent_wechat:
channels.append('💚 WeChat')
return ', '.join(channels) if channels else '—'
channels_sent.short_description = 'Отправлено'
actions = ['mark_as_read', 'mark_as_unread', 'archive_notifications']
def mark_as_read(self, request, queryset):
count = 0
for notification in queryset.filter(is_read=False):
notification.mark_as_read()
count += 1
self.message_user(request, f'Отмечено как прочитанное: {count}')
mark_as_read.short_description = 'Отметить как прочитанное'
def mark_as_unread(self, request, queryset):
count = queryset.filter(is_read=True).update(is_read=False, read_at=None)
self.message_user(request, f'Отмечено как непрочитанное: {count}')
mark_as_unread.short_description = 'Отметить как непрочитанное'
def archive_notifications(self, request, queryset):
count = 0
for notification in queryset.filter(is_archived=False):
notification.archive()
count += 1
self.message_user(request, f'Заархивировано: {count}')
archive_notifications.short_description = 'Архивировать'
@admin.register(UserNotificationSettings)
class UserNotificationSettingsAdmin(admin.ModelAdmin):
list_display = [
'user_name',
'notification_type',
'is_enabled',
'channels_enabled',
'quiet_hours_status',
]
list_filter = [
'is_enabled',
'notification_type__category',
'send_web',
'send_email',
'send_telegram',
'quiet_hours_enabled',
]
search_fields = [
'user__first_name',
'user__last_name',
'user__email',
'notification_type__name',
]
ordering = ['user__last_name', 'notification_type__category__order']
fieldsets = (
('Основная информация', {
'fields': ('user', 'notification_type', 'is_enabled')
}),
('Каналы доставки', {
'fields': ('send_web', 'send_email', 'send_telegram', 'send_whatsapp', 'send_wechat')
}),
('Тихий режим', {
'fields': ('quiet_hours_enabled', 'quiet_start_time', 'quiet_end_time'),
'classes': ('collapse',)
}),
)
def user_name(self, obj):
return obj.user.get_full_name() or obj.user.username
user_name.short_description = 'Пользователь'
user_name.admin_order_field = 'user__last_name'
def channels_enabled(self, obj):
channels = []
if obj.send_web:
channels.append('🌐')
if obj.send_email:
channels.append('📧')
if obj.send_telegram:
channels.append('✈️')
if obj.send_whatsapp:
channels.append('💬')
if obj.send_wechat:
channels.append('💚')
return ' '.join(channels) if channels else '—'
channels_enabled.short_description = 'Каналы'
def quiet_hours_status(self, obj):
if obj.quiet_hours_enabled and obj.quiet_start_time and obj.quiet_end_time:
return f'🌙 {obj.quiet_start_time.strftime("%H:%M")} - {obj.quiet_end_time.strftime("%H:%M")}'
return '—'
quiet_hours_status.short_description = 'Тихий режим'
@admin.register(NotificationTemplate)
class NotificationTemplateAdmin(admin.ModelAdmin):
list_display = [
'notification_type',
'channel',
'is_active',
'updated_at',
]
list_filter = [
'channel',
'is_active',
'notification_type__category',
]
search_fields = [
'notification_type__name',
'title_template',
'message_template',
]
ordering = ['notification_type__category__order', 'channel']
fieldsets = (
('Основная информация', {
'fields': ('notification_type', 'channel', 'is_active')
}),
('Шаблоны', {
'fields': ('title_template', 'message_template', 'action_button_template')
}),
('Email (дополнительно)', {
'fields': ('html_template',),
'classes': ('collapse',)
}),
)

View File

@ -0,0 +1,36 @@
from django.urls import path
from . import api_views
app_name = 'notifications_api'
urlpatterns = [
path('notifications/', api_views.get_notifications, name='list'),
path('notifications/count/', api_views.get_unread_count, name='count'),
path(
'notifications/<int:notification_id>/read/',
api_views.mark_as_read,
name='mark_read'
),
path(
'notifications/read-all/',
api_views.mark_all_as_read,
name='mark_all_read'
),
path(
'notifications/<int:notification_id>/',
api_views.delete_notification,
name='delete'
),
path('notifications/categories/', api_views.get_categories, name='categories'),
path('notifications/settings/', api_views.get_user_settings, name='settings_get'),
path(
'notifications/settings/update/',
api_views.update_user_settings,
name='settings_update'
),
path(
'notifications/settings/category/update/',
api_views.update_category_settings,
name='category_settings_update'
),
]

View File

@ -0,0 +1,300 @@
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
from django.shortcuts import get_object_or_404
from .models import Notification, NotificationCategory, NotificationType
from .services import NotificationService
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def get_notifications(request):
"""Получить список уведомлений с пагинацией и фильтрами"""
page = int(request.GET.get('page', 1))
page_size = int(request.GET.get('page_size', 20))
category = request.GET.get('category')
is_read = request.GET.get('is_read')
search = request.GET.get('search', '').strip()
unread_only = request.GET.get('unread_only', 'false').lower() == 'true'
queryset = Notification.objects.filter(
recipient=request.user,
is_archived=False
).select_related(
'notification_type',
'notification_type__category'
).order_by('-created_at')
# Фильтр по категории
if category:
queryset = queryset.filter(notification_type__category__code=category)
# Фильтр по статусу прочитанности
if is_read and is_read in ('true', 'false'):
queryset = queryset.filter(is_read=(is_read == 'true'))
elif unread_only:
queryset = queryset.filter(is_read=False)
# Поиск по заголовку и тексту
if search:
from django.db.models import Q
queryset = queryset.filter(
Q(title__icontains=search) |
Q(message__icontains=search) |
Q(short_message__icontains=search)
)
total = queryset.count()
start = (page - 1) * page_size
end = start + page_size
notifications = queryset[start:end]
data = {
'total': total,
'page': page,
'page_size': page_size,
'notifications': [
{
'id': n.id,
'title': n.title,
'message': n.message, # Полное сообщение для списка
'category': n.notification_type.category.code,
'category_name': n.notification_type.category.name,
'icon': n.notification_type.category.icon,
'color': n.notification_type.category.color,
'priority': n.notification_type.priority,
'is_read': n.is_read,
'action_url': n.action_url,
'action_text': n.action_text,
'created_at': n.created_at.isoformat(),
}
for n in notifications
]
}
return Response(data)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def get_unread_count(request):
"""Получить количество непрочитанных уведомлений"""
count = Notification.objects.filter(
recipient=request.user,
is_read=False,
is_archived=False
).count()
return Response({'count': count})
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def mark_as_read(request, notification_id):
"""Отметить уведомление как прочитанное"""
success = NotificationService.mark_as_read(notification_id, request.user)
if success:
return Response({'status': 'success'})
else:
return Response(
{'status': 'error', 'message': 'Notification not found'},
status=status.HTTP_404_NOT_FOUND
)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def mark_all_as_read(request):
"""Отметить все уведомления как прочитанные"""
category = request.data.get('category')
count = NotificationService.mark_all_as_read(request.user, category)
return Response({'status': 'success', 'count': count})
@api_view(['DELETE'])
@permission_classes([IsAuthenticated])
def delete_notification(request, notification_id):
"""Удалить уведомление (архивировать)"""
try:
notification = Notification.objects.get(
id=notification_id,
recipient=request.user
)
notification.archive()
return Response({'status': 'success'})
except Notification.DoesNotExist:
return Response(
{'status': 'error', 'message': 'Notification not found'},
status=status.HTTP_404_NOT_FOUND
)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def get_categories(request):
"""Получить список категорий уведомлений"""
categories = NotificationCategory.objects.filter(is_active=True)
data = [
{
'code': cat.code,
'name': cat.name,
'icon': cat.icon,
'color': cat.color,
}
for cat in categories
]
return Response(data)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def get_user_settings(request):
"""Получить настройки уведомлений пользователя по категориям"""
categories = NotificationCategory.objects.filter(is_active=True)
settings_by_category = {}
for category in categories:
# Получить типы уведомлений этой категории
types = NotificationType.objects.filter(
category=category,
is_active=True
)
if not types.exists():
continue
# Получить настройки первого типа категории как представитель
# (предполагается, что настройки одинаковы для всей категории)
first_type = types.first()
settings = NotificationService.get_user_settings(
request.user, first_type
)
settings_by_category[category.code] = {
'is_enabled': settings.is_enabled,
'web_enabled': settings.send_web,
'email_enabled': settings.send_email,
'telegram_enabled': settings.send_telegram,
}
return Response({
'settings': settings_by_category
})
@api_view(['PUT'])
@permission_classes([IsAuthenticated])
def update_user_settings(request):
"""Обновить настройки уведомлений пользователя"""
notification_type_code = request.data.get('notification_type_code')
try:
notification_type = NotificationType.objects.get(
code=notification_type_code
)
except NotificationType.DoesNotExist:
return Response(
{'status': 'error', 'message': 'Notification type not found'},
status=status.HTTP_404_NOT_FOUND
)
settings = NotificationService.get_user_settings(
request.user, notification_type
)
# Обновить настройки
if 'is_enabled' in request.data and not notification_type.is_required:
settings.is_enabled = request.data['is_enabled']
if 'send_web' in request.data:
settings.send_web = request.data['send_web']
if 'send_email' in request.data:
settings.send_email = request.data['send_email']
if 'send_telegram' in request.data:
settings.send_telegram = request.data['send_telegram']
if 'send_whatsapp' in request.data:
settings.send_whatsapp = request.data['send_whatsapp']
if 'send_wechat' in request.data:
settings.send_wechat = request.data['send_wechat']
if 'quiet_hours_enabled' in request.data:
settings.quiet_hours_enabled = request.data['quiet_hours_enabled']
if 'quiet_start_time' in request.data:
settings.quiet_start_time = request.data['quiet_start_time']
if 'quiet_end_time' in request.data:
settings.quiet_end_time = request.data['quiet_end_time']
settings.save()
return Response({'status': 'success'})
@api_view(['PUT'])
@permission_classes([IsAuthenticated])
def update_category_settings(request):
"""
Обновить настройки для всех уведомлений категории
"""
category_code = request.data.get('category')
if not category_code:
return Response(
{'status': 'error', 'message': 'Category is required'},
status=status.HTTP_400_BAD_REQUEST
)
try:
category = NotificationCategory.objects.get(code=category_code)
except NotificationCategory.DoesNotExist:
return Response(
{'status': 'error', 'message': 'Category not found'},
status=status.HTTP_404_NOT_FOUND
)
# Получить все типы уведомлений категории
notification_types = NotificationType.objects.filter(
category=category,
is_active=True
)
# Обновить настройки для каждого типа
updated_count = 0
for notification_type in notification_types:
settings = NotificationService.get_user_settings(
request.user, notification_type
)
# Обновить поля
if 'is_enabled' in request.data and not notification_type.is_required:
settings.is_enabled = request.data['is_enabled']
if 'web_enabled' in request.data:
settings.send_web = request.data['web_enabled']
if 'email_enabled' in request.data:
settings.send_email = request.data['email_enabled']
if 'telegram_enabled' in request.data:
settings.send_telegram = request.data['telegram_enabled']
settings.save()
updated_count += 1
return Response({
'status': 'success',
'updated_count': updated_count
})

View File

@ -0,0 +1,10 @@
from django.apps import AppConfig
class NotificationsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'notifications'
verbose_name = 'Уведомления'
def ready(self):
import notifications.signals # noqa

View File

@ -0,0 +1,119 @@
import json
import logging
from channels.generic.websocket import AsyncWebsocketConsumer
from channels.db import database_sync_to_async
from django.contrib.auth import get_user_model
User = get_user_model()
logger = logging.getLogger(__name__)
class NotificationConsumer(AsyncWebsocketConsumer):
"""WebSocket consumer для уведомлений в реальном времени"""
async def connect(self):
"""Подключение к WebSocket"""
self.user = self.scope['user']
if self.user.is_anonymous:
await self.close()
return
# Добавить в группу уведомлений пользователя
self.notification_group_name = f'notifications_{self.user.id}'
await self.channel_layer.group_add(
self.notification_group_name,
self.channel_name
)
await self.accept()
logger.info(f'NotificationConsumer connected: user={self.user.id}, group={self.notification_group_name}')
# Отправить текущее количество непрочитанных
unread_count = await self.get_unread_count()
await self.send(text_data=json.dumps({
'type': 'unread_count',
'count': unread_count
}))
async def disconnect(self, close_code):
"""Отключение от WebSocket"""
if hasattr(self, 'notification_group_name'):
await self.channel_layer.group_discard(
self.notification_group_name,
self.channel_name
)
async def receive(self, text_data):
"""Получение сообщения от клиента"""
try:
data = json.loads(text_data)
message_type = data.get('type')
if message_type == 'mark_read':
notification_id = data.get('notification_id')
if notification_id:
await self.mark_notification_as_read(notification_id)
elif message_type == 'mark_all_read':
await self.mark_all_notifications_as_read()
except json.JSONDecodeError:
await self.send(text_data=json.dumps({
'type': 'error',
'message': 'Invalid JSON'
}))
async def notification_new(self, event):
"""Отправить новое уведомление клиенту"""
logger.info(f'Sending new notification to user {self.user.id}: {event.get("notification", {}).get("title", "No title")}')
await self.send(text_data=json.dumps({
'type': 'new_notification',
'notification': event['notification']
}))
async def notification_count_update(self, event):
"""Обновить счетчик непрочитанных"""
await self.send(text_data=json.dumps({
'type': 'unread_count',
'count': event['count']
}))
@database_sync_to_async
def get_unread_count(self):
"""Получить количество непрочитанных уведомлений"""
from .models import Notification
return Notification.objects.filter(
recipient=self.user,
is_read=False,
is_archived=False
).count()
@database_sync_to_async
def mark_notification_as_read(self, notification_id):
"""Отметить уведомление как прочитанное"""
from .models import Notification
try:
notification = Notification.objects.get(
id=notification_id,
recipient=self.user
)
notification.mark_as_read()
return True
except Notification.DoesNotExist:
return False
@database_sync_to_async
def mark_all_notifications_as_read(self):
"""Отметить все уведомления как прочитанные"""
from .models import Notification
from django.utils import timezone
Notification.objects.filter(
recipient=self.user,
is_read=False
).update(
is_read=True,
read_at=timezone.now()
)

View File

@ -0,0 +1 @@
# Пустой файл для пакета

View File

@ -0,0 +1 @@
# Пустой файл для пакета

View File

@ -0,0 +1,561 @@
from django.core.management.base import BaseCommand
from notifications.models import NotificationCategory, NotificationType
class Command(BaseCommand):
help = 'Создать начальные типы уведомлений'
def handle(self, *args, **options):
self.stdout.write('Создание категорий и типов уведомлений...\n')
# Категории и их типы
categories_data = {
'communications': {
'name': 'Коммуникации',
'icon': 'bi-chat-dots',
'color': 'primary',
'order': 1,
'types': [
{
'code': 'chat_new_message',
'name': 'Новое сообщение в чате',
'description': 'Получено новое сообщение',
'priority': 'normal',
'default_channels': {
'web': True,
'email': False,
'telegram': True
},
'is_groupable': True,
'grouping_window_minutes': 5,
},
{
'code': 'chat_mention',
'name': 'Упоминание в сообщении',
'description': 'Вас упомянули в сообщении (@username)',
'priority': 'high',
'default_channels': {
'web': True,
'email': False,
'telegram': True
},
'is_groupable': False,
},
{
'code': 'chat_reply',
'name': 'Ответ на ваше сообщение',
'description': 'Кто-то ответил на ваше сообщение',
'priority': 'normal',
'default_channels': {
'web': True,
'email': False,
'telegram': True
},
'is_groupable': True,
},
{
'code': 'chat_added_to_chat',
'name': 'Добавление в чат',
'description': 'Вас добавили в новый чат/группу',
'priority': 'normal',
'default_channels': {
'web': True,
'email': False,
'telegram': False
},
'is_groupable': False,
},
],
},
'documents': {
'name': 'Документы',
'icon': 'bi-file-earmark-text',
'color': 'info',
'order': 2,
'types': [
{
'code': 'document_ready',
'name': 'Документ на ознакомление',
'description': 'Новый документ требует ознакомления',
'priority': 'high',
'default_channels': {
'web': True,
'email': True,
'telegram': True
},
'is_groupable': False,
},
{
'code': 'document_signed_all',
'name': 'Документ подписан всеми',
'description': 'Все участники ознакомились',
'priority': 'normal',
'default_channels': {
'web': True,
'email': False,
'telegram': False
},
'is_groupable': False,
},
{
'code': 'document_reminder',
'name': 'Напоминание об ознакомлении',
'description': 'Срок ознакомления истекает',
'priority': 'urgent',
'default_channels': {
'web': True,
'email': True,
'telegram': True
},
'is_groupable': False,
},
{
'code': 'document_comment',
'name': 'Комментарий к документу',
'description': 'Новый комментарий к документу',
'priority': 'normal',
'default_channels': {
'web': True,
'email': False,
'telegram': False
},
'is_groupable': True,
},
],
},
'requests': {
'name': 'Заявления',
'icon': 'bi-clipboard-check',
'color': 'success',
'order': 3,
'types': [
{
'code': 'request_new',
'name': 'Новая заявка',
'description': 'Поступила новая заявка на рассмотрение',
'priority': 'high',
'default_channels': {
'web': True,
'email': True,
'telegram': True
},
'is_groupable': True,
},
{
'code': 'request_approved',
'name': 'Заявка одобрена',
'description': 'Ваша заявка одобрена',
'priority': 'high',
'default_channels': {
'web': True,
'email': True,
'telegram': True
},
'is_groupable': False,
},
{
'code': 'request_rejected',
'name': 'Заявка отклонена',
'description': 'Ваша заявка отклонена',
'priority': 'high',
'default_channels': {
'web': True,
'email': True,
'telegram': True
},
'is_groupable': False,
},
{
'code': 'request_comment',
'name': 'Комментарий к заявке',
'description': 'Новый комментарий к вашей заявке',
'priority': 'normal',
'default_channels': {
'web': True,
'email': False,
'telegram': True
},
'is_groupable': True,
},
{
'code': 'request_status_changed',
'name': 'Изменение статуса заявки',
'description': 'Статус вашей заявки изменен',
'priority': 'normal',
'default_channels': {
'web': True,
'email': False,
'telegram': True
},
'is_groupable': False,
},
],
},
'calendar': {
'name': 'Календарь',
'icon': 'bi-calendar-event',
'color': 'warning',
'order': 4,
'types': [
{
'code': 'event_created',
'name': 'Новое событие',
'description': 'Добавлено новое событие в календарь',
'priority': 'normal',
'default_channels': {
'web': True,
'email': False,
'telegram': False
},
'is_groupable': True,
},
{
'code': 'event_reminder_hour',
'name': 'Напоминание за час',
'description': 'Событие начнется через час',
'priority': 'high',
'default_channels': {
'web': True,
'email': False,
'telegram': True
},
'is_groupable': False,
},
{
'code': 'event_reminder_day',
'name': 'Напоминание за день',
'description': 'Событие начнется завтра',
'priority': 'normal',
'default_channels': {
'web': True,
'email': True,
'telegram': False
},
'is_groupable': False,
},
{
'code': 'event_changed',
'name': 'Изменение события',
'description': 'Детали события изменены',
'priority': 'normal',
'default_channels': {
'web': True,
'email': False,
'telegram': True
},
'is_groupable': False,
},
{
'code': 'event_cancelled',
'name': 'Отмена события',
'description': 'Событие отменено',
'priority': 'high',
'default_channels': {
'web': True,
'email': True,
'telegram': True
},
'is_groupable': False,
},
],
},
'department': {
'name': 'Отдел',
'icon': 'bi-people',
'color': 'secondary',
'order': 5,
'types': [
{
'code': 'department_new_employee',
'name': 'Новый сотрудник в отделе',
'description': 'В ваш отдел добавлен новый сотрудник',
'priority': 'low',
'default_channels': {
'web': True,
'email': False,
'telegram': False
},
'is_groupable': True,
},
{
'code': 'department_employee_left',
'name': 'Сотрудник покинул отдел',
'description': 'Сотрудник больше не в вашем отделе',
'priority': 'low',
'default_channels': {
'web': True,
'email': False,
'telegram': False
},
'is_groupable': True,
},
{
'code': 'department_structure_changed',
'name': 'Изменение структуры отдела',
'description': 'Структура отдела была изменена',
'priority': 'normal',
'default_channels': {
'web': True,
'email': False,
'telegram': False
},
'is_groupable': False,
},
{
'code': 'department_new_head',
'name': 'Новый руководитель',
'description': 'Назначен новый руководитель отдела',
'priority': 'high',
'default_channels': {
'web': True,
'email': True,
'telegram': False
},
'is_groupable': False,
},
{
'code': 'department_announcement',
'name': 'Объявление для отдела',
'description': 'Важное объявление для вашего отдела',
'priority': 'high',
'default_channels': {
'web': True,
'email': True,
'telegram': True
},
'is_groupable': False,
},
],
},
'profile': {
'name': 'Профиль',
'icon': 'bi-person-circle',
'color': 'dark',
'order': 6,
'types': [
{
'code': 'profile_data_changed',
'name': 'Изменение данных профиля',
'description': 'Администратор изменил ваши данные',
'priority': 'normal',
'default_channels': {
'web': True,
'email': True,
'telegram': False
},
'is_groupable': False,
'is_required': True,
},
{
'code': 'profile_password_changed',
'name': 'Изменение пароля',
'description': 'Ваш пароль был изменен',
'priority': 'urgent',
'default_channels': {
'web': True,
'email': True,
'telegram': True
},
'is_groupable': False,
'is_required': True,
},
{
'code': 'profile_email_changed',
'name': 'Изменение email',
'description': 'Ваш email адрес был изменен',
'priority': 'urgent',
'default_channels': {
'web': True,
'email': True,
'telegram': True
},
'is_groupable': False,
'is_required': True,
},
{
'code': 'profile_messenger_linked',
'name': 'Привязка мессенджера',
'description': 'Мессенджер успешно привязан к профилю',
'priority': 'normal',
'default_channels': {
'web': True,
'email': False,
'telegram': False
},
'is_groupable': False,
},
{
'code': 'profile_new_login',
'name': 'Вход из нового места',
'description': 'Обнаружен вход из нового места/устройства',
'priority': 'high',
'default_channels': {
'web': True,
'email': True,
'telegram': True
},
'is_groupable': False,
'is_required': True,
},
],
},
'feed': {
'name': 'Новости',
'icon': 'bi-newspaper',
'color': 'danger',
'order': 7,
'types': [
{
'code': 'feed_new_post',
'name': 'Новая новость',
'description': 'Опубликована новая новость',
'priority': 'low',
'default_channels': {
'web': True,
'email': False,
'telegram': False
},
'is_groupable': True,
},
{
'code': 'feed_post_comment',
'name': 'Комментарий к новости',
'description': 'Новый комментарий к вашей новости',
'priority': 'normal',
'default_channels': {
'web': True,
'email': False,
'telegram': False
},
'is_groupable': True,
},
{
'code': 'feed_post_reaction',
'name': 'Реакция на новость',
'description': 'Кто-то отреагировал на вашу новость',
'priority': 'low',
'default_channels': {
'web': True,
'email': False,
'telegram': False
},
'is_groupable': True,
'grouping_window_minutes': 60,
},
],
},
'system': {
'name': 'Система',
'icon': 'bi-gear',
'color': 'light',
'order': 8,
'types': [
{
'code': 'system_maintenance',
'name': 'Технические работы',
'description': 'Запланированы технические работы',
'priority': 'urgent',
'default_channels': {
'web': True,
'email': True,
'telegram': True
},
'is_groupable': False,
'is_required': True,
},
{
'code': 'system_announcement',
'name': 'Важное объявление',
'description': 'Общее важное объявление',
'priority': 'high',
'default_channels': {
'web': True,
'email': True,
'telegram': True
},
'is_groupable': False,
'is_required': True,
},
{
'code': 'system_new_feature',
'name': 'Новый функционал',
'description': 'Добавлен новый функционал',
'priority': 'low',
'default_channels': {
'web': True,
'email': False,
'telegram': False
},
'is_groupable': False,
},
{
'code': 'system_policy_change',
'name': 'Изменение политик',
'description': 'Изменены политики безопасности',
'priority': 'high',
'default_channels': {
'web': True,
'email': True,
'telegram': False
},
'is_groupable': False,
'is_required': True,
},
],
},
}
# Создание категорий и типов
created_categories = 0
created_types = 0
for cat_code, cat_data in categories_data.items():
# Создать или обновить категорию
category, created = NotificationCategory.objects.update_or_create(
code=cat_code,
defaults={
'name': cat_data['name'],
'icon': cat_data['icon'],
'color': cat_data['color'],
'order': cat_data['order'],
}
)
if created:
created_categories += 1
self.stdout.write(
self.style.SUCCESS(f'✓ Создана категория: {category.name}')
)
# Создать типы для категории
for type_data in cat_data['types']:
notification_type, created = (
NotificationType.objects.update_or_create(
code=type_data['code'],
defaults={
'category': category,
'name': type_data['name'],
'description': type_data.get('description', ''),
'priority': type_data.get('priority', 'normal'),
'default_channels': type_data.get(
'default_channels', {'web': True}
),
'is_groupable': type_data.get('is_groupable', True),
'grouping_window_minutes': type_data.get(
'grouping_window_minutes', 5
),
'is_required': type_data.get('is_required', False),
}
)
)
if created:
created_types += 1
self.stdout.write(f' → Создан тип: {notification_type.name}')
self.stdout.write('\n')
self.stdout.write(
self.style.SUCCESS(
f'Успешно создано:\n'
f' Категорий: {created_categories}\n'
f' Типов: {created_types}\n'
)
)

View File

@ -0,0 +1,146 @@
# Generated by Django 5.2.4 on 2025-11-20 12:57
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='NotificationCategory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(choices=[('communications', 'Коммуникации'), ('documents', 'Документы'), ('requests', 'Заявления'), ('calendar', 'Календарь'), ('department', 'Отдел'), ('profile', 'Профиль'), ('feed', 'Новости'), ('system', 'Система')], max_length=50, unique=True, verbose_name='Код')),
('name', models.CharField(max_length=100, verbose_name='Название')),
('description', models.TextField(blank=True, verbose_name='Описание')),
('icon', models.CharField(default='bi-bell', help_text='Bootstrap icon класс', max_length=50, verbose_name='Иконка')),
('color', models.CharField(default='primary', help_text='Bootstrap цвет', max_length=20, verbose_name='Цвет')),
('is_active', models.BooleanField(default=True, verbose_name='Активна')),
('order', models.IntegerField(default=0, verbose_name='Порядок')),
],
options={
'verbose_name': 'Категория уведомлений',
'verbose_name_plural': 'Категории уведомлений',
'db_table': 'notifications_category',
'ordering': ['order', 'name'],
},
),
migrations.CreateModel(
name='NotificationType',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(help_text='Уникальный код типа, например: chat_new_message', max_length=100, unique=True, verbose_name='Код')),
('name', models.CharField(max_length=200, verbose_name='Название')),
('description', models.TextField(blank=True, verbose_name='Описание')),
('default_enabled', models.BooleanField(default=True, verbose_name='Включено по умолчанию')),
('default_channels', models.JSONField(default=dict, help_text='{"web": true, "email": false, "telegram": false}', verbose_name='Каналы по умолчанию')),
('priority', models.CharField(choices=[('low', 'Низкий'), ('normal', 'Обычный'), ('high', 'Высокий'), ('urgent', 'Срочный')], default='normal', max_length=20, verbose_name='Приоритет')),
('is_required', models.BooleanField(default=False, help_text='Нельзя отключить (например, системные уведомления)', verbose_name='Обязательное')),
('is_groupable', models.BooleanField(default=True, help_text='Объединять несколько похожих уведомлений', verbose_name='Можно группировать')),
('grouping_window_minutes', models.IntegerField(default=5, help_text='Группировать уведомления за последние N минут', verbose_name='Окно группировки (минуты)')),
('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='Обновлен')),
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='types', to='notifications.notificationcategory', verbose_name='Категория')),
],
options={
'verbose_name': 'Тип уведомления',
'verbose_name_plural': 'Типы уведомлений',
'db_table': 'notifications_type',
'ordering': ['category__order', 'name'],
},
),
migrations.CreateModel(
name='NotificationTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('channel', models.CharField(choices=[('web', 'Веб'), ('email', 'Email'), ('telegram', 'Telegram'), ('whatsapp', 'WhatsApp'), ('wechat', 'WeChat')], max_length=20, verbose_name='Канал')),
('title_template', models.TextField(help_text='Django template синтаксис', verbose_name='Шаблон заголовка')),
('message_template', models.TextField(help_text='Django template синтаксис', verbose_name='Шаблон сообщения')),
('html_template', models.TextField(blank=True, help_text='Для email', verbose_name='HTML шаблон')),
('action_button_template', models.CharField(blank=True, max_length=200, verbose_name='Шаблон кнопки действия')),
('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='Обновлен')),
('notification_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='templates', to='notifications.notificationtype', verbose_name='Тип уведомления')),
],
options={
'verbose_name': 'Шаблон уведомления',
'verbose_name_plural': 'Шаблоны уведомлений',
'db_table': 'notifications_template',
'unique_together': {('notification_type', 'channel')},
},
),
migrations.CreateModel(
name='Notification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255, verbose_name='Заголовок')),
('message', models.TextField(verbose_name='Сообщение')),
('short_message', models.CharField(blank=True, help_text='Для превью', max_length=150, verbose_name='Краткое сообщение')),
('object_id', models.PositiveIntegerField(blank=True, null=True, verbose_name='ID объекта')),
('metadata', models.JSONField(blank=True, default=dict, help_text='Дополнительные данные для рендеринга', verbose_name='Метаданные')),
('action_url', models.CharField(blank=True, max_length=500, verbose_name='Ссылка действия')),
('action_text', models.CharField(default='Посмотреть', max_length=100, verbose_name='Текст кнопки')),
('is_read', models.BooleanField(db_index=True, default=False, verbose_name='Прочитано')),
('read_at', models.DateTimeField(blank=True, null=True, verbose_name='Прочитано когда')),
('is_archived', models.BooleanField(default=False, verbose_name='В архиве')),
('archived_at', models.DateTimeField(blank=True, null=True, verbose_name='Заархивировано когда')),
('sent_web', models.BooleanField(default=False, verbose_name='Отправлено на сайт')),
('sent_email', models.BooleanField(default=False, verbose_name='Отправлено на email')),
('sent_telegram', models.BooleanField(default=False, verbose_name='Отправлено в Telegram')),
('sent_whatsapp', models.BooleanField(default=False, verbose_name='Отправлено в WhatsApp')),
('sent_wechat', models.BooleanField(default=False, verbose_name='Отправлено в WeChat')),
('sent_at', models.DateTimeField(blank=True, null=True, verbose_name='Отправлено когда')),
('group_key', models.CharField(blank=True, db_index=True, help_text='Для объединения похожих уведомлений', max_length=255, verbose_name='Ключ группировки')),
('is_grouped', models.BooleanField(default=False, verbose_name='Сгруппировано')),
('grouped_count', models.IntegerField(default=1, help_text='Сколько уведомлений объединено', verbose_name='Количество в группе')),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Создано')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Тип объекта')),
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL, verbose_name='Получатель')),
('notification_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='notifications.notificationtype', verbose_name='Тип')),
],
options={
'verbose_name': 'Уведомление',
'verbose_name_plural': 'Уведомления',
'db_table': 'notifications_notification',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['recipient', '-created_at'], name='notificatio_recipie_a972ce_idx'), models.Index(fields=['recipient', 'is_read'], name='notificatio_recipie_4e3567_idx'), models.Index(fields=['notification_type', '-created_at'], name='notificatio_notific_c4ec61_idx'), models.Index(fields=['group_key', '-created_at'], name='notificatio_group_k_c495c5_idx')],
},
),
migrations.CreateModel(
name='UserNotificationSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_enabled', models.BooleanField(default=True, verbose_name='Включено')),
('send_web', models.BooleanField(default=True, verbose_name='Отправлять на сайт')),
('send_email', models.BooleanField(default=False, verbose_name='Отправлять на email')),
('send_telegram', models.BooleanField(default=False, verbose_name='Отправлять в Telegram')),
('send_whatsapp', models.BooleanField(default=False, verbose_name='Отправлять в WhatsApp')),
('send_wechat', models.BooleanField(default=False, verbose_name='Отправлять в WeChat')),
('quiet_hours_enabled', models.BooleanField(default=False, verbose_name='Тихий режим включен')),
('quiet_start_time', models.TimeField(blank=True, help_text='Например, 22:00', null=True, verbose_name='Начало тихого режима')),
('quiet_end_time', models.TimeField(blank=True, help_text='Например, 08:00', null=True, verbose_name='Конец тихого режима')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
('notification_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='notifications.notificationtype', verbose_name='Тип уведомления')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_settings', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
],
options={
'verbose_name': 'Настройка уведомлений пользователя',
'verbose_name_plural': 'Настройки уведомлений пользователей',
'db_table': 'notifications_user_settings',
'unique_together': {('user', 'notification_type')},
},
),
]

View File

@ -0,0 +1,414 @@
from django.db import models
from django.conf import settings
from django.utils import timezone
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
class NotificationCategory(models.Model):
"""Категории уведомлений для группировки"""
CATEGORY_CHOICES = [
('communications', 'Коммуникации'),
('documents', 'Документы'),
('requests', 'Заявления'),
('calendar', 'Календарь'),
('department', 'Отдел'),
('profile', 'Профиль'),
('feed', 'Новости'),
('system', 'Система'),
]
code = models.CharField(
max_length=50,
unique=True,
choices=CATEGORY_CHOICES,
verbose_name='Код'
)
name = models.CharField(max_length=100, verbose_name='Название')
description = models.TextField(blank=True, verbose_name='Описание')
icon = models.CharField(
max_length=50,
default='bi-bell',
verbose_name='Иконка',
help_text='Bootstrap icon класс'
)
color = models.CharField(
max_length=20,
default='primary',
verbose_name='Цвет',
help_text='Bootstrap цвет'
)
is_active = models.BooleanField(default=True, verbose_name='Активна')
order = models.IntegerField(default=0, verbose_name='Порядок')
class Meta:
ordering = ['order', 'name']
verbose_name = 'Категория уведомлений'
verbose_name_plural = 'Категории уведомлений'
db_table = 'notifications_category'
def __str__(self):
return self.name
class NotificationType(models.Model):
"""Конкретные типы уведомлений внутри категорий"""
category = models.ForeignKey(
NotificationCategory,
on_delete=models.CASCADE,
related_name='types',
verbose_name='Категория'
)
code = models.CharField(
max_length=100,
unique=True,
verbose_name='Код',
help_text='Уникальный код типа, например: chat_new_message'
)
name = models.CharField(max_length=200, verbose_name='Название')
description = models.TextField(blank=True, verbose_name='Описание')
# Настройки по умолчанию для всех пользователей
default_enabled = models.BooleanField(
default=True,
verbose_name='Включено по умолчанию'
)
default_channels = models.JSONField(
default=dict,
verbose_name='Каналы по умолчанию',
help_text='{"web": true, "email": false, "telegram": false}'
)
# Приоритет
PRIORITY_CHOICES = [
('low', 'Низкий'),
('normal', 'Обычный'),
('high', 'Высокий'),
('urgent', 'Срочный'),
]
priority = models.CharField(
max_length=20,
choices=PRIORITY_CHOICES,
default='normal',
verbose_name='Приоритет'
)
# Можно ли отключить
is_required = models.BooleanField(
default=False,
verbose_name='Обязательное',
help_text='Нельзя отключить (например, системные уведомления)'
)
# Группировка
is_groupable = models.BooleanField(
default=True,
verbose_name='Можно группировать',
help_text='Объединять несколько похожих уведомлений'
)
grouping_window_minutes = models.IntegerField(
default=5,
verbose_name='Окно группировки (минуты)',
help_text='Группировать уведомления за последние N минут'
)
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='Обновлен')
class Meta:
ordering = ['category__order', 'name']
verbose_name = 'Тип уведомления'
verbose_name_plural = 'Типы уведомлений'
db_table = 'notifications_type'
def __str__(self):
return f'{self.category.name}: {self.name}'
class Notification(models.Model):
"""Конкретное уведомление для пользователя"""
recipient = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='notifications',
verbose_name='Получатель'
)
notification_type = models.ForeignKey(
NotificationType,
on_delete=models.CASCADE,
verbose_name='Тип'
)
# Заголовок и текст
title = models.CharField(max_length=255, verbose_name='Заголовок')
message = models.TextField(verbose_name='Сообщение')
short_message = models.CharField(
max_length=150,
blank=True,
verbose_name='Краткое сообщение',
help_text='Для превью'
)
# Связь с объектом (GenericForeignKey)
content_type = models.ForeignKey(
ContentType,
on_delete=models.CASCADE,
null=True,
blank=True,
verbose_name='Тип объекта'
)
object_id = models.PositiveIntegerField(
null=True,
blank=True,
verbose_name='ID объекта'
)
content_object = GenericForeignKey('content_type', 'object_id')
# Метаданные
metadata = models.JSONField(
default=dict,
blank=True,
verbose_name='Метаданные',
help_text='Дополнительные данные для рендеринга'
)
# Ссылка действия
action_url = models.CharField(
max_length=500,
blank=True,
verbose_name='Ссылка действия'
)
action_text = models.CharField(
max_length=100,
default='Посмотреть',
verbose_name='Текст кнопки'
)
# Статусы
is_read = models.BooleanField(
default=False,
verbose_name='Прочитано',
db_index=True
)
read_at = models.DateTimeField(
null=True,
blank=True,
verbose_name='Прочитано когда'
)
is_archived = models.BooleanField(
default=False,
verbose_name='В архиве'
)
archived_at = models.DateTimeField(
null=True,
blank=True,
verbose_name='Заархивировано когда'
)
# Доставка
sent_web = models.BooleanField(default=False, verbose_name='Отправлено на сайт')
sent_email = models.BooleanField(default=False, verbose_name='Отправлено на email')
sent_telegram = models.BooleanField(default=False, verbose_name='Отправлено в Telegram')
sent_whatsapp = models.BooleanField(default=False, verbose_name='Отправлено в WhatsApp')
sent_wechat = models.BooleanField(default=False, verbose_name='Отправлено в WeChat')
sent_at = models.DateTimeField(
null=True,
blank=True,
verbose_name='Отправлено когда'
)
# Группировка
group_key = models.CharField(
max_length=255,
blank=True,
db_index=True,
verbose_name='Ключ группировки',
help_text='Для объединения похожих уведомлений'
)
is_grouped = models.BooleanField(
default=False,
verbose_name='Сгруппировано'
)
grouped_count = models.IntegerField(
default=1,
verbose_name='Количество в группе',
help_text='Сколько уведомлений объединено'
)
created_at = models.DateTimeField(
auto_now_add=True,
db_index=True,
verbose_name='Создано'
)
updated_at = models.DateTimeField(auto_now=True, verbose_name='Обновлено')
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['recipient', '-created_at']),
models.Index(fields=['recipient', 'is_read']),
models.Index(fields=['notification_type', '-created_at']),
models.Index(fields=['group_key', '-created_at']),
]
verbose_name = 'Уведомление'
verbose_name_plural = 'Уведомления'
db_table = 'notifications_notification'
def __str__(self):
return f'{self.recipient.get_short_name()}: {self.title}'
def mark_as_read(self):
"""Отметить как прочитанное"""
if not self.is_read:
self.is_read = True
self.read_at = timezone.now()
self.save(update_fields=['is_read', 'read_at'])
def archive(self):
"""Заархивировать уведомление"""
if not self.is_archived:
self.is_archived = True
self.archived_at = timezone.now()
self.save(update_fields=['is_archived', 'archived_at'])
class UserNotificationSettings(models.Model):
"""Персональные настройки уведомлений пользователя"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='notification_settings',
verbose_name='Пользователь'
)
notification_type = models.ForeignKey(
NotificationType,
on_delete=models.CASCADE,
verbose_name='Тип уведомления'
)
# Включено ли уведомление
is_enabled = models.BooleanField(
default=True,
verbose_name='Включено'
)
# Каналы доставки
send_web = models.BooleanField(
default=True,
verbose_name='Отправлять на сайт'
)
send_email = models.BooleanField(
default=False,
verbose_name='Отправлять на email'
)
send_telegram = models.BooleanField(
default=False,
verbose_name='Отправлять в Telegram'
)
send_whatsapp = models.BooleanField(
default=False,
verbose_name='Отправлять в WhatsApp'
)
send_wechat = models.BooleanField(
default=False,
verbose_name='Отправлять в WeChat'
)
# Тихий режим (не беспокоить)
quiet_hours_enabled = models.BooleanField(
default=False,
verbose_name='Тихий режим включен'
)
quiet_start_time = models.TimeField(
null=True,
blank=True,
verbose_name='Начало тихого режима',
help_text='Например, 22:00'
)
quiet_end_time = models.TimeField(
null=True,
blank=True,
verbose_name='Конец тихого режима',
help_text='Например, 08:00'
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name='Создано')
updated_at = models.DateTimeField(auto_now=True, verbose_name='Обновлено')
class Meta:
unique_together = ['user', 'notification_type']
verbose_name = 'Настройка уведомлений пользователя'
verbose_name_plural = 'Настройки уведомлений пользователей'
db_table = 'notifications_user_settings'
def __str__(self):
return f'{self.user.get_short_name()}: {self.notification_type.name}'
class NotificationTemplate(models.Model):
"""Шаблоны для разных каналов доставки"""
notification_type = models.ForeignKey(
NotificationType,
on_delete=models.CASCADE,
related_name='templates',
verbose_name='Тип уведомления'
)
CHANNEL_CHOICES = [
('web', 'Веб'),
('email', 'Email'),
('telegram', 'Telegram'),
('whatsapp', 'WhatsApp'),
('wechat', 'WeChat'),
]
channel = models.CharField(
max_length=20,
choices=CHANNEL_CHOICES,
verbose_name='Канал'
)
# Шаблоны (используем Django template syntax)
title_template = models.TextField(
verbose_name='Шаблон заголовка',
help_text='Django template синтаксис'
)
message_template = models.TextField(
verbose_name='Шаблон сообщения',
help_text='Django template синтаксис'
)
# Для email
html_template = models.TextField(
blank=True,
verbose_name='HTML шаблон',
help_text='Для email'
)
# Для кнопок действий
action_button_template = models.CharField(
max_length=200,
blank=True,
verbose_name='Шаблон кнопки действия'
)
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='Обновлен')
class Meta:
unique_together = ['notification_type', 'channel']
verbose_name = 'Шаблон уведомления'
verbose_name_plural = 'Шаблоны уведомлений'
db_table = 'notifications_template'
def __str__(self):
return f'{self.notification_type.name} - {self.get_channel_display()}'

View File

@ -0,0 +1,6 @@
from django.urls import re_path
from . import consumers
websocket_urlpatterns = [
re_path(r'^ws/notifications/$', consumers.NotificationConsumer.as_asgi()),
]

View File

@ -0,0 +1,262 @@
"""
Сервисный слой для работы с уведомлениями
"""
import json
from typing import Any, Dict, Optional
from django.contrib.auth import get_user_model
from django.utils import timezone
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync
from .models import (
Notification,
NotificationType,
UserNotificationSettings,
)
User = get_user_model()
class NotificationService:
"""Сервис для создания и отправки уведомлений"""
@staticmethod
def create_notification(
recipient: User,
notification_type_code: str,
title: str,
message: str,
content_object=None,
action_url: str = '',
action_text: str = 'Посмотреть',
metadata: Optional[Dict[str, Any]] = None,
send_immediately: bool = True,
) -> Optional[Notification]:
"""
Создать и отправить уведомление
Args:
recipient: Получатель уведомления
notification_type_code: Код типа уведомления
title: Заголовок
message: Текст сообщения
content_object: Связанный объект (опционально)
action_url: URL для действия
action_text: Текст кнопки действия
metadata: Дополнительные данные
send_immediately: Отправить сразу или отложить
Returns:
Созданное уведомление или None если тип не найден
"""
try:
notification_type = NotificationType.objects.get(
code=notification_type_code,
is_active=True
)
except NotificationType.DoesNotExist:
return None
# Проверить настройки пользователя
settings = NotificationService.get_user_settings(
recipient, notification_type
)
if not settings.is_enabled:
return None
# Создать краткое сообщение
short_message = message[:150] if len(message) > 150 else message
# Создать уведомление
notification = Notification.objects.create(
recipient=recipient,
notification_type=notification_type,
title=title,
message=message,
short_message=short_message,
content_object=content_object,
action_url=action_url,
action_text=action_text,
metadata=metadata or {},
)
# Отправить если нужно
if send_immediately:
NotificationService.send_notification(notification, settings)
return notification
@staticmethod
def send_notification(
notification: Notification,
settings: Optional[UserNotificationSettings] = None
):
"""Отправить уведомление по всем каналам"""
if settings is None:
settings = NotificationService.get_user_settings(
notification.recipient,
notification.notification_type
)
# Отправить на веб (WebSocket)
if settings.send_web:
NotificationService.send_web_notification(notification)
notification.sent_web = True
# Отправить на email (будет реализовано позже)
if settings.send_email:
# TODO: Реализовать email отправку
notification.sent_email = False
# Отправить в Telegram (будет реализовано позже)
if settings.send_telegram:
# TODO: Реализовать Telegram отправку
notification.sent_telegram = False
# Обновить время отправки
notification.sent_at = timezone.now()
notification.save(update_fields=[
'sent_web',
'sent_email',
'sent_telegram',
'sent_whatsapp',
'sent_wechat',
'sent_at'
])
@staticmethod
def send_web_notification(notification: Notification):
"""Отправить уведомление через WebSocket"""
channel_layer = get_channel_layer()
notification_data = {
'id': notification.id,
'title': notification.title,
'message': notification.short_message,
'category': notification.notification_type.category.code,
'category_name': notification.notification_type.category.name,
'icon': notification.notification_type.category.icon,
'color': notification.notification_type.category.color,
'priority': notification.notification_type.priority,
'action_url': notification.action_url,
'action_text': notification.action_text,
'created_at': notification.created_at.isoformat(),
}
async_to_sync(channel_layer.group_send)(
f'notifications_{notification.recipient.id}',
{
'type': 'notification_new',
'notification': notification_data
}
)
# Обновить счетчик
unread_count = Notification.objects.filter(
recipient=notification.recipient,
is_read=False,
is_archived=False
).count()
async_to_sync(channel_layer.group_send)(
f'notifications_{notification.recipient.id}',
{
'type': 'notification_count_update',
'count': unread_count
}
)
@staticmethod
def get_user_settings(
user: User,
notification_type: NotificationType
) -> UserNotificationSettings:
"""Получить или создать настройки пользователя"""
settings, created = UserNotificationSettings.objects.get_or_create(
user=user,
notification_type=notification_type,
defaults={
'is_enabled': notification_type.default_enabled,
'send_web': notification_type.default_channels.get(
'web', True
),
'send_email': notification_type.default_channels.get(
'email', False
),
'send_telegram': notification_type.default_channels.get(
'telegram', False
),
'send_whatsapp': notification_type.default_channels.get(
'whatsapp', False
),
'send_wechat': notification_type.default_channels.get(
'wechat', False
),
}
)
return settings
@staticmethod
def mark_as_read(notification_id: int, user: User) -> bool:
"""Отметить уведомление как прочитанное"""
try:
notification = Notification.objects.get(
id=notification_id,
recipient=user
)
notification.mark_as_read()
# Обновить счетчик через WebSocket
channel_layer = get_channel_layer()
unread_count = Notification.objects.filter(
recipient=user,
is_read=False,
is_archived=False
).count()
async_to_sync(channel_layer.group_send)(
f'notifications_{user.id}',
{
'type': 'notification_count_update',
'count': unread_count
}
)
return True
except Notification.DoesNotExist:
return False
@staticmethod
def mark_all_as_read(user: User, category: Optional[str] = None) -> int:
"""Отметить все уведомления как прочитанные"""
queryset = Notification.objects.filter(
recipient=user,
is_read=False
)
if category:
queryset = queryset.filter(
notification_type__category__code=category
)
count = queryset.update(
is_read=True,
read_at=timezone.now()
)
# Обновить счетчик
channel_layer = get_channel_layer()
unread_count = Notification.objects.filter(
recipient=user,
is_read=False,
is_archived=False
).count()
async_to_sync(channel_layer.group_send)(
f'notifications_{user.id}',
{
'type': 'notification_count_update',
'count': unread_count
}
)
return count

View File

@ -0,0 +1,2 @@
# Пустой файл для регистрации сигналов
# Сигналы будут добавлены на этапе 5 (интеграция с модулями)

View File

@ -0,0 +1,30 @@
"""
Тестовый view для создания уведомлений
"""
from django.http import JsonResponse
from django.contrib.auth.decorators import login_required
from notifications.services import NotificationService
@login_required
def create_test_notification(request):
"""Создать тестовое уведомление для текущего пользователя"""
notification = NotificationService.create_notification(
recipient=request.user,
notification_type_code='system_announcement',
title='🎉 Тестовое уведомление',
message='Это тестовое сообщение отправлено через API. Вы должны увидеть toast!',
action_url='/',
)
if notification:
return JsonResponse({
'success': True,
'notification_id': notification.id,
'message': 'Уведомление создано и отправлено'
})
else:
return JsonResponse({
'success': False,
'message': 'Не удалось создать уведомление'
}, status=400)

View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@ -0,0 +1,10 @@
from django.urls import path
from . import views, test_views
app_name = 'notifications'
urlpatterns = [
path('', views.notification_list, name='list'),
path('settings/', views.notification_settings, name='settings'),
path('test/create/', test_views.create_test_notification, name='test_create'),
]

View File

@ -0,0 +1,15 @@
from django.shortcuts import render
from django.contrib.auth.decorators import login_required
@login_required
def notification_list(request):
"""Страница списка уведомлений с фильтрами и пагинацией"""
return render(request, 'notifications/notification_list_new.html')
@login_required
def notification_settings(request):
"""Страница настроек уведомлений"""
return render(request, 'notifications/notification_settings_new.html')

108
backend/quick_test.py Normal file
View File

@ -0,0 +1,108 @@
#!/usr/bin/env python
"""
Быстрый тест signals без Unicode символов.
"""
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
django.setup()
from django.contrib.auth import get_user_model
from communications.models import Chat, Message
from notifications.models import Notification
Employee = get_user_model()
print("\n" + "="*60)
print(" БЫСТРЫЙ ТЕСТ СИСТЕМЫ УВЕДОМЛЕНИЙ")
print("="*60 + "\n")
# Создаем пользователей
user1, _ = Employee.objects.get_or_create(
email='quicktest1@test.com',
defaults={
'first_name': 'User',
'last_name': 'One',
'phone_number': '+79991111111',
'is_active': True
}
)
user2, _ = Employee.objects.get_or_create(
email='quicktest2@test.com',
defaults={
'first_name': 'User',
'last_name': 'Two',
'phone_number': '+79992222222',
'is_active': True
}
)
print(f"[INFO] Пользователи: {user1.email}, {user2.email}\n")
# Очищаем старые уведомления
Notification.objects.filter(recipient__in=[user1, user2]).delete()
# ТЕСТ 1: Приватный чат и сообщение
print("="*60)
print(" ТЕСТ: Communications - Сообщения и упоминания")
print("="*60)
chat = Chat.objects.create(type='private')
chat.participants.add(user1, user2)
print(f"[OK] Создан чат #{chat.id}")
# Простое сообщение
msg1 = Message.objects.create(
chat=chat,
author=user1,
content='Привет! Как дела?'
)
count = Notification.objects.filter(
recipient=user2,
notification_type__code='chat_new_message'
).count()
print(f"[{'OK' if count > 0 else 'FAIL'}] Уведомление о новом сообщении: {count}")
# Упоминание
msg2 = Message.objects.create(
chat=chat,
author=user1,
content=f'@{user2.email} нужна помощь!'
)
count = Notification.objects.filter(
recipient=user2,
notification_type__code='chat_mention'
).count()
print(f"[{'OK' if count > 0 else 'FAIL'}] Уведомление об упоминании: {count}")
# Ответ на сообщение
msg3 = Message.objects.create(
chat=chat,
author=user2,
content='Конечно!',
reply_to=msg1
)
count = Notification.objects.filter(
recipient=user1,
notification_type__code='chat_reply'
).count()
print(f"[{'OK' if count > 0 else 'FAIL'}] Уведомление об ответе: {count}")
# Итого
total = Notification.objects.filter(recipient__in=[user1, user2]).count()
print(f"\n[INFO] Всего создано уведомлений: {total}")
# Показываем список
print("\nСозданные уведомления:")
for notif in Notification.objects.filter(recipient__in=[user1, user2])[:10]:
print(f" - {notif.recipient.email}: [{notif.notification_type.code}] {notif.title}")
print("\n" + "="*60)
print(" ТЕСТ ЗАВЕРШЕН!")
print("="*60 + "\n")

View File

@ -4,3 +4,7 @@ from django.apps import AppConfig
class RequestsAppConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'requests_app'
def ready(self):
# Подключаем signals для уведомлений
import requests_app.notification_signals # noqa

View File

@ -0,0 +1,227 @@
"""
Signals для автоматической генерации уведомлений в модуле Requests.
Обрабатывает события:
- Новая заявка (для ответственных/руководителей)
- Одобрение заявки
- Отклонение заявки
- Комментарий к заявке
- Изменение статуса заявки
"""
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
from django.contrib.auth import get_user_model
from .models import Request, RequestComment
from notifications.services import NotificationService
Employee = get_user_model()
@receiver(post_save, sender=Request)
def create_request_notifications(sender, instance, created, **kwargs):
"""
Создает уведомления при создании или изменении заявки.
Обрабатывает:
1. Новая заявка - уведомление руководителю/ответственным
2. Изменение статуса - уведомление автору
"""
request_obj = instance
if created and request_obj.status != 'draft':
# Новая заявка - уведомляем ответственных
notify_new_request(request_obj)
elif not created:
# Проверяем изменение статуса
# Получаем предыдущее состояние из БД
try:
old_request = Request.objects.get(pk=request_obj.pk)
if hasattr(request_obj, '_old_status'):
old_status = request_obj._old_status
new_status = request_obj.status
if old_status != new_status:
notify_status_change(request_obj, old_status, new_status)
except Request.DoesNotExist:
pass
@receiver(pre_save, sender=Request)
def track_status_change(sender, instance, **kwargs):
"""
Сохраняем старый статус перед обновлением для отслеживания изменений.
"""
if instance.pk:
try:
old_instance = Request.objects.get(pk=instance.pk)
instance._old_status = old_instance.status
except Request.DoesNotExist:
instance._old_status = None
@receiver(post_save, sender=RequestComment)
def create_comment_notification(sender, instance, created, **kwargs):
"""
Создает уведомление при добавлении комментария к заявке.
"""
if not created:
return
comment = instance
request_obj = comment.request
author = comment.author
# Уведомляем автора заявки (если комментарий не от него)
if request_obj.employee.id != author.id:
NotificationService.create_notification(
recipient=request_obj.employee,
notification_type_code='request_comment',
title='Новый комментарий к вашей заявке',
message=(
f'{author.get_full_name() or author.username} '
f'прокомментировал заявку: {comment.text[:100]}'
),
content_object=request_obj,
action_url=f'/requests/{request_obj.id}/',
metadata={
'request_id': request_obj.id,
'request_type': request_obj.type,
'comment_id': comment.id,
'author_id': author.id,
}
)
# Также уведомляем согласующего (если есть и это не он)
if (request_obj.approver and
request_obj.approver.id != author.id and
request_obj.approver.id != request_obj.employee.id):
NotificationService.create_notification(
recipient=request_obj.approver,
notification_type_code='request_comment',
title='Комментарий к заявке',
message=(
f'{author.get_full_name() or author.username} '
f'прокомментировал заявку: {comment.text[:100]}'
),
content_object=request_obj,
action_url=f'/requests/{request_obj.id}/',
metadata={
'request_id': request_obj.id,
'request_type': request_obj.type,
'comment_id': comment.id,
'author_id': author.id,
}
)
# ===== Вспомогательные функции =====
def notify_new_request(request_obj):
"""
Отправляет уведомление о новой заявке ответственным лицам.
"""
# Определяем получателей в зависимости от типа заявки
recipients = []
# 1. Руководитель отдела сотрудника
if request_obj.department and request_obj.department.head:
head = request_obj.department.head
if head.id != request_obj.employee.id:
recipients.append(head)
# 2. Если есть назначенный согласующий
if request_obj.approver:
if request_obj.approver not in recipients:
recipients.append(request_obj.approver)
# 3. Пользователи с правом обрабатывать заявки
users_with_permission = Employee.objects.filter(
user_permissions__codename='can_process_requests'
).exclude(id=request_obj.employee.id)
for user in users_with_permission:
if user not in recipients:
recipients.append(user)
# Отправляем уведомления
for recipient in recipients:
NotificationService.create_notification(
recipient=recipient,
notification_type_code='request_new',
title=f'Новая заявка от {request_obj.employee.get_full_name()}',
message=(
f'Тип: {request_obj.get_type_display()}. '
f'{request_obj.comment[:100] if request_obj.comment else ""}'
),
content_object=request_obj,
action_url=f'/requests/{request_obj.id}/',
metadata={
'request_id': request_obj.id,
'request_type': request_obj.type,
'employee_id': request_obj.employee.id,
'department_id': (
request_obj.department.id
if request_obj.department else None
),
}
)
def notify_status_change(request_obj, old_status, new_status):
"""
Отправляет уведомление при изменении статуса заявки.
"""
# Уведомляем автора заявки
recipient = request_obj.employee
# Определяем тип уведомления и сообщение
if new_status == 'approved':
notification_type = 'request_approved'
title = 'Ваша заявка одобрена'
approver_name = (
request_obj.approver.get_full_name()
if request_obj.approver else 'Руководитель'
)
message = (
f'Заявка "{request_obj.get_type_display()}" '
f'одобрена пользователем {approver_name}'
)
elif new_status == 'rejected':
notification_type = 'request_rejected'
title = 'Ваша заявка отклонена'
approver_name = (
request_obj.approver.get_full_name()
if request_obj.approver else 'Руководитель'
)
message = (
f'Заявка "{request_obj.get_type_display()}" '
f'отклонена пользователем {approver_name}'
)
else:
# Общее уведомление об изменении статуса
notification_type = 'request_status_changed'
title = 'Статус заявки изменен'
message = (
f'Статус заявки "{request_obj.get_type_display()}" '
f'изменен: {old_status} → {new_status}'
)
NotificationService.create_notification(
recipient=recipient,
notification_type_code=notification_type,
title=title,
message=message,
content_object=request_obj,
action_url=f'/requests/{request_obj.id}/',
metadata={
'request_id': request_obj.id,
'request_type': request_obj.type,
'old_status': old_status,
'new_status': new_status,
'approver_id': (
request_obj.approver.id if request_obj.approver else None
),
}
)

View File

@ -0,0 +1,301 @@
/* Enhanced Chat Styles for new messenger features */
/* Reply Preview */
.reply-preview {
font-size: 0.85rem;
padding: 4px 8px;
background: rgba(0, 0, 0, 0.05);
border-left: 3px solid var(--bs-primary);
border-radius: 4px;
}
.reply-preview i {
color: var(--bs-primary);
}
/* Forward Info */
.forward-info {
font-size: 0.85rem;
color: var(--bs-secondary);
font-style: italic;
}
.forward-info i {
color: var(--bs-info);
}
/* Message Actions (shown on hover) */
.msg {
position: relative;
}
.message-actions {
position: absolute;
top: -12px;
right: 10px;
background: white;
border: 1px solid #dee2e6;
border-radius: 20px;
padding: 2px 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
opacity: 0;
transition: opacity 0.2s;
z-index: 10;
}
.msg:hover .message-actions {
opacity: 1;
}
.message-actions .btn {
padding: 2px 6px;
font-size: 0.85rem;
}
/* Reactions */
.reactions {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.reaction-btn {
border: 1px solid #dee2e6;
background: white;
border-radius: 12px;
padding: 2px 8px;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
}
.reaction-btn:hover {
background: #f8f9fa;
transform: scale(1.1);
}
.reaction-btn.active {
background: #e3f2fd;
border-color: var(--bs-primary);
}
.reaction-btn .count {
font-size: 0.75rem;
color: #666;
margin-left: 2px;
}
/* Quick Reactions (on hover) */
.quick-reactions {
position: absolute;
bottom: -20px;
left: 50%;
transform: translateX(-50%);
background: white;
border: 1px solid #dee2e6;
border-radius: 20px;
padding: 4px 8px;
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
z-index: 20;
display: flex;
gap: 4px;
}
.msg:hover .quick-reactions {
opacity: 1;
pointer-events: all;
}
.quick-reactions button {
border: none;
background: transparent;
font-size: 1.1rem;
padding: 4px 8px;
cursor: pointer;
border-radius: 8px;
transition: background 0.2s;
}
.quick-reactions button:hover {
background: #f0f0f0;
}
/* Attachments */
.attachments {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.attachment-image {
max-width: 200px;
max-height: 200px;
border-radius: 8px;
cursor: pointer;
transition: transform 0.2s;
}
.attachment-image:hover {
transform: scale(1.05);
}
.attachment-file {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(0, 0, 0, 0.05);
border-radius: 8px;
text-decoration: none;
color: inherit;
font-size: 0.9rem;
}
.attachment-file:hover {
background: rgba(0, 0, 0, 0.1);
}
/* Edited Indicator */
.bubble .bi-pencil-fill {
font-size: 0.7rem;
opacity: 0.6;
}
/* Pinned Message Indicator */
.bubble .bi-pin-angle-fill {
color: var(--bs-warning);
font-size: 1rem;
}
/* Typing Indicator */
#typing {
padding: 4px 12px;
font-style: italic;
}
/* Edit/Reply Indicators */
#editing-indicator,
#reply-indicator {
margin-bottom: 8px;
border-radius: 8px;
}
/* Pinned Chat Badge */
.chat-pinned-badge {
position: absolute;
top: 8px;
right: 8px;
background: var(--bs-warning);
color: white;
border-radius: 50%;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
}
/* Chat Avatar */
.chat-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #dee2e6;
}
.chat-list-item {
position: relative;
}
.chat-list-item.pinned {
background: #fffbeb;
border-left: 4px solid var(--bs-warning);
}
/* Unread Mentions Badge */
.mention-badge {
background: #ef4444;
color: white;
border-radius: 12px;
padding: 2px 8px;
font-size: 0.75rem;
font-weight: bold;
}
/* Thread Indicator */
.thread-indicator {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.85rem;
color: var(--bs-primary);
cursor: pointer;
}
.thread-indicator:hover {
text-decoration: underline;
}
/* System Messages */
.msg.system-message {
justify-content: center !important;
}
.msg.system-message .bubble {
background: #f8f9fa;
border: 1px dashed #dee2e6;
color: #6c757d;
text-align: center;
font-size: 0.9rem;
font-style: italic;
}
/* Deleted Messages */
.msg .bubble em.text-muted {
font-size: 0.9rem;
opacity: 0.7;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.message-actions {
position: static;
opacity: 1;
margin-top: 8px;
}
.quick-reactions {
position: static;
transform: none;
opacity: 1;
pointer-events: all;
margin-top: 8px;
justify-content: center;
}
.attachment-image {
max-width: 150px;
max-height: 150px;
}
}
/* Animation for new messages */
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.msg.new {
animation: slideIn 0.3s ease-out;
}

View File

@ -0,0 +1,133 @@
/* Context Menu Styles */
.context-menu {
position: absolute;
background: white;
border: 1px solid #dee2e6;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
padding: 4px 0;
min-width: 200px;
z-index: 9999;
}
.context-menu button {
width: 100%;
border: none;
background: transparent;
padding: 8px 16px;
text-align: left;
cursor: pointer;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 8px;
}
.context-menu button:hover {
background: #f8f9fa;
}
.context-menu hr {
margin: 4px 0;
border-color: #dee2e6;
}
/* Chat List Update Animation */
.chat-row.updated {
background: #e3f2fd;
transition: background 0.3s ease-out;
}
/* Pinned Chat Indicator */
.chat-row.pinned {
background: #fffbeb;
border-left: 4px solid var(--bs-warning);
}
.chat-pinned-badge {
position: absolute;
top: 4px;
right: 4px;
background: var(--bs-warning);
color: white;
border-radius: 50%;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
z-index: 1;
}
/* Chat Avatars */
.chat-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #dee2e6;
}
.chat-avatar-group {
display: flex;
align-items: center;
}
.chat-avatar-group img {
margin-left: -8px;
border: 2px solid white;
}
.chat-avatar-group img:first-child {
margin-left: 0;
}
/* Chat Actions Menu Button */
.chat-actions-menu {
display: flex;
align-items: center;
position: static !important;
}
.chat-row {
overflow: visible !important;
}
.list-chats {
overflow: visible !important;
}
.feed-card {
overflow: visible !important;
}
.chat-actions-menu .btn {
opacity: 0;
transition: opacity 0.2s;
}
.chat-row:hover .chat-actions-menu .btn {
opacity: 1;
}
.chat-actions-menu .btn:focus,
.chat-actions-menu .btn[aria-expanded="true"] {
opacity: 1;
}
.chat-actions-menu .dropdown-menu {
font-size: 0.9rem;
min-width: 200px;
position: absolute !important;
z-index: 1050 !important;
}
.chat-actions-menu .dropdown-item {
padding: 0.5rem 1rem;
}
.chat-actions-menu .dropdown-item i {
width: 20px;
text-align: center;
}

View File

@ -17,57 +17,10 @@
--list-avatar: 56px; /* размер аватара */
}
/* Контейнер списков (скролл в колонке) */
/* Контейнер списков */
.list-view{
border: 1px solid color-mix(in srgb, var(--bs-border-color), transparent 12%);
border-radius: var(--feed-radius, 16px);
background: var(--bs-body-bg);
padding: 0;
max-height: calc(100dvh - var(--navbar-h, 0px) - 160px);
overflow: auto;
-webkit-overflow-scrolling: touch;
}
/* Важно: убираем межстрочный gap от .feed-stream */
.list-view.feed-stream{
display: block;
gap: 0 !important;
}
/* Кастомный скроллбар - тонкий и незаметный */
.list-view::-webkit-scrollbar {
width: 8px;
}
.list-view::-webkit-scrollbar-track {
background: transparent;
margin: 16px 4px; /* большой отступ сверху/снизу чтобы не попадать на скругленные углы */
}
.list-view::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--bs-border-color) 40%, transparent);
border-radius: 4px;
border: 2px solid var(--bs-body-bg); /* визуальный отступ от края */
background-clip: padding-box;
}
.list-view::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--bs-border-color) 60%, transparent);
border: 2px solid var(--bs-body-bg);
background-clip: padding-box;
}
/* Убираем стрелки полностью */
.list-view::-webkit-scrollbar-button {
display: none;
height: 0;
width: 0;
}
/* Для Firefox */
.list-view {
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--bs-border-color) 40%, transparent) transparent;
padding: 0;
}
/* Важно: убираем межстрочный gap от .feed-stream */

View File

@ -3,6 +3,15 @@
* Стили навбара: меню-тогглер, поиск, уведомления
*/
/* Navbar overflow visible для dropdown */
.app-navbar {
overflow: visible !important;
}
.app-navbar .container-fluid {
overflow: visible !important;
}
/* ─────────────────────────────────────────────────────────────
MENU TOGGLE (гамбургер)
────────────────────────────────────────────────────────────── */
@ -27,3 +36,34 @@
.dropdown-toggle.no-caret::after {
display: none !important;
}
/* ─────────────────────────────────────────────────────────────
MOBILE SEARCH OVERLAY
────────────────────────────────────────────────────────────── */
.mobile-search-overlay {
position: fixed;
top: var(--navbar-h);
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1030;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
}
.mobile-search-overlay.active {
opacity: 1;
pointer-events: auto;
}
/* Светлая тема - забеливание */
[data-bs-theme="light"] .mobile-search-overlay {
background-color: rgba(255, 255, 255, 0.85);
}
/* Темная тема - затемнение */
[data-bs-theme="dark"] .mobile-search-overlay {
background-color: rgba(0, 0, 0, 0.7);
}

View File

@ -191,6 +191,19 @@
margin-top: var(--space-2);
order: 10;
}
/* Кнопка фильтров переносится под поиск и прижимается к правому краю */
.page-filter-toggle {
order: 11;
margin-top: var(--space-2);
margin-left: auto;
margin-right: 0 !important;
}
/* Родительский контейнер должен переносить элементы */
.page-header .d-flex.align-items-center {
flex-wrap: wrap;
}
}
/* ===== БЛОК ФИЛЬТРОВ ===== */

View File

@ -0,0 +1,425 @@
/* Стили для системы уведомлений */
/* ============================================
КОЛОКОЛЬЧИК В NAVBAR
============================================ */
.bell-wrapper {
position: relative;
display: inline-block;
}
.bell-dot {
position: absolute;
top: -4px;
right: -6px;
min-width: 16px;
height: 16px;
padding: 0 4px;
background-color: #dc3545;
color: white;
border-radius: 8px;
font-size: 9px;
font-weight: 700;
display: flex;
align-items-center;
justify-content: center;
line-height: 1;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.3);
}
/* ============================================
DROPDOWN МЕНЮ УВЕДОМЛЕНИЙ
============================================ */
.notification-dropdown-menu {
position: fixed;
width: 360px;
max-height: 500px;
background: white;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
z-index: 10000;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Тёмная тема */
[data-bs-theme="dark"] .notification-dropdown-menu {
background: #2b3035;
color: #dee2e6;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
/* Заголовок */
.notification-dropdown-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #e9ecef;
background: #f8f9fa;
}
[data-bs-theme="dark"] .notification-dropdown-header {
background: #1f2327;
border-bottom-color: #3d4349;
}
/* Тело с прокруткой */
.notification-dropdown-body {
flex: 1;
overflow-y: auto;
max-height: 300px;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-gutter: stable both-edges;
overscroll-behavior: contain;
scrollbar-color: color-mix(in srgb, var(--bs-body-color), transparent 70%) transparent;
}
.notification-dropdown-body:hover {
scrollbar-color: color-mix(in srgb, var(--bs-body-color), transparent 55%) transparent;
}
.notification-dropdown-body::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.notification-dropdown-body::-webkit-scrollbar-track {
background: transparent;
margin-block: 0;
}
.notification-dropdown-body::-webkit-scrollbar-thumb {
background-color: color-mix(in srgb, var(--bs-body-color), transparent 75%);
border-radius: 999px;
border: 3px solid transparent;
background-clip: padding-box;
}
.notification-dropdown-body:hover::-webkit-scrollbar-thumb {
background-color: color-mix(in srgb, var(--bs-body-color), transparent 55%);
}
[data-bs-theme="dark"] .notification-dropdown-body {
scrollbar-color: color-mix(in srgb, var(--bs-body-color), transparent 78%) transparent;
}
[data-bs-theme="dark"] .notification-dropdown-body:hover {
scrollbar-color: color-mix(in srgb, var(--bs-body-color), transparent 60%) transparent;
}
[data-bs-theme="dark"] .notification-dropdown-body::-webkit-scrollbar-thumb {
background-color: color-mix(in srgb, var(--bs-body-color), transparent 78%);
}
[data-bs-theme="dark"] .notification-dropdown-body:hover::-webkit-scrollbar-thumb {
background-color: color-mix(in srgb, var(--bs-body-color), transparent 60%);
}
@media (hover: none), (pointer: coarse) {
.notification-dropdown-body {
scrollbar-width: none;
}
.notification-dropdown-body::-webkit-scrollbar {
display: none;
}
}
/* Разделитель */
.notification-dropdown-divider {
height: 1px;
background: #e9ecef;
margin: 0;
}
[data-bs-theme="dark"] .notification-dropdown-divider {
background: #3d4349;
}
/* Секция */
.notification-dropdown-section {
padding: 8px 0;
}
/* ============================================
ЭЛЕМЕНТЫ СПИСКА УВЕДОМЛЕНИЙ
============================================ */
.notification-item {
display: flex;
gap: 12px;
padding: 12px 16px;
cursor: pointer;
transition: background-color 0.2s;
position: relative;
border-bottom: 1px solid #e9ecef;
}
.notification-item:last-child {
border-bottom: none;
}
.notification-item:hover {
background-color: #f8f9fa;
}
.notification-item.unread {
background-color: #f0f7ff;
}
.notification-item.unread:hover {
background-color: #e6f2ff;
}
/* Темная тема - элементы уведомлений */
[data-bs-theme="dark"] .notification-item {
border-bottom-color: #3d4349;
}
[data-bs-theme="dark"] .notification-item:hover {
background-color: #343a40;
}
[data-bs-theme="dark"] .notification-item.unread {
background-color: #1a3a52;
}
[data-bs-theme="dark"] .notification-item.unread:hover {
background-color: #1e4260;
}
/* Иконка уведомления */
.notification-icon {
flex-shrink: 0;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
}
/* Контент уведомления */
.notification-content {
flex: 1;
min-width: 0;
}
.notification-title {
font-weight: 600;
font-size: 14px;
margin-bottom: 4px;
color: #212529;
}
[data-bs-theme="dark"] .notification-title {
color: #dee2e6;
}
.notification-message {
font-size: 13px;
color: #6c757d;
margin-bottom: 4px;
}
[data-bs-theme="dark"] .notification-message {
color: #adb5bd;
}
.notification-time {
font-size: 11px;
color: #adb5bd;
}
[data-bs-theme="dark"] .notification-time {
color: #6c757d;
}
/* Значок непрочитанного */
.notification-unread-badge {
flex-shrink: 0;
width: 8px;
height: 8px;
background-color: #0d6efd;
border-radius: 50%;
margin-left: 8px;
align-self: center;
}
/* Пустое состояние */
.notification-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
color: #adb5bd;
text-align: center;
}
.notification-empty-state i {
font-size: 48px;
margin-bottom: 12px;
}
.notification-empty-state div {
font-size: 14px;
}
[data-bs-theme="dark"] .notification-empty-state {
color: #6c757d;
}
.extra-small {
font-size: 0.7rem;
}
/* ============================================
TOAST УВЕДОМЛЕНИЯ (FALLBACK)
============================================ */
.toast-container {
z-index: 9999;
}
.toast {
min-width: 300px;
}
/* ============================================
СТРАНИЦА СПИСКА УВЕДОМЛЕНИЙ
============================================ */
/* Карточки уведомлений - прочитанные */
.notification-read {
opacity: 0.7;
}
.notification-read .card {
background-color: #f8f9fa;
}
[data-bs-theme="dark"] .notification-read {
opacity: 0.65;
}
[data-bs-theme="dark"] .notification-read .card {
background-color: #1f2327 !important;
border-color: #3d4349 !important;
}
/* Карточки уведомлений - непрочитанные */
.notification-unread {
border-left: 4px solid var(--bs-primary);
}
.notification-unread .card {
background-color: #ffffff;
}
[data-bs-theme="dark"] .notification-unread .card {
background-color: #2b3035;
border-color: #3d4349;
}
/* Иконки категорий */
.notification-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
}
/* Цвета для карточек в темной теме */
[data-bs-theme="dark"] .card {
background-color: #2b3035;
border-color: #3d4349;
color: #dee2e6;
}
[data-bs-theme="dark"] .card-body {
color: #dee2e6;
}
[data-bs-theme="dark"] .text-muted {
color: #adb5bd !important;
}
/* Кнопки в темной теме */
[data-bs-theme="dark"] .btn-outline-primary {
color: #6ea8fe;
border-color: #6ea8fe;
}
[data-bs-theme="dark"] .btn-outline-primary:hover {
background-color: #0d6efd;
border-color: #0d6efd;
color: #fff;
}
[data-bs-theme="dark"] .btn-outline-secondary {
color: #adb5bd;
border-color: #6c757d;
}
[data-bs-theme="dark"] .btn-outline-secondary:hover {
background-color: #6c757d;
border-color: #6c757d;
color: #fff;
}
[data-bs-theme="dark"] .btn-outline-danger {
color: #ea868f;
border-color: #ea868f;
}
[data-bs-theme="dark"] .btn-outline-danger:hover {
background-color: #dc3545;
border-color: #dc3545;
color: #fff;
}
/* Формы в темной теме */
[data-bs-theme="dark"] .form-select,
[data-bs-theme="dark"] .form-control {
background-color: #1f2327;
border-color: #3d4349;
color: #dee2e6;
}
[data-bs-theme="dark"] .form-select:focus,
[data-bs-theme="dark"] .form-control:focus {
background-color: #2b3035;
border-color: #6ea8fe;
color: #dee2e6;
}
/* Пагинация в темной теме */
[data-bs-theme="dark"] .pagination .page-link {
background-color: #2b3035;
border-color: #3d4349;
color: #6ea8fe;
}
[data-bs-theme="dark"] .pagination .page-link:hover {
background-color: #343a40;
border-color: #3d4349;
color: #9ec5fe;
}
[data-bs-theme="dark"] .pagination .page-item.active .page-link {
background-color: #0d6efd;
border-color: #0d6efd;
color: #fff;
}
[data-bs-theme="dark"] .pagination .page-item.disabled .page-link {
background-color: #1f2327;
border-color: #3d4349;
color: #6c757d;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 367 KiB

View File

@ -0,0 +1,475 @@
// Enhanced Chat Detail with new messenger features
(function() {
'use strict';
const chatScroll = document.getElementById('chatScroll');
if (!chatScroll) return;
const chatId = chatScroll.dataset.chatId;
const meId = parseInt(chatScroll.dataset.meId);
let ws = null;
let typingTimeout = null;
let reconnectAttempts = 0;
const MAX_RECONNECT = 5;
// State
const state = {
editingMessageId: null,
replyToMessageId: null,
selectedMessages: new Set()
};
// ==================== WebSocket ====================
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/chat/${chatId}/`;
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('WebSocket connected');
reconnectAttempts = 0;
};
ws.onmessage = (e) => {
const data = JSON.parse(e.data);
handleWebSocketMessage(data);
};
ws.onerror = (e) => {
console.error('WebSocket error:', e);
};
ws.onclose = () => {
console.log('WebSocket closed');
if (reconnectAttempts < MAX_RECONNECT) {
reconnectAttempts++;
setTimeout(connectWebSocket, 2000 * reconnectAttempts);
}
};
}
function handleWebSocketMessage(data) {
switch(data.type) {
case 'message_edited':
updateMessage(data.message);
break;
case 'message_deleted':
markMessageAsDeleted(data.message_id);
break;
case 'reaction_added':
case 'reaction_removed':
updateReactions(data.message_id, data.reactions);
break;
case 'user_typing':
showTypingIndicator(data.user_name);
break;
case 'user_stopped_typing':
hideTypingIndicator();
break;
default:
// New message
if (data.id) {
appendMessage(data);
}
}
}
function sendAction(action, payload) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ action, ...payload }));
}
}
// ==================== Message Rendering ====================
function appendMessage(msg) {
const isMe = msg.author_id === meId;
const existingMsg = document.querySelector(`[data-id="${msg.id}"]`);
if (existingMsg) {
updateMessage(msg);
return;
}
const msgHtml = renderMessage(msg, isMe);
chatScroll.insertAdjacentHTML('beforeend', msgHtml);
scrollToBottom();
}
function renderMessage(msg, isMe) {
const timeStr = new Date(msg.created_ts).toLocaleTimeString('ru', {hour: '2-digit', minute: '2-digit'});
let html = `
<div class="d-flex mb-3 msg ${isMe ? 'justify-content-end' : 'justify-content-start'}"
data-id="${msg.id}"
data-ts="${msg.created_ts}"
data-author-id="${msg.author_id}">
`;
// Avatar (left side for others)
if (!isMe && msg.avatar) {
html += `
<a class="me-2 text-decoration-none" href="${msg.author_url}">
<span class="mini-ava border">
<img src="${msg.avatar}" alt="" loading="lazy">
</span>
</a>`;
}
html += `<div class="d-flex flex-column" style="max-width:80%;">`;
// Author & time
html += `<div class="small text-secondary ${isMe ? 'text-end' : ''}">`;
if (isMe) {
html += `<a href="/employees/profile/" class="text-decoration-none">Вы</a>`;
} else {
html += `<a href="${msg.author_url}" class="text-decoration-none fw-semibold">${msg.author_name}</a>`;
}
html += ` · <time>${timeStr}</time>`;
if (msg.is_edited) {
html += ` <i class="bi-pencil-fill text-muted" title="Изменено"></i>`;
}
html += `</div>`;
// Reply preview
if (msg.reply_to) {
html += `
<div class="reply-preview mb-1">
<i class="bi-reply"></i>
<span class="fw-semibold">${msg.reply_to.author_name}:</span>
${escapeHtml(msg.reply_to.content.substring(0, 50))}
</div>`;
}
// Forward info
if (msg.is_forwarded && msg.forward_info) {
html += `
<div class="forward-info mb-1">
<i class="bi-forward"></i>
Переслано от ${msg.forward_info.original_author}
${msg.forward_info.forward_count > 1 ? `(×${msg.forward_info.forward_count})` : ''}
</div>`;
}
// Message bubble
const bubbleClass = isMe ? 'bubble-me' : 'bubble-other';
html += `<div class="mt-1 bubble ${bubbleClass} position-relative">`;
if (msg.is_deleted) {
html += `<em class="text-muted">Сообщение удалено</em>`;
} else {
html += escapeHtml(msg.content).replace(/\n/g, '<br>');
}
// Attachments
if (msg.attachments && msg.attachments.length > 0) {
html += `<div class="attachments mt-2">`;
msg.attachments.forEach(att => {
if (att.file_type === 'image') {
html += `
<a href="${att.file_url}" target="_blank">
<img src="${att.thumbnail || att.file_url}" class="attachment-image" alt="${att.file_name}">
</a>`;
} else {
html += `
<a href="${att.file_url}" class="attachment-file" download>
<i class="bi-file-earmark"></i> ${att.file_name}
</a>`;
}
});
html += `</div>`;
}
// Pinned indicator
if (msg.is_pinned) {
html += `<i class="bi-pin-angle-fill position-absolute top-0 end-0 m-1"></i>`;
}
// Message actions
if (isMe && !msg.is_deleted) {
html += `
<div class="message-actions">
<button class="btn btn-sm btn-ghost" onclick="editMessage(${msg.id})" title="Редактировать">
<i class="bi-pencil"></i>
</button>
<button class="btn btn-sm btn-ghost" onclick="deleteMessage(${msg.id})" title="Удалить">
<i class="bi-trash"></i>
</button>
</div>`;
}
html += `</div>`; // bubble
// Reactions
if (msg.reactions && Object.keys(msg.reactions).length > 0) {
html += `<div class="reactions mt-1" data-message-id="${msg.id}">`;
for (const [emoji, userIds] of Object.entries(msg.reactions)) {
const isMyReaction = userIds.includes(meId);
html += `
<button class="reaction-btn ${isMyReaction ? 'active' : ''}"
data-emoji="${emoji}"
onclick="toggleReaction(${msg.id}, '${emoji}')">
${emoji} <span class="count">${userIds.length}</span>
</button>`;
}
html += `</div>`;
}
// Quick reactions (on hover)
html += `
<div class="quick-reactions">
<button onclick="addReaction(${msg.id}, '👍')">👍</button>
<button onclick="addReaction(${msg.id}, '❤️')">❤️</button>
<button onclick="addReaction(${msg.id}, '😊')">😊</button>
<button onclick="addReaction(${msg.id}, '🔥')">🔥</button>
<button onclick="setReplyTo(${msg.id})"><i class="bi-reply"></i></button>
<button onclick="forwardMessage(${msg.id})"><i class="bi-forward"></i></button>
</div>`;
html += `</div>`; // flex-column
// Avatar (right side for me)
if (isMe) {
const myAvatar = document.querySelector('.mini-ava img')?.src || '';
html += `
<a class="ms-2 text-decoration-none" href="/employees/profile/">
<span class="mini-ava border">
${myAvatar ? `<img src="${myAvatar}" alt="" loading="lazy">` : '<i class="bi-person"></i>'}
</span>
</a>`;
}
html += `</div>`; // msg container
return html;
}
function updateMessage(msg) {
const msgEl = document.querySelector(`[data-id="${msg.id}"]`);
if (!msgEl) return;
const isMe = msg.author_id === meId;
const newHtml = renderMessage(msg, isMe);
msgEl.outerHTML = newHtml;
}
function markMessageAsDeleted(messageId) {
const msgEl = document.querySelector(`[data-id="${messageId}"]`);
if (!msgEl) return;
const bubble = msgEl.querySelector('.bubble');
if (bubble) {
bubble.innerHTML = '<em class="text-muted">Сообщение удалено</em>';
}
}
function updateReactions(messageId, reactions) {
const msgEl = document.querySelector(`[data-id="${messageId}"]`);
if (!msgEl) return;
let reactionsContainer = msgEl.querySelector('.reactions');
if (Object.keys(reactions).length === 0) {
if (reactionsContainer) reactionsContainer.remove();
return;
}
if (!reactionsContainer) {
const bubble = msgEl.querySelector('.bubble');
reactionsContainer = document.createElement('div');
reactionsContainer.className = 'reactions mt-1';
reactionsContainer.dataset.messageId = messageId;
bubble.after(reactionsContainer);
}
let html = '';
for (const [emoji, userIds] of Object.entries(reactions)) {
const isMyReaction = userIds.includes(meId);
html += `
<button class="reaction-btn ${isMyReaction ? 'active' : ''}"
data-emoji="${emoji}"
onclick="toggleReaction(${messageId}, '${emoji}')">
${emoji} <span class="count">${userIds.length}</span>
</button>`;
}
reactionsContainer.innerHTML = html;
}
// ==================== Actions ====================
window.editMessage = function(messageId) {
const msgEl = document.querySelector(`[data-id="${messageId}"]`);
const bubble = msgEl.querySelector('.bubble');
const content = bubble.textContent.trim();
state.editingMessageId = messageId;
const textarea = document.getElementById('id_content');
textarea.value = content;
textarea.focus();
showEditingIndicator(messageId);
};
window.deleteMessage = function(messageId) {
if (!confirm('Удалить сообщение?')) return;
sendAction('delete_message', { message_id: messageId });
};
window.addReaction = function(messageId, emoji) {
sendAction('add_reaction', { message_id: messageId, emoji });
};
window.toggleReaction = function(messageId, emoji) {
const msgEl = document.querySelector(`[data-id="${messageId}"]`);
const reactionBtn = msgEl.querySelector(`.reaction-btn[data-emoji="${emoji}"]`);
if (reactionBtn.classList.contains('active')) {
sendAction('remove_reaction', { message_id: messageId, emoji });
} else {
sendAction('add_reaction', { message_id: messageId, emoji });
}
};
window.setReplyTo = function(messageId) {
const msgEl = document.querySelector(`[data-id="${messageId}"]`);
const bubble = msgEl.querySelector('.bubble');
const authorName = msgEl.querySelector('.fw-semibold')?.textContent || 'Вы';
const content = bubble.textContent.trim();
state.replyToMessageId = messageId;
showReplyIndicator(authorName, content);
document.getElementById('id_content').focus();
};
window.forwardMessage = function(messageId) {
// TODO: Show modal to select target chat
console.log('Forward message:', messageId);
};
// ==================== UI Helpers ====================
function showEditingIndicator(messageId) {
const indicator = document.createElement('div');
indicator.id = 'editing-indicator';
indicator.className = 'alert alert-info d-flex align-items-center';
indicator.innerHTML = `
<i class="bi-pencil me-2"></i>
Редактирование сообщения
<button type="button" class="btn-close ms-auto" onclick="cancelEdit()"></button>
`;
const form = document.getElementById('chatForm');
form.before(indicator);
}
function showReplyIndicator(authorName, content) {
const indicator = document.createElement('div');
indicator.id = 'reply-indicator';
indicator.className = 'alert alert-secondary d-flex align-items-center';
indicator.innerHTML = `
<i class="bi-reply me-2"></i>
<div>
<strong>${authorName}:</strong>
${escapeHtml(content.substring(0, 50))}...
</div>
<button type="button" class="btn-close ms-auto" onclick="cancelReply()"></button>
`;
const form = document.getElementById('chatForm');
form.before(indicator);
}
window.cancelEdit = function() {
state.editingMessageId = null;
document.getElementById('editing-indicator')?.remove();
document.getElementById('id_content').value = '';
};
window.cancelReply = function() {
state.replyToMessageId = null;
document.getElementById('reply-indicator')?.remove();
};
function showTypingIndicator(userName) {
const indicator = document.getElementById('typing');
indicator.textContent = `${userName} печатает…`;
indicator.classList.remove('d-none');
}
function hideTypingIndicator() {
const indicator = document.getElementById('typing');
indicator.classList.add('d-none');
}
// ==================== Form Handling ====================
const form = document.getElementById('chatForm');
const textarea = document.getElementById('id_content');
form?.addEventListener('submit', function(e) {
e.preventDefault();
const content = textarea.value.trim();
if (!content) return;
if (state.editingMessageId) {
// Edit message
sendAction('edit_message', {
message_id: state.editingMessageId,
content
});
cancelEdit();
} else if (state.replyToMessageId) {
// Reply to message
sendAction('send_message', {
content,
reply_to_id: state.replyToMessageId
});
cancelReply();
} else {
// New message
sendAction('send_message', { content });
}
textarea.value = '';
sendAction('stop_typing');
});
// Typing indicator
textarea?.addEventListener('input', function() {
clearTimeout(typingTimeout);
sendAction('typing');
typingTimeout = setTimeout(() => {
sendAction('stop_typing');
}, 3000);
});
// ==================== Utilities ====================
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function scrollToBottom() {
chatScroll.scrollTop = chatScroll.scrollHeight;
}
// ==================== Init ====================
connectWebSocket();
scrollToBottom();
// Scroll button
const scrollBtn = document.getElementById('scrollBtn');
scrollBtn?.addEventListener('click', scrollToBottom);
})();

View File

@ -0,0 +1,480 @@
// Enhanced Chat List with pinning, avatars, and real-time updates
(function() {
'use strict';
let ws = null;
let reconnectAttempts = 0;
const MAX_RECONNECT = 5;
// ==================== WebSocket ====================
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/chats/`;
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('Chat List WebSocket connected');
reconnectAttempts = 0;
};
ws.onmessage = (e) => {
const data = JSON.parse(e.data);
handleWebSocketMessage(data);
};
ws.onerror = (e) => {
console.error('WebSocket error:', e);
};
ws.onclose = () => {
console.log('WebSocket closed');
if (reconnectAttempts < MAX_RECONNECT) {
reconnectAttempts++;
setTimeout(connectWebSocket, 2000 * reconnectAttempts);
}
};
}
function handleWebSocketMessage(data) {
switch(data.type) {
case 'list_update':
updateChatLastMessage(data.chat_id, data.message);
break;
case 'message_edited':
updateChatLastMessage(data.chat_id, data.message);
break;
case 'user_typing':
showChatTyping(data.chat_id, data.user_name);
break;
}
}
function updateChatLastMessage(chatId, message) {
const chatRow = document.querySelector(`[data-chat-id="${chatId}"]`);
if (!chatRow) return;
// Update timestamp
const lastTime = chatRow.querySelector('[data-last-time]');
if (lastTime) {
const date = new Date(message.created_ts);
lastTime.textContent = date.toLocaleString('ru', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
// Update author
const lastAuthor = chatRow.querySelector('[data-last-author]');
if (lastAuthor) {
lastAuthor.textContent = message.author_name;
}
// Update preview
const lastPreview = chatRow.querySelector('[data-last-preview]');
if (lastPreview) {
let preview = message.content;
if (message.is_deleted) {
preview = 'Сообщение удалено';
} else if (message.has_attachments) {
preview = '📎 Вложение';
}
lastPreview.textContent = preview.substring(0, 120);
}
// Update unread count (simplified - actual count needs server update)
const unreadBadge = chatRow.querySelector('[data-unread]');
if (unreadBadge && message.author_id !== getCurrentUserId()) {
unreadBadge.classList.remove('d-none');
}
// Move to top if not pinned
const isPinned = chatRow.classList.contains('pinned');
if (!isPinned) {
moveToTop(chatRow);
}
// Flash animation
chatRow.classList.add('updated');
setTimeout(() => chatRow.classList.remove('updated'), 1000);
}
function showChatTyping(chatId, userName) {
const chatRow = document.querySelector(`[data-chat-id="${chatId}"]`);
if (!chatRow) return;
const feedSub = chatRow.querySelector('.feed-sub');
if (!feedSub) return;
// Save original content
if (!feedSub.dataset.originalContent) {
feedSub.dataset.originalContent = feedSub.innerHTML;
}
// Show typing
feedSub.innerHTML = `<em class="text-primary">${userName} печатает...</em>`;
// Hide after 3s
clearTimeout(feedSub._typingTimeout);
feedSub._typingTimeout = setTimeout(() => {
if (feedSub.dataset.originalContent) {
feedSub.innerHTML = feedSub.dataset.originalContent;
delete feedSub.dataset.originalContent;
}
}, 3000);
}
function moveToTop(chatRow) {
const parent = chatRow.parentElement;
const firstChild = parent.firstElementChild;
if (firstChild !== chatRow) {
parent.insertBefore(chatRow, firstChild);
}
}
function getCurrentUserId() {
// Try to get from data attribute or similar
return parseInt(document.body.dataset.userId || 0);
}
// ==================== Pin/Unpin ====================
window.togglePinChat = function(chatId, event) {
event.preventDefault();
event.stopPropagation();
const chatRow = document.querySelector(`[data-chat-id="${chatId}"]`);
const isPinned = chatRow.classList.contains('pinned');
fetch(`/communications/api/chat/${chatId}/pin/`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': getCsrfToken()
},
body: `pinned=${!isPinned}`
})
.then(response => response.json())
.then(data => {
if (data.ok) {
if (data.pinned) {
chatRow.classList.add('pinned');
addPinBadge(chatRow);
moveToPinnedSection(chatRow);
} else {
chatRow.classList.remove('pinned');
removePinBadge(chatRow);
moveToUnpinnedSection(chatRow);
}
}
})
.catch(error => console.error('Pin error:', error));
};
function addPinBadge(chatRow) {
if (chatRow.querySelector('.chat-pinned-badge')) return;
const badge = document.createElement('div');
badge.className = 'chat-pinned-badge';
badge.innerHTML = '<i class="bi-pin-angle-fill"></i>';
badge.title = 'Закреплено';
const feedMeta = chatRow.querySelector('.flex-grow-1');
feedMeta.style.position = 'relative';
feedMeta.appendChild(badge);
}
function removePinBadge(chatRow) {
const badge = chatRow.querySelector('.chat-pinned-badge');
if (badge) badge.remove();
}
function moveToPinnedSection(chatRow) {
const parent = chatRow.parentElement;
const firstUnpinned = Array.from(parent.children).find(row =>
!row.classList.contains('pinned')
);
if (firstUnpinned) {
parent.insertBefore(chatRow, firstUnpinned);
} else {
parent.appendChild(chatRow);
}
}
function moveToUnpinnedSection(chatRow) {
const parent = chatRow.parentElement;
parent.appendChild(chatRow);
}
// ==================== Context Menu ====================
function initContextMenus() {
document.querySelectorAll('.chat-row').forEach(chatRow => {
chatRow.addEventListener('contextmenu', function(e) {
e.preventDefault();
showContextMenu(e, this);
});
});
}
function showContextMenu(event, chatRow) {
const chatId = chatRow.dataset.chatId;
const isPinned = chatRow.classList.contains('pinned');
// Remove existing menu
document.getElementById('chat-context-menu')?.remove();
const menu = document.createElement('div');
menu.id = 'chat-context-menu';
menu.className = 'context-menu';
menu.style.left = `${event.pageX}px`;
menu.style.top = `${event.pageY}px`;
menu.innerHTML = `
<button onclick="togglePinChat(${chatId}, event)">
<i class="bi-pin-angle${isPinned ? '-fill' : ''}"></i>
${isPinned ? 'Открепить' : 'Закрепить'}
</button>
<button onclick="toggleNotifications(${chatId}, event)">
<i class="bi-bell-slash"></i>
Выключить уведомления
</button>
<button onclick="hideChat(${chatId}, event)">
<i class="bi-eye-slash"></i>
Скрыть чат
</button>
<hr class="my-1">
<button onclick="markAsRead(${chatId}, event)">
<i class="bi-check-all"></i>
Отметить прочитанным
</button>
`;
document.body.appendChild(menu);
// Close on click outside
setTimeout(() => {
document.addEventListener('click', function closeMenu() {
menu.remove();
document.removeEventListener('click', closeMenu);
});
}, 10);
}
window.toggleNotifications = function(chatId, event) {
event.preventDefault();
event.stopPropagation();
fetch(`/communications/api/chat/${chatId}/notifications/`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': getCsrfToken()
},
body: 'enabled=false'
})
.then(response => response.json())
.then(data => {
if (data.ok) {
console.log('Notifications toggled');
}
});
};
window.hideChat = function(chatId, event) {
event.preventDefault();
event.stopPropagation();
if (!confirm('Скрыть этот чат из списка?')) return;
// TODO: Implement hide chat API
console.log('Hide chat:', chatId);
};
window.markAsRead = function(chatId, event) {
event.preventDefault();
event.stopPropagation();
fetch(`/communications/chats/${chatId}/mark-read/`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': getCsrfToken()
}
})
.then(response => response.json())
.then(data => {
if (data.ok) {
const chatRow = document.querySelector(`[data-chat-id="${chatId}"]`);
const unreadBadge = chatRow.querySelector('[data-unread]');
if (unreadBadge) {
unreadBadge.classList.add('d-none');
}
}
});
};
// ==================== Search & Filter ====================
const searchInput = document.getElementById('chatSearch');
if (searchInput) {
searchInput.addEventListener('input', function(e) {
const query = e.target.value.toLowerCase().trim();
document.querySelectorAll('.chat-row').forEach(row => {
const haystack = row.dataset.haystack?.toLowerCase() || '';
const match = haystack.includes(query);
row.style.display = match ? '' : 'none';
});
updateEmptyNotes();
});
}
function updateEmptyNotes() {
document.querySelectorAll('.chat-section').forEach(section => {
const listContainer = section.querySelector('.list-chats');
const visibleChats = Array.from(listContainer.children).filter(
row => row.style.display !== 'none'
);
const emptyNote = section.querySelector('[data-empty-note]');
if (emptyNote) {
emptyNote.style.display = visibleChats.length === 0 ? 'block' : 'none';
}
});
}
// ==================== Utilities ====================
function getCsrfToken() {
return document.querySelector('[name=csrfmiddlewaretoken]')?.value || '';
}
// ==================== Init ====================
connectWebSocket();
initDropdownMenus();
updateEmptyNotes();
// Sort pinned chats to top on load
document.querySelectorAll('.list-chats').forEach(container => {
const pinned = Array.from(container.querySelectorAll('.chat-row.pinned'));
pinned.forEach(row => moveToPinnedSection(row));
});
// ==================== Dropdown Menu Handlers ====================
function initDropdownMenus() {
// Обновление текста "Закрепить"/"Открепить" при открытии dropdown
document.addEventListener('show.bs.dropdown', function(e) {
const dropdown = e.target;
const chatRow = dropdown.closest('.chat-row');
if (!chatRow) return;
const isPinned = chatRow.classList.contains('pinned');
const pinTextElement = dropdown.parentElement.querySelector('[data-pin-text]');
if (pinTextElement) {
pinTextElement.textContent = isPinned ? 'Открепить' : 'Закрепить';
}
});
// Обработка действий в выпадающем меню
document.addEventListener('click', function(e) {
const actionLink = e.target.closest('[data-action]');
if (!actionLink) return;
e.preventDefault();
const action = actionLink.dataset.action;
const chatRow = actionLink.closest('.chat-row');
if (!chatRow) return;
const chatId = chatRow.dataset.chatId;
switch(action) {
case 'pin':
togglePinChat(chatId, chatRow);
break;
case 'notifications':
toggleNotifications(chatId, chatRow);
break;
case 'edit':
editChat(chatId);
break;
case 'manage':
manageChat(chatId);
break;
case 'delete':
deleteChat(chatId, chatRow);
break;
}
// Закрываем dropdown
const dropdownBtn = actionLink.closest('.dropdown-menu')?.previousElementSibling;
if (dropdownBtn) {
const dropdown = bootstrap.Dropdown.getInstance(dropdownBtn);
if (dropdown) dropdown.hide();
}
});
}
function toggleNotifications(chatId, chatRow) {
// TODO: реализовать API запрос
console.log('Toggle notifications for chat', chatId);
alert('Управление уведомлениями будет доступно в следующей версии');
}
function editChat(chatId) {
// TODO: создать страницу редактирования чата
alert('Редактирование чатов будет доступно в следующей версии');
// window.location.href = `/communications/chat/${chatId}/edit/`;
}
function manageChat(chatId) {
// TODO: создать страницу управления чатом
alert('Управление чатом (участники, права) будет доступно в следующей версии');
// window.location.href = `/communications/chat/${chatId}/manage/`;
}
function deleteChat(chatId, chatRow) {
// TODO: реализовать API endpoint для удаления чата
alert('Удаление чатов будет доступно в следующей версии');
return;
/* Закомментировано до реализации API
if (!confirm('Вы уверены, что хотите удалить этот чат? Это действие необратимо.')) {
return;
}
fetch(`/communications/api/chat/${chatId}/delete/`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': getCsrfToken()
}
})
.then(response => response.json())
.then(data => {
if (data.ok) {
// Удаляем строку из списка
chatRow.style.transition = 'opacity 0.3s';
chatRow.style.opacity = '0';
setTimeout(() => chatRow.remove(), 300);
} else {
alert('Ошибка при удалении чата: ' + (data.error || 'Неизвестная ошибка'));
}
})
.catch(error => {
console.error('Delete error:', error);
alert('Ошибка при удалении чата');
});
*/
}
})();

View File

@ -52,6 +52,7 @@ export class RecipientPicker {
this.apiUrl = options.apiUrl;
this.searchQuery = '';
this.selected = new Map(); // id -> {id, display_name, email}
this.maxSelections = options.maxSelections || null; // null = без ограничений
// Получаем элементы
this.elSelected = root.querySelector('.rp-selected');
@ -87,6 +88,12 @@ export class RecipientPicker {
const payload = JSON.parse(checkbox.dataset.payload || '{}');
if (checkbox.checked) {
// Проверка лимита выбора
if (this.maxSelections && this.selected.size >= this.maxSelections) {
checkbox.checked = false;
alert(`Можно выбрать не более ${this.maxSelections} получателей`);
return;
}
this.selected.set(id, payload);
} else {
this.selected.delete(id);
@ -228,6 +235,34 @@ export class RecipientPicker {
return Array.from(this.selected.keys());
}
/**
* Получить массив выбранных сотрудников с полными данными.
* @returns {Array<Object>} Массив объектов {id, display_name, email, ...}
*/
getSelected() {
return Array.from(this.selected.values());
}
/**
* Установить максимальное количество выбираемых получателей.
* @param {number|null} max - Максимум (null = без ограничений)
*/
setMaxSelections(max) {
this.maxSelections = max;
// Если превышен лимит, сбрасываем лишние выборы
if (max && this.selected.size > max) {
const toKeep = Array.from(this.selected.entries()).slice(0, max);
this.selected.clear();
toKeep.forEach(([id, data]) => this.selected.set(id, data));
this._renderSelected();
// Обновляем чекбоксы
this.elResults.querySelectorAll('input[type=checkbox][data-id]').forEach(checkbox => {
const id = Number(checkbox.dataset.id);
checkbox.checked = this.selected.has(id);
});
}
}
/**
* Очистить все выборы.
*/

View File

@ -0,0 +1,156 @@
/**
* navbar-mobile-search.js
* Управление мобильным поиском в navbar
*/
(function() {
'use strict';
// Элементы
const searchToggle = document.querySelector('.mobile-search-toggle');
const searchInput = document.querySelector('.mobile-search-input');
const searchForm = document.querySelector('.mobile-search-form');
const navbarIcons = document.querySelectorAll('.navbar-icon');
const navbarBrand = document.querySelector('.navbar-brand-item');
const navbarRight = document.querySelector('.navbar-right');
if (!searchToggle || !searchInput) return;
let isSearchOpen = false;
let searchOverlay = null;
// Создать оверлей
function createOverlay() {
if (!searchOverlay) {
searchOverlay = document.createElement('div');
searchOverlay.className = 'mobile-search-overlay';
document.body.appendChild(searchOverlay);
}
}
// Показать оверлей
function showOverlay() {
createOverlay();
setTimeout(() => {
searchOverlay.classList.add('active');
}, 10);
}
// Скрыть оверлей
function hideOverlay() {
if (searchOverlay) {
searchOverlay.classList.remove('active');
}
}
// Открыть/закрыть поиск
function toggleMobileSearch() {
isSearchOpen = !isSearchOpen;
if (isSearchOpen) {
// Открыть поиск
searchToggle.classList.add('d-none');
searchInput.classList.remove('d-none');
searchInput.classList.add('d-flex');
// Показать оверлей
showOverlay();
// Скрыть остальные элементы на мобильном
navbarIcons.forEach(icon => {
if (window.innerWidth < 768) {
icon.classList.add('d-none');
}
});
if (window.innerWidth < 768) {
if (navbarBrand) navbarBrand.classList.add('d-none');
if (navbarRight) navbarRight.classList.add('d-none');
}
// Фокус на инпут
const input = searchInput.querySelector('input');
if (input) {
setTimeout(() => input.focus(), 100);
}
} else {
// Закрыть поиск
closeMobileSearch();
}
}
function closeMobileSearch() {
isSearchOpen = false;
searchToggle.classList.remove('d-none');
searchInput.classList.add('d-none');
searchInput.classList.remove('d-flex');
// Скрыть оверлей
hideOverlay();
// Показать остальные элементы
navbarIcons.forEach(icon => icon.classList.remove('d-none'));
if (navbarBrand) navbarBrand.classList.remove('d-none');
if (navbarRight) navbarRight.classList.remove('d-none');
}
// Клик на иконку поиска
searchToggle.addEventListener('click', toggleMobileSearch);
// Закрыть при клике вне поиска
document.addEventListener('click', (e) => {
if (!isSearchOpen) return;
// Проверяем, был ли клик вне формы поиска (или на оверлей)
if (!searchForm.contains(e.target) || e.target === searchOverlay) {
closeMobileSearch();
}
});
// Закрыть при нажатии Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && isSearchOpen) {
closeMobileSearch();
}
});
// Отправка формы - отключаем неактивный input
searchForm.addEventListener('submit', (e) => {
const mobileInput = document.getElementById('mobileSearchInput');
const desktopInput = document.getElementById('desktopSearchInput');
// Определяем какой input активен
const isMobile = window.innerWidth < 768;
if (isMobile) {
// На мобильном отключаем десктопный
if (desktopInput) desktopInput.disabled = true;
// Проверяем что мобильный не пустой
if (!mobileInput || !mobileInput.value.trim()) {
e.preventDefault();
if (desktopInput) desktopInput.disabled = false;
return;
}
} else {
// На десктопе отключаем мобильный
if (mobileInput) mobileInput.disabled = true;
// Проверяем что десктопный не пустой
if (!desktopInput || !desktopInput.value.trim()) {
e.preventDefault();
if (mobileInput) mobileInput.disabled = false;
return;
}
}
// Форма отправится с одним активным полем
});
// Закрыть при изменении размера окна на десктоп
window.addEventListener('resize', () => {
if (window.innerWidth >= 768 && isSearchOpen) {
closeMobileSearch();
}
});
})();

View File

@ -0,0 +1,448 @@
/**
* Управление страницей списка уведомлений
*/
class NotificationListManager {
constructor() {
this.currentPage = 1;
this.pageSize = 20;
this.currentFilter = {
category: '',
is_read: '',
search: ''
};
this.init();
}
init() {
this.setupFilters();
this.setupSearch();
this.setupMarkAllButton();
this.loadNotifications();
}
setupFilters() {
// Фильтр по категории
const categoryFilter = document.getElementById('categoryFilter');
if (categoryFilter) {
categoryFilter.addEventListener('change', (e) => {
this.currentFilter.category = e.target.value;
this.currentPage = 1;
this.loadNotifications();
});
}
// Фильтр по статусу прочитанности
const statusFilter = document.getElementById('statusFilter');
if (statusFilter) {
statusFilter.addEventListener('change', (e) => {
this.currentFilter.is_read = e.target.value;
this.currentPage = 1;
this.loadNotifications();
});
}
}
setupSearch() {
const searchInput = document.getElementById('notificationSearch');
const searchBtn = document.getElementById('searchBtn');
if (searchInput && searchBtn) {
searchBtn.addEventListener('click', () => {
this.currentFilter.search = searchInput.value.trim();
this.currentPage = 1;
this.loadNotifications();
});
searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
this.currentFilter.search = searchInput.value.trim();
this.currentPage = 1;
this.loadNotifications();
}
});
}
}
setupMarkAllButton() {
const markAllBtn = document.getElementById('markAllReadBtn');
if (markAllBtn) {
markAllBtn.addEventListener('click', async () => {
await this.markAllAsRead();
});
}
}
async loadNotifications() {
const container = document.getElementById('notificationListContainer');
if (!container) return;
// Показать загрузку
container.innerHTML = `
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
</div>
`;
try {
const params = new URLSearchParams({
page: this.currentPage,
page_size: this.pageSize
});
if (this.currentFilter.category) {
params.append('category', this.currentFilter.category);
}
if (this.currentFilter.is_read !== '') {
params.append('is_read', this.currentFilter.is_read);
}
if (this.currentFilter.search) {
params.append('search', this.currentFilter.search);
}
const response = await fetch(`/api/notifications/?${params}`, {
credentials: 'same-origin',
headers: {
'Accept': 'application/json',
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
this.renderNotifications(data);
} catch (error) {
console.error('[NotificationList] Error loading:', error);
container.innerHTML = `
<div class="alert alert-danger">
<i class="bi-exclamation-triangle"></i>
Ошибка загрузки уведомлений: ${error.message}
</div>
`;
}
}
renderNotifications(data) {
const container = document.getElementById('notificationListContainer');
if (!container) return;
if (!data.notifications || data.notifications.length === 0) {
container.innerHTML = `
<div class="text-center py-5">
<i class="bi-bell-slash" style="font-size: 3rem; color: #ccc;"></i>
<p class="text-muted mt-3">Уведомлений нет</p>
</div>
`;
return;
}
const notificationsHtml = data.notifications.map(n => this.renderNotificationCard(n)).join('');
const paginationHtml = this.renderPagination(data);
container.innerHTML = `
<div class="row row-cols-1 g-3">
${notificationsHtml}
</div>
${paginationHtml}
`;
// Добавить обработчики событий
this.attachEventHandlers();
}
renderNotificationCard(notification) {
const categoryColors = {
'communications': 'primary',
'documents': 'success',
'requests': 'warning',
'calendar': 'info',
'department': 'secondary',
'profile': 'dark',
'feed': 'danger',
'system': 'light'
};
const categoryIcons = {
'communications': 'bi-chat-dots',
'documents': 'bi-file-earmark-text',
'requests': 'bi-clipboard-check',
'calendar': 'bi-calendar-event',
'department': 'bi-people',
'profile': 'bi-person',
'feed': 'bi-newspaper',
'system': 'bi-gear'
};
const color = categoryColors[notification.category] || 'secondary';
const icon = categoryIcons[notification.category] || 'bi-bell';
const readClass = notification.is_read ? 'notification-read' : 'notification-unread';
return `
<div class="col">
<div class="card ${readClass}" data-notification-id="${notification.id}">
<div class="card-body">
<div class="d-flex align-items-start">
<div class="flex-shrink-0">
<div class="notification-icon bg-${color} bg-opacity-10">
<i class="bi ${icon} text-${color}"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="d-flex justify-content-between align-items-start mb-2">
<h6 class="mb-0">${this.escapeHtml(notification.title)}</h6>
<small class="text-muted ms-2">${this.formatTime(notification.created_at)}</small>
</div>
<p class="mb-2 text-muted">${this.escapeHtml(notification.message)}</p>
<div class="d-flex gap-2">
${notification.action_url ? `
<a href="${notification.action_url}" class="btn btn-sm btn-outline-primary">
<i class="bi-arrow-right-circle"></i> ${notification.action_text || 'Посмотреть'}
</a>
` : ''}
${!notification.is_read ? `
<button class="btn btn-sm btn-outline-secondary mark-read-btn" data-id="${notification.id}">
<i class="bi-check2"></i> Прочитано
</button>
` : ''}
<button class="btn btn-sm btn-outline-danger delete-btn" data-id="${notification.id}">
<i class="bi-trash"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
`;
}
renderPagination(data) {
if (data.total <= this.pageSize) {
return '';
}
const totalPages = Math.ceil(data.total / this.pageSize);
const pages = [];
// Всегда показываем первую страницу
pages.push(1);
// Показываем страницы вокруг текущей
for (let i = Math.max(2, this.currentPage - 1); i <= Math.min(totalPages - 1, this.currentPage + 1); i++) {
if (!pages.includes(i)) {
pages.push(i);
}
}
// Всегда показываем последнюю страницу
if (!pages.includes(totalPages)) {
pages.push(totalPages);
}
let paginationHtml = '<nav class="mt-4"><ul class="pagination justify-content-center">';
// Кнопка "Предыдущая"
paginationHtml += `
<li class="page-item ${this.currentPage === 1 ? 'disabled' : ''}">
<a class="page-link" href="#" data-page="${this.currentPage - 1}">
<i class="bi-chevron-left"></i>
</a>
</li>
`;
// Страницы
let prevPage = 0;
pages.forEach(page => {
if (page - prevPage > 1) {
paginationHtml += '<li class="page-item disabled"><span class="page-link">...</span></li>';
}
paginationHtml += `
<li class="page-item ${page === this.currentPage ? 'active' : ''}">
<a class="page-link" href="#" data-page="${page}">${page}</a>
</li>
`;
prevPage = page;
});
// Кнопка "Следующая"
paginationHtml += `
<li class="page-item ${this.currentPage === totalPages ? 'disabled' : ''}">
<a class="page-link" href="#" data-page="${this.currentPage + 1}">
<i class="bi-chevron-right"></i>
</a>
</li>
`;
paginationHtml += '</ul></nav>';
return paginationHtml;
}
attachEventHandlers() {
// Обработчики для кнопок "Прочитано"
document.querySelectorAll('.mark-read-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
const id = btn.dataset.id;
await this.markAsRead(id);
});
});
// Обработчики для кнопок удаления
document.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
const id = btn.dataset.id;
if (confirm('Удалить это уведомление?')) {
await this.deleteNotification(id);
}
});
});
// Обработчики для пагинации
document.querySelectorAll('.pagination .page-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const page = parseInt(link.dataset.page);
if (page && page !== this.currentPage) {
this.currentPage = page;
this.loadNotifications();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
});
});
}
async markAsRead(id) {
try {
const response = await fetch(`/api/notifications/${id}/read/`, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken()
}
});
if (response.ok) {
// Обновить карточку
const card = document.querySelector(`[data-notification-id="${id}"]`);
if (card) {
card.classList.remove('notification-unread');
card.classList.add('notification-read');
card.querySelector('.mark-read-btn')?.remove();
}
// Обновить счетчик в navbar
if (window.notificationManager) {
const badge = document.getElementById('notificationBadge');
if (badge) {
const currentCount = parseInt(badge.textContent) || 0;
if (currentCount > 0) {
window.notificationManager.updateBadge(currentCount - 1);
}
}
}
}
} catch (error) {
console.error('[NotificationList] Error marking as read:', error);
alert('Ошибка при обновлении уведомления');
}
}
async markAllAsRead() {
if (!confirm('Отметить все уведомления как прочитанные?')) {
return;
}
try {
const response = await fetch('/api/notifications/read-all/', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken()
}
});
if (response.ok) {
// Перезагрузить список
this.loadNotifications();
// Обновить счетчик
if (window.notificationManager) {
window.notificationManager.updateBadge(0);
}
}
} catch (error) {
console.error('[NotificationList] Error marking all as read:', error);
alert('Ошибка при обновлении уведомлений');
}
}
async deleteNotification(id) {
try {
const response = await fetch(`/api/notifications/${id}/`, {
method: 'DELETE',
credentials: 'same-origin',
headers: {
'X-CSRFToken': this.getCsrfToken()
}
});
if (response.ok) {
// Удалить карточку
const card = document.querySelector(`[data-notification-id="${id}"]`)?.closest('.col');
if (card) {
card.remove();
}
// Если список пуст, показать заглушку
const container = document.getElementById('notificationListContainer');
if (container && !container.querySelector('.card')) {
this.loadNotifications();
}
}
} catch (error) {
console.error('[NotificationList] Error deleting:', error);
alert('Ошибка при удалении уведомления');
}
}
getCsrfToken() {
return document.querySelector('[name=csrfmiddlewaretoken]')?.value || '';
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
formatTime(dateString) {
const date = new Date(dateString);
const now = new Date();
const diff = Math.floor((now - date) / 1000); // секунды
if (diff < 60) return 'только что';
if (diff < 3600) return `${Math.floor(diff / 60)} мин назад`;
if (diff < 86400) return `${Math.floor(diff / 3600)} ч назад`;
if (diff < 604800) return `${Math.floor(diff / 86400)} дн назад`;
return date.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'short',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
});
}
}
// Инициализация при загрузке страницы
document.addEventListener('DOMContentLoaded', () => {
if (document.getElementById('notificationListContainer')) {
window.notificationListManager = new NotificationListManager();
}
});

View File

@ -0,0 +1,583 @@
/**
* Менеджер уведомлений - подключение к WebSocket и управление
*/
class NotificationManager {
constructor() {
this.ws = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 1000;
this.isConnected = false;
this.notificationPermission = 'default';
this.soundEnabled = this.loadSoundPreference();
this.notificationSound = null;
this.init();
}
/**
* Загрузить настройку звука из localStorage
*/
loadSoundPreference() {
const saved = localStorage.getItem('notificationSoundEnabled');
return saved === null ? true : saved === 'true';
}
/**
* Сохранить настройку звука
*/
saveSoundPreference(enabled) {
this.soundEnabled = enabled;
localStorage.setItem('notificationSoundEnabled', enabled.toString());
}
/**
* Воспроизвести звук уведомления
*/
playNotificationSound() {
if (!this.soundEnabled) return;
if (!this.notificationSound) {
// Создаем короткий звук через Web Audio API
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = 800; // Частота звука
oscillator.type = 'sine';
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.3);
}
}
init() {
this.requestNotificationPermission();
this.connectWebSocket();
this.loadInitialNotifications();
this.setupDropdown();
}
/**
* Запросить разрешение на показ браузерных уведомлений
*/
async requestNotificationPermission() {
if (!('Notification' in window)) {
console.warn('[Notifications] Browser does not support notifications');
return;
}
if (Notification.permission === 'granted') {
this.notificationPermission = 'granted';
console.log('[Notifications] Permission already granted');
return;
}
if (Notification.permission !== 'denied') {
try {
const permission = await Notification.requestPermission();
this.notificationPermission = permission;
console.log('[Notifications] Permission:', permission);
} catch (error) {
console.error('[Notifications] Error requesting permission:', error);
}
}
}
connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/notifications/`;
console.log('[Notifications] Connecting to:', wsUrl);
try {
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('[Notifications] WebSocket connected');
this.isConnected = true;
this.reconnectAttempts = 0;
this.updateConnectionStatus(true);
};
this.ws.onmessage = (event) => {
this.handleMessage(event);
};
this.ws.onclose = () => {
console.log('[Notifications] WebSocket disconnected');
this.isConnected = false;
this.updateConnectionStatus(false);
this.attemptReconnect();
};
this.ws.onerror = (error) => {
console.error('[Notifications] WebSocket error:', error);
};
} catch (error) {
console.error('[Notifications] Failed to create WebSocket:', error);
this.attemptReconnect();
}
}
handleMessage(event) {
try {
const data = JSON.parse(event.data);
console.log('[Notifications] Received:', data);
switch (data.type) {
case 'unread_count':
this.updateBadge(data.count);
break;
case 'new_notification':
this.showNewNotification(data.notification);
this.updateBadge(data.notification.unread_count);
break;
case 'count_update':
this.updateBadge(data.count);
break;
default:
console.warn('[Notifications] Unknown message type:', data.type);
}
} catch (error) {
console.error('[Notifications] Error parsing message:', error);
}
}
attemptReconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
const delay = this.reconnectDelay * this.reconnectAttempts;
console.log(`[Notifications] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
setTimeout(() => {
this.connectWebSocket();
}, delay);
} else {
console.error('[Notifications] Max reconnect attempts reached');
}
}
updateConnectionStatus(connected) {
// Визуальная индикация статуса подключения (опционально)
const badge = document.getElementById('notificationBadge');
if (badge) {
if (!connected) {
badge.style.backgroundColor = '#dc3545'; // красный при отключении
} else {
badge.style.backgroundColor = ''; // вернуть стандартный цвет
}
}
}
updateBadge(count) {
const badge = document.getElementById('notificationBadge');
if (badge) {
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.classList.remove('d-none');
} else {
badge.classList.add('d-none');
}
}
}
async loadInitialNotifications() {
try {
const response = await fetch('/api/notifications/?page=1&page_size=5&unread_only=true', {
credentials: 'same-origin',
headers: {
'Accept': 'application/json',
}
});
if (!response.ok) {
const errorText = await response.text();
console.error('[Notifications] API Error:', response.status, errorText);
throw new Error(`Failed to load notifications: ${response.status}`);
}
const data = await response.json();
console.log('[Notifications] Loaded data:', data);
this.renderNotifications(data.notifications);
this.updateBadge(data.total);
} catch (error) {
console.error('[Notifications] Error loading notifications:', error);
// Показываем заглушку при ошибке
const list = document.getElementById('notificationList');
if (list) {
list.innerHTML = `
<div class="notification-empty-state">
<i class="bi-exclamation-triangle"></i>
<div>Ошибка загрузки уведомлений</div>
</div>
`;
}
}
}
renderNotifications(notifications) {
const listElement = document.getElementById('notificationList');
if (!listElement) return;
if (notifications.length === 0) {
listElement.innerHTML = `
<div class="notification-empty-state">
<i class="bi-bell-slash"></i>
<div>Нет новых уведомлений</div>
</div>
`;
return;
}
listElement.innerHTML = notifications.map(n => this.renderNotificationItem(n)).join('');
// Добавить обработчики событий
listElement.querySelectorAll('[data-notification-id]').forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
const notificationId = item.dataset.notificationId;
const actionUrl = item.dataset.actionUrl;
this.markAsRead(notificationId).then(() => {
this.closeDropdown(); // Закрыть dropdown
if (actionUrl) {
window.location.href = actionUrl;
}
});
});
});
}
renderNotificationItem(notification) {
const iconColorClass = `text-${notification.color}`;
const timeAgo = this.getTimeAgo(notification.created_at);
return `
<div class="notification-item ${!notification.is_read ? 'unread' : ''}"
data-notification-id="${notification.id}"
data-action-url="${notification.action_url || ''}">
<div class="notification-icon">
<i class="${notification.icon} ${iconColorClass}"></i>
</div>
<div class="notification-content">
<div class="notification-title">${this.escapeHtml(notification.title)}</div>
<div class="notification-message">${this.escapeHtml(notification.message)}</div>
<div class="notification-time">${timeAgo}</div>
</div>
${!notification.is_read ? '<div class="notification-unread-badge"></div>' : ''}
</div>
`;
}
showNewNotification(notification) {
// Показать нативное браузерное уведомление
this.showBrowserNotification(notification);
// Обновить список в dropdown
this.loadInitialNotifications();
// Воспроизвести звук (опционально)
this.playNotificationSound();
}
/**
* Показать нативное браузерное уведомление
*/
showBrowserNotification(notification) {
if (!('Notification' in window)) {
console.warn('[Notifications] Browser notifications not supported');
// Fallback to toast
this.showToast(notification);
return;
}
if (Notification.permission !== 'granted') {
console.warn('[Notifications] Permission not granted');
// Fallback to toast
this.showToast(notification);
return;
}
// Определить иконку по категории
const iconMap = {
'communications': '💬',
'documents': '📄',
'requests': '📋',
'calendar': '📅',
'department': '👥',
'profile': '👤',
'feed': '📰',
'system': '⚙️'
};
const options = {
body: notification.message,
icon: '/static/img/logo.png', // Можно заменить на специальную иконку
badge: '/static/img/favicon-32.png',
tag: `notification-${notification.id}`,
requireInteraction: notification.priority === 'urgent',
silent: false,
data: {
notificationId: notification.id,
actionUrl: notification.action_url
}
};
try {
const browserNotification = new Notification(
`${iconMap[notification.category] || '🔔'} ${notification.title}`,
options
);
// Обработать клик по уведомлению
browserNotification.onclick = (event) => {
event.preventDefault();
window.focus();
// Отметить как прочитанное
this.markAsRead(notification.id);
// Перейти по ссылке
if (notification.action_url) {
window.location.href = notification.action_url;
}
browserNotification.close();
};
// Автозакрытие через 10 секунд (если не urgent)
if (notification.priority !== 'urgent') {
setTimeout(() => {
browserNotification.close();
}, 10000);
}
console.log('[Notifications] Browser notification shown:', notification.title);
} catch (error) {
console.error('[Notifications] Error showing browser notification:', error);
// Fallback to toast
this.showToast(notification);
}
}
showToast(notification) {
// Создать toast элемент
const toastHtml = `
<div class="toast align-items-center border-0 bg-${notification.color} text-white" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
<i class="${notification.icon} me-2"></i>
<strong>${this.escapeHtml(notification.title)}</strong>
<div class="small">${this.escapeHtml(notification.message)}</div>
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
`;
// Добавить в контейнер
let toastContainer = document.getElementById('toastContainer');
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.id = 'toastContainer';
toastContainer.className = 'toast-container position-fixed top-0 end-0 p-3';
toastContainer.style.zIndex = '9999';
document.body.appendChild(toastContainer);
}
toastContainer.insertAdjacentHTML('beforeend', toastHtml);
// Показать toast
const toastElement = toastContainer.lastElementChild;
const toast = new bootstrap.Toast(toastElement, {
autohide: true,
delay: 5000
});
toast.show();
// Удалить после скрытия
toastElement.addEventListener('hidden.bs.toast', () => {
toastElement.remove();
});
}
playNotificationSound() {
// Опциональный звук уведомления
try {
const audio = new Audio('/static/sounds/notification.mp3');
audio.volume = 0.3;
audio.play().catch(() => {
// Игнорировать ошибки воспроизведения
});
} catch (error) {
// Звук не критичен
}
}
async markAsRead(notificationId) {
try {
const response = await fetch(`/api/notifications/${notificationId}/read/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken()
}
});
if (response.ok) {
console.log('[Notifications] Marked as read:', notificationId);
}
} catch (error) {
console.error('[Notifications] Error marking as read:', error);
}
}
async markAllAsRead() {
try {
const response = await fetch('/api/notifications/read-all/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken()
}
});
if (response.ok) {
console.log('[Notifications] All marked as read');
this.loadInitialNotifications();
}
} catch (error) {
console.error('[Notifications] Error marking all as read:', error);
}
}
getTimeAgo(dateString) {
const date = new Date(dateString);
const now = new Date();
const seconds = Math.floor((now - date) / 1000);
if (seconds < 60) return 'только что';
if (seconds < 3600) return `${Math.floor(seconds / 60)} мин назад`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)} ч назад`;
if (seconds < 604800) return `${Math.floor(seconds / 86400)} дн назад`;
return date.toLocaleDateString('ru-RU');
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
getCsrfToken() {
return document.querySelector('[name=csrfmiddlewaretoken]')?.value ||
document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';
}
/**
* Управление dropdown меню
*/
setupDropdown() {
const toggle = document.getElementById('notificationToggle');
const dropdown = document.getElementById('notificationDropdown');
if (!toggle || !dropdown) return;
// Клик по кнопке
toggle.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleDropdown();
});
// Клик вне dropdown - закрыть
document.addEventListener('click', (e) => {
if (!dropdown.contains(e.target) && !toggle.contains(e.target)) {
this.closeDropdown();
}
});
// Позиционирование при открытии
this.positionDropdown();
window.addEventListener('resize', () => this.positionDropdown());
window.addEventListener('scroll', () => this.positionDropdown());
}
toggleDropdown() {
const dropdown = document.getElementById('notificationDropdown');
if (dropdown.style.display === 'none' || !dropdown.style.display) {
this.openDropdown();
} else {
this.closeDropdown();
}
}
openDropdown() {
const dropdown = document.getElementById('notificationDropdown');
dropdown.style.display = 'flex';
this.positionDropdown();
}
closeDropdown() {
const dropdown = document.getElementById('notificationDropdown');
dropdown.style.display = 'none';
}
positionDropdown() {
const toggle = document.getElementById('notificationToggle');
const dropdown = document.getElementById('notificationDropdown');
if (!toggle || !dropdown || dropdown.style.display === 'none') return;
const rect = toggle.getBoundingClientRect();
const dropdownWidth = 360;
const margin = 16; // отступ от края экрана
// Позиция по вертикали
dropdown.style.top = `${rect.bottom + 8}px`;
// Позиция по горизонтали - адаптивная
const isMobile = window.innerWidth < 768;
if (isMobile) {
// На мобильных - центрируем с отступами от краёв
dropdown.style.left = `${margin}px`;
dropdown.style.right = `${margin}px`;
dropdown.style.width = 'auto';
} else {
// На десктопе - справа от кнопки
const rightPosition = window.innerWidth - rect.right;
// Проверяем, не выходит ли за левый край
if (rect.right - dropdownWidth < margin) {
// Если выходит - выравниваем по левому краю с отступом
dropdown.style.left = `${margin}px`;
dropdown.style.right = 'auto';
} else {
// Обычное выравнивание по правому краю кнопки
dropdown.style.right = `${rightPosition}px`;
dropdown.style.left = 'auto';
}
dropdown.style.width = `${dropdownWidth}px`;
}
}
}
// Инициализация при загрузке страницы
document.addEventListener('DOMContentLoaded', function() {
if (document.getElementById('notificationBell')) {
window.notificationManager = new NotificationManager();
console.log('[Notifications] Manager initialized');
}
});

View File

@ -0,0 +1,274 @@
/**
* Управление настройками уведомлений
*/
class NotificationSettingsManager {
constructor() {
this.settings = {};
this.init();
}
init() {
this.loadSettings();
this.setupSoundToggle();
}
async loadSettings() {
const container = document.getElementById('settingsContainer');
if (!container) return;
try {
const response = await fetch('/api/notifications/settings/', {
credentials: 'same-origin',
headers: {
'Accept': 'application/json',
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
this.settings = data.settings || {};
this.renderSettings();
} catch (error) {
console.error('[Settings] Error loading:', error);
container.innerHTML = `
<div class="alert alert-danger">
<i class="bi-exclamation-triangle"></i>
Ошибка загрузки настроек: ${error.message}
</div>
`;
}
}
renderSettings() {
const container = document.getElementById('settingsContainer');
if (!container) return;
const categories = [
{ code: 'communications', name: 'Коммуникации', icon: 'bi-chat-dots', description: 'Сообщения, упоминания, ответы в чатах' },
{ code: 'documents', name: 'Документы', icon: 'bi-file-earmark-text', description: 'Новые документы, ознакомления' },
{ code: 'requests', name: 'Заявления', icon: 'bi-clipboard-check', description: 'Заявки, согласования, комментарии' },
{ code: 'calendar', name: 'Календарь', icon: 'bi-calendar-event', description: 'События, напоминания, изменения' },
{ code: 'department', name: 'Отдел', icon: 'bi-people', description: 'Изменения в структуре отдела' },
{ code: 'profile', name: 'Профиль', icon: 'bi-person', description: 'Изменения профиля и безопасности' },
{ code: 'feed', name: 'Новости', icon: 'bi-newspaper', description: 'Публикации, комментарии, реакции' },
{ code: 'system', name: 'Система', icon: 'bi-gear', description: 'Системные уведомления и обновления' }
];
let html = '<div class="row g-4">';
categories.forEach(cat => {
const setting = this.settings[cat.code] || {
is_enabled: true,
web_enabled: true,
email_enabled: false,
telegram_enabled: false
};
html += `
<div class="col-md-6">
<div class="card h-100">
<div class="card-body">
<div class="d-flex align-items-start mb-3">
<div class="flex-shrink-0">
<div class="settings-icon">
<i class="${cat.icon}"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<h5 class="card-title mb-1">${cat.name}</h5>
<p class="text-muted small mb-3">${cat.description}</p>
<div class="form-check form-switch mb-2">
<input class="form-check-input setting-toggle"
type="checkbox"
id="enable_${cat.code}"
data-category="${cat.code}"
data-field="is_enabled"
${setting.is_enabled ? 'checked' : ''}>
<label class="form-check-label" for="enable_${cat.code}">
<strong>Включить уведомления</strong>
</label>
</div>
<div class="ps-4 ${setting.is_enabled ? '' : 'd-none'}" id="channels_${cat.code}">
<div class="form-check mb-2">
<input class="form-check-input channel-toggle"
type="checkbox"
id="web_${cat.code}"
data-category="${cat.code}"
data-field="web_enabled"
${setting.web_enabled ? 'checked' : ''}>
<label class="form-check-label" for="web_${cat.code}">
<i class="bi-globe"></i> Веб-уведомления
</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input channel-toggle"
type="checkbox"
id="email_${cat.code}"
data-category="${cat.code}"
data-field="email_enabled"
${setting.email_enabled ? 'checked' : ''}
disabled>
<label class="form-check-label text-muted" for="email_${cat.code}">
<i class="bi-envelope"></i> Email (скоро)
</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input channel-toggle"
type="checkbox"
id="telegram_${cat.code}"
data-category="${cat.code}"
data-field="telegram_enabled"
${setting.telegram_enabled ? 'checked' : ''}
disabled>
<label class="form-check-label text-muted" for="telegram_${cat.code}">
<i class="bi-telegram"></i> Telegram (скоро)
</label>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
});
html += '</div>';
container.innerHTML = html;
// Добавить обработчики
this.attachEventHandlers();
}
attachEventHandlers() {
// Обработчики для главных переключателей
document.querySelectorAll('.setting-toggle').forEach(toggle => {
toggle.addEventListener('change', async (e) => {
const category = toggle.dataset.category;
const field = toggle.dataset.field;
const value = toggle.checked;
// Показать/скрыть каналы
const channels = document.getElementById(`channels_${category}`);
if (channels) {
if (value) {
channels.classList.remove('d-none');
} else {
channels.classList.add('d-none');
}
}
await this.updateSetting(category, field, value);
});
});
// Обработчики для каналов
document.querySelectorAll('.channel-toggle').forEach(toggle => {
toggle.addEventListener('change', async (e) => {
const category = toggle.dataset.category;
const field = toggle.dataset.field;
const value = toggle.checked;
await this.updateSetting(category, field, value);
});
});
}
async updateSetting(category, field, value) {
try {
const response = await fetch('/api/notifications/settings/category/update/', {
method: 'PUT',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken()
},
body: JSON.stringify({
category: category,
[field]: value
})
});
if (!response.ok) {
throw new Error('Failed to update setting');
}
// Обновить локальные настройки
if (!this.settings[category]) {
this.settings[category] = {};
}
this.settings[category][field] = value;
// Показать уведомление об успехе
this.showToast('Настройки сохранены', 'success');
} catch (error) {
console.error('[Settings] Error updating:', error);
this.showToast('Ошибка сохранения настроек', 'danger');
}
}
setupSoundToggle() {
const soundToggle = document.getElementById('soundToggle');
if (soundToggle && window.notificationManager) {
soundToggle.checked = window.notificationManager.soundEnabled;
soundToggle.addEventListener('change', (e) => {
window.notificationManager.saveSoundPreference(e.target.checked);
this.showToast(
e.target.checked ? 'Звук включен' : 'Звук выключен',
'info'
);
});
}
}
getCsrfToken() {
return document.querySelector('[name=csrfmiddlewaretoken]')?.value || '';
}
showToast(message, type = 'info') {
// Создать toast уведомление
const toastContainer = document.getElementById('toastContainer') || this.createToastContainer();
const toast = document.createElement('div');
toast.className = `toast align-items-center text-white bg-${type} border-0`;
toast.setAttribute('role', 'alert');
toast.innerHTML = `
<div class="d-flex">
<div class="toast-body">${message}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
`;
toastContainer.appendChild(toast);
const bsToast = new bootstrap.Toast(toast, { delay: 3000 });
bsToast.show();
toast.addEventListener('hidden.bs.toast', () => {
toast.remove();
});
}
createToastContainer() {
const container = document.createElement('div');
container.id = 'toastContainer';
container.className = 'toast-container position-fixed bottom-0 end-0 p-3';
document.body.appendChild(container);
return container;
}
}
// Инициализация
document.addEventListener('DOMContentLoaded', () => {
if (document.getElementById('settingsContainer')) {
window.notificationSettingsManager = new NotificationSettingsManager();
}
});

View File

@ -36,6 +36,7 @@
<link rel="stylesheet" href="{% static 'css/components/ios-search.css' %}">
<link rel="stylesheet" href="{% static 'css/components/base-app.css' %}">
<link rel="stylesheet" href="{% static 'css/components/ios-components.css' %}">
<link rel="stylesheet" href="{% static 'css/notifications/notifications.css' %}">
{% block extra_css %}{% endblock %}
</head>
@ -218,6 +219,9 @@
});
})();
</script>
<!-- Notification Manager -->
<script src="{% static 'js/notifications/notification-manager.js' %}" defer></script>
{% endif %}
{% block extra_js %}{% endblock %}

View File

@ -0,0 +1,23 @@
{# Фильтры для страницы чатов #}
<form method="get" class="row g-2 align-items-end">
<div class="col-12 col-sm-8">
<label class="form-label mb-1">Тип чата</label>
<select name="chat_type" class="form-select" id="chatTypeFilter">
<option value="all" selected>Все</option>
<option value="global">Глобальный</option>
<option value="department">Отделы</option>
<option value="private">Личные</option>
<option value="group">Группы</option>
<option value="channel">Каналы</option>
<option value="announcement">Объявления</option>
</select>
</div>
<div class="col-12 col-sm-4 d-flex justify-content-end gap-2">
<button class="btn btn-outline-primary d-flex align-items-center justify-content-center gap-1" type="button" id="applyChatFilter">
<i class="bi-funnel"></i><span>Применить</span>
</button>
<button type="button" class="btn btn-outline-secondary d-flex align-items-center justify-content-center" id="resetChatFilter" title="Сбросить фильтры">
<i class="bi-x-lg"></i>
</button>
</div>
</form>

View File

@ -8,6 +8,8 @@
<link rel="stylesheet" href="{% static 'css/components/ios-search.css' %}">
<link rel="stylesheet" href="{% static 'css/components/feed-cards.css' %}">
<link rel="stylesheet" href="{% static 'css/components/page-header.css' %}">
<link rel="stylesheet" href="{% static 'css/components/document-list.css' %}">
<link rel="stylesheet" href="{% static 'css/components/chat-list-enhanced.css' %}">
{% endblock %}
{% block content %}
@ -16,30 +18,7 @@
<div class="col-12">
{# ===== НОВАЯ ШАПКА С КОМПОНЕНТОМ ===== #}
{% include "includes/components/page_header.html" with icon="bi-chat-dots" title="Мои чаты" search_id="chatSearch" search_placeholder="Поиск по названию или участникам" no_create=True %}
{# Сегментированный фильтр #}
<div class="feed-new mb-3">
<div class="d-flex align-items-center gap-2 flex-wrap">
<div class="btn-group" role="group" aria-label="Фильтр чатов">
<input type="radio" class="btn-check" name="chatFilter" id="fltAll" autocomplete="off" checked>
<label class="btn btn-ghost" for="fltAll" data-filter="all">Все</label>
<input type="radio" class="btn-check" name="chatFilter" id="fltGlobal" autocomplete="off">
<label class="btn btn-ghost" for="fltGlobal" data-filter="global">Глобальный</label>
<input type="radio" class="btn-check" name="chatFilter" id="fltDepartment" autocomplete="off">
<label class="btn btn-ghost" for="fltDepartment" data-filter="department">Отделы</label>
<input type="radio" class="btn-check" name="chatFilter" id="fltPrivate" autocomplete="off">
<label class="btn btn-ghost" for="fltPrivate" data-filter="private">Личные</label>
</div>
<a href="{% url 'employees:employees_list' %}" class="btn btn-ghost ms-auto">
<i class="bi-person-plus"></i> Новый чат
</a>
</div>
</div>
{% include "includes/components/page_header.html" with icon="bi-chat-dots" title="Мои чаты" search_id="chatSearch" search_placeholder="Поиск по названию или участникам" has_filters=True filters_target="chatFilters" filters_template="communications/_filters.html" create_as_card=True create_modal="chatCreateModal" create_text="Создать чат" %}
{# ===== 1) ГЛОБАЛЬНЫЙ ===== #}
<div class="feed-card chat-section mb-3" data-sec="global">
@ -60,11 +39,20 @@
data-type="global"
data-last-ts="{% if last %}{{ last.created_at|date:'U' }}000{% else %}{{ chat.created_at|date:'U' }}000{% endif %}"
data-haystack="глобальный чат">
<div class="feed-ava" style="width:44px;height:44px;"><i class="bi-globe"></i></div>
<div class="flex-grow-1">
<div class="d-flex align-items-center gap-2 flex-wrap position-relative">
{# Аватар или иконка #}
<div class="feed-ava" style="width:44px;height:44px;">
{% if chat.avatar %}
<img src="{{ chat.avatar.url }}" alt="{{ chat.name }}" class="chat-avatar" loading="lazy">
{% else %}
<i class="bi-globe"></i>
{% endif %}
</div>
<div class="flex-grow-1 position-relative">
<div class="d-flex align-items-center gap-2 flex-wrap">
<a href="{% url 'communications:chat_detail' chat.pk %}" class="feed-author text-decoration-none stretched-link">
Глобальный чат
{{ chat.name|default:"Глобальный чат" }}
</a>
<span class="badge bg-light text-dark border">
<i class="bi-chat-left-text"></i> {{ chat.messages.count }}
@ -84,7 +72,18 @@
{% endif %}
</div>
</div>
<i class="bi-chevron-right text-secondary ms-2 mt-1"></i>
{# Меню действий - Глобальный чат #}
<div class="chat-actions-menu position-relative" style="z-index:2;">
<button class="btn btn-sm btn-link text-secondary p-1" type="button" data-chat-menu-btn data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#" data-action="pin"><i class="bi-pin me-2"></i><span data-pin-text>Закрепить</span></a></li>
<li><a class="dropdown-item" href="#" data-action="notifications"><i class="bi-bell me-2"></i>Уведомления</a></li>
</ul>
</div>
</div>
{% endwith %}
{% endif %}
@ -112,8 +111,8 @@
data-last-ts="{% if last %}{{ last.created_at|date:'U' }}000{% else %}{{ chat.created_at|date:'U' }}000{% endif %}"
data-haystack="{{ chat.department.name }}">
<div class="feed-ava" style="width:44px;height:44px;"><i class="bi-diagram-3"></i></div>
<div class="flex-grow-1">
<div class="d-flex align-items-center gap-2 flex-wrap position-relative">
<div class="flex-grow-1 position-relative">
<div class="d-flex align-items-center gap-2 flex-wrap">
<a href="{% url 'communications:chat_detail' chat.pk %}" class="feed-author text-decoration-none stretched-link">
Чат отдела: {{ chat.department.name }}
</a>
@ -135,7 +134,18 @@
{% endif %}
</div>
</div>
<i class="bi-chevron-right text-secondary ms-2 mt-1"></i>
{# Меню действий - Отдел #}
<div class="chat-actions-menu position-relative" style="z-index:2;">
<button class="btn btn-sm btn-link text-secondary p-1" type="button" data-chat-menu-btn data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#" data-action="pin"><i class="bi-pin me-2"></i><span data-pin-text>Закрепить</span></a></li>
<li><a class="dropdown-item" href="#" data-action="notifications"><i class="bi-bell me-2"></i>Уведомления</a></li>
</ul>
</div>
</div>
{% endwith %}
{% endif %}
@ -174,8 +184,8 @@
{% endwith %}
</div>
<div class="flex-grow-1">
<div class="d-flex align-items-center gap-2 flex-wrap position-relative">
<div class="flex-grow-1 position-relative">
<div class="d-flex align-items-center gap-2 flex-wrap">
<a href="{% url 'communications:chat_detail' chat.pk %}" class="feed-author text-decoration-none stretched-link">
{% for u in chat.participants.all %}
{% if u != user %}
@ -203,7 +213,241 @@
</div>
</div>
<i class="bi-chevron-right text-secondary ms-2 mt-1"></i>
{# Меню действий - Личный чат #}
<div class="chat-actions-menu position-relative" style="z-index:2;">
<button class="btn btn-sm btn-link text-secondary p-1" type="button" data-chat-menu-btn data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#" data-action="pin"><i class="bi-pin me-2"></i><span data-pin-text>Закрепить</span></a></li>
<li><a class="dropdown-item" href="#" data-action="notifications"><i class="bi-bell me-2"></i>Уведомления</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" href="#" data-action="delete"><i class="bi-trash me-2"></i>Удалить</a></li>
</ul>
</div>
</div>
{% endwith %}
{% endif %}
{% endfor %}
</div>
</div>
{# ===== 4) ГРУППЫ ===== #}
<div class="feed-card chat-section mb-3" data-sec="group">
<div class="feed-hd">
<div class="feed-ava" style="width:44px;height:44px;"><i class="bi-people-fill"></i></div>
<div class="flex-grow-1 feed-meta">
<h6 class="mb-0 text-secondary">Группы</h6>
<span class="small text-secondary" data-empty-note style="display:none;">Нет групповых чатов</span>
</div>
</div>
<div id="sec-group" class="list-chats">
{% for chat in chats %}
{% if chat.type == 'group' %}
{% with last=chat.last_message.0 %}
<div class="chat-row d-flex align-items-start gap-2 py-2 px-2 {% if not forloop.first %}border-top{% endif %}"
data-chat-id="{{ chat.pk }}"
data-type="group"
data-last-ts="{% if last %}{{ last.created_at|date:'U' }}000{% else %}{{ chat.created_at|date:'U' }}000{% endif %}"
data-haystack="{{ chat.name }} {{ chat.description }}">
<div class="feed-ava" style="width:44px;height:44px;">
{% if chat.avatar %}
<img src="{{ chat.avatar.url }}" alt="{{ chat.name }}" class="chat-avatar" loading="lazy">
{% else %}
<i class="bi-people-fill"></i>
{% endif %}
</div>
<div class="flex-grow-1 position-relative">
<div class="d-flex align-items-center gap-2 flex-wrap">
<a href="{% url 'communications:chat_detail' chat.pk %}" class="feed-author text-decoration-none stretched-link">
{{ chat.name }}
</a>
<span class="badge bg-light text-dark border">
<i class="bi-chat-left-text"></i> {{ chat.messages.count }}
</span>
<span class="badge bg-danger-subtle text-danger-emphasis border border-danger-subtle {% if not chat.unread_count %}d-none{% endif %}"
data-unread>+<span data-unread-count>{{ chat.unread_count|default:0 }}</span></span>
</div>
<div class="feed-sub mt-1">
{% if last %}
<span class="text-secondary">
<span data-last-time>{{ last.created_at|date:"d.m.Y H:i" }}</span> ·
<strong data-last-author>{{ last.author.get_full_name|default:last.author.username }}</strong>:
<span data-last-preview>{{ last.content|default:"(вложение)"|truncatechars:120 }}</span>
</span>
{% else %}
<span class="text-secondary">Сообщений пока нет.</span>
{% endif %}
</div>
</div>
{# Меню действий #}
<div class="chat-actions-menu position-relative" style="z-index:2;">
<button class="btn btn-sm btn-link text-secondary p-1" type="button" data-chat-menu-btn data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#" data-action="pin"><i class="bi-pin me-2"></i>Закрепить</a></li>
<li><a class="dropdown-item" href="#" data-action="notifications"><i class="bi-bell me-2"></i>Уведомления</a></li>
<li><a class="dropdown-item" href="#" data-action="edit"><i class="bi-pencil me-2"></i>Редактировать</a></li>
<li><a class="dropdown-item" href="#" data-action="manage"><i class="bi-gear me-2"></i>Управление</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" href="#" data-action="delete"><i class="bi-trash me-2"></i>Удалить</a></li>
</ul>
</div>
</div>
{% endwith %}
{% endif %}
{% endfor %}
</div>
</div>
{# ===== 5) КАНАЛЫ ===== #}
<div class="feed-card chat-section mb-3" data-sec="channel">
<div class="feed-hd">
<div class="feed-ava" style="width:44px;height:44px;"><i class="bi-broadcast"></i></div>
<div class="flex-grow-1 feed-meta">
<h6 class="mb-0 text-secondary">Каналы</h6>
<span class="small text-secondary" data-empty-note style="display:none;">Нет каналов</span>
</div>
</div>
<div id="sec-channel" class="list-chats">
{% for chat in chats %}
{% if chat.type == 'channel' %}
{% with last=chat.last_message.0 %}
<div class="chat-row d-flex align-items-start gap-2 py-2 px-2 {% if not forloop.first %}border-top{% endif %}"
data-chat-id="{{ chat.pk }}"
data-type="channel"
data-last-ts="{% if last %}{{ last.created_at|date:'U' }}000{% else %}{{ chat.created_at|date:'U' }}000{% endif %}"
data-haystack="{{ chat.name }} {{ chat.description }}">
<div class="feed-ava" style="width:44px;height:44px;">
{% if chat.avatar %}
<img src="{{ chat.avatar.url }}" alt="{{ chat.name }}" class="chat-avatar" loading="lazy">
{% else %}
<i class="bi-broadcast"></i>
{% endif %}
</div>
<div class="flex-grow-1 position-relative">
<div class="d-flex align-items-center gap-2 flex-wrap">
<a href="{% url 'communications:chat_detail' chat.pk %}" class="feed-author text-decoration-none stretched-link">
{{ chat.name }}
</a>
<span class="badge bg-light text-dark border">
<i class="bi-chat-left-text"></i> {{ chat.messages.count }}
</span>
<span class="badge bg-danger-subtle text-danger-emphasis border border-danger-subtle {% if not chat.unread_count %}d-none{% endif %}"
data-unread>+<span data-unread-count>{{ chat.unread_count|default:0 }}</span></span>
</div>
<div class="feed-sub mt-1">
{% if last %}
<span class="text-secondary">
<span data-last-time>{{ last.created_at|date:"d.m.Y H:i" }}</span> ·
<strong data-last-author>{{ last.author.get_full_name|default:last.author.username }}</strong>:
<span data-last-preview>{{ last.content|default:"(вложение)"|truncatechars:120 }}</span>
</span>
{% else %}
<span class="text-secondary">Сообщений пока нет.</span>
{% endif %}
</div>
</div>
{# Меню действий #}
<div class="chat-actions-menu position-relative" style="z-index:2;">
<button class="btn btn-sm btn-link text-secondary p-1" type="button" data-chat-menu-btn data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#" data-action="pin"><i class="bi-pin me-2"></i>Закрепить</a></li>
<li><a class="dropdown-item" href="#" data-action="notifications"><i class="bi-bell me-2"></i>Уведомления</a></li>
<li><a class="dropdown-item" href="#" data-action="edit"><i class="bi-pencil me-2"></i>Редактировать</a></li>
<li><a class="dropdown-item" href="#" data-action="manage"><i class="bi-gear me-2"></i>Управление</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" href="#" data-action="delete"><i class="bi-trash me-2"></i>Удалить</a></li>
</ul>
</div>
</div>
{% endwith %}
{% endif %}
{% endfor %}
</div>
</div>
{# ===== 6) ОБЪЯВЛЕНИЯ ===== #}
<div class="feed-card chat-section mb-3" data-sec="announcement">
<div class="feed-hd">
<div class="feed-ava" style="width:44px;height:44px;"><i class="bi-megaphone"></i></div>
<div class="flex-grow-1 feed-meta">
<h6 class="mb-0 text-secondary">Объявления</h6>
<span class="small text-secondary" data-empty-note style="display:none;">Нет объявлений</span>
</div>
</div>
<div id="sec-announcement" class="list-chats">
{% for chat in chats %}
{% if chat.type == 'announcement' %}
{% with last=chat.last_message.0 %}
<div class="chat-row d-flex align-items-start gap-2 py-2 px-2 {% if not forloop.first %}border-top{% endif %}"
data-chat-id="{{ chat.pk }}"
data-type="announcement"
data-last-ts="{% if last %}{{ last.created_at|date:'U' }}000{% else %}{{ chat.created_at|date:'U' }}000{% endif %}"
data-haystack="{{ chat.name }} {{ chat.description }}">
<div class="feed-ava" style="width:44px;height:44px;">
{% if chat.avatar %}
<img src="{{ chat.avatar.url }}" alt="{{ chat.name }}" class="chat-avatar" loading="lazy">
{% else %}
<i class="bi-megaphone"></i>
{% endif %}
</div>
<div class="flex-grow-1 position-relative">
<div class="d-flex align-items-center gap-2 flex-wrap">
<a href="{% url 'communications:chat_detail' chat.pk %}" class="feed-author text-decoration-none stretched-link">
{{ chat.name }}
</a>
<span class="badge bg-light text-dark border">
<i class="bi-chat-left-text"></i> {{ chat.messages.count }}
</span>
<span class="badge bg-danger-subtle text-danger-emphasis border border-danger-subtle {% if not chat.unread_count %}d-none{% endif %}"
data-unread>+<span data-unread-count>{{ chat.unread_count|default:0 }}</span></span>
</div>
<div class="feed-sub mt-1">
{% if last %}
<span class="text-secondary">
<span data-last-time>{{ last.created_at|date:"d.m.Y H:i" }}</span> ·
<strong data-last-author>{{ last.author.get_full_name|default:last.author.username }}</strong>:
<span data-last-preview>{{ last.content|default:"(вложение)"|truncatechars:120 }}</span>
</span>
{% else %}
<span class="text-secondary">Сообщений пока нет.</span>
{% endif %}
</div>
</div>
{# Меню действий #}
<div class="chat-actions-menu position-relative" style="z-index:2;">
<button class="btn btn-sm btn-link text-secondary p-1" type="button" data-chat-menu-btn data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#" data-action="pin"><i class="bi-pin me-2"></i>Закрепить</a></li>
<li><a class="dropdown-item" href="#" data-action="notifications"><i class="bi-bell me-2"></i>Уведомления</a></li>
<li><a class="dropdown-item" href="#" data-action="edit"><i class="bi-pencil me-2"></i>Редактировать</a></li>
<li><a class="dropdown-item" href="#" data-action="manage"><i class="bi-gear me-2"></i>Управление</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" href="#" data-action="delete"><i class="bi-trash me-2"></i>Удалить</a></li>
</ul>
</div>
</div>
{% endwith %}
{% endif %}
@ -221,14 +465,204 @@
</div>
</div>
{# ===== МОДАЛКА СОЗДАНИЯ ЧАТА ===== #}
<div class="modal fade" id="chatCreateModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Создать чат</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
{# Выбор типа чата #}
<div class="mb-3">
<label for="chatTypeSelect" class="form-label">Тип чата</label>
<select class="form-select" id="chatTypeSelect">
<option value="private">Личный чат</option>
<option value="group">Группа</option>
<option value="channel">Канал</option>
<option value="announcement">Объявления</option>
</select>
</div>
{# Поля для группы/канала/объявлений #}
<div id="groupFieldsSection" style="display:none;">
<div class="mb-3">
<label for="chatNameInput" class="form-label">Название чата <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="chatNameInput" placeholder="Введите название">
</div>
<div class="mb-3">
<label for="chatDescInput" class="form-label">Описание</label>
<textarea class="form-control" id="chatDescInput" rows="3" placeholder="Краткое описание чата"></textarea>
</div>
</div>
{# Выбор участников (для личного чата и группы) #}
<div id="recipientSection">
<label class="form-label">Выберите участников</label>
<div id="chatRecipients" class="recipient-picker" data-api-employees="{% url 'api:v1:employees-list' %}">
<div class="rp-selected" aria-live="polite"></div>
<div class="rp-search mb-2">
<input type="search" class="form-control" placeholder="Найти сотрудника (имя, почта, телефон)">
</div>
<div class="rp-results" role="listbox" aria-label="Сотрудники"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" type="button" id="createChatBtn"><i class="bi-check-lg"></i> Создать</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
</div>
</div>
</div>
</div>
<script type="module">
import { initSearchFilter } from "{% static 'js/components/searchFilterHandler.js' %}";
import { RecipientPicker } from "{% static 'js/components/recipientPickerHandler.js' %}";
// Получаем токен доступа
const accessMeta = document.querySelector('meta[name="api-access"]');
const ACCESS = accessMeta ? accessMeta.content : '';
const headersAuth = ACCESS ? { 'Authorization': 'Bearer ' + ACCESS } : {};
// Инициализация RecipientPicker для создания чата
const chatPicker = new RecipientPicker(
document.getElementById('chatRecipients'),
{
headers: headersAuth,
apiUrl: "{% url 'api:v1:employees-list' %}",
maxSelections: 1 // По умолчанию для личного чата
}
);
// Переключение типа чата
const chatTypeSelect = document.getElementById('chatTypeSelect');
const recipientSection = document.getElementById('recipientSection');
const groupFieldsSection = document.getElementById('groupFieldsSection');
chatTypeSelect?.addEventListener('change', function() {
const type = this.value;
if(type === 'private') {
recipientSection.style.display = 'block';
groupFieldsSection.style.display = 'none';
chatPicker.setMaxSelections(1);
} else if(type === 'group') {
recipientSection.style.display = 'block';
groupFieldsSection.style.display = 'block';
chatPicker.setMaxSelections(100);
} else {
// channel, announcement - только название/описание
recipientSection.style.display = 'none';
groupFieldsSection.style.display = 'block';
}
});
// Обработка создания чата
document.getElementById('createChatBtn')?.addEventListener('click', async () => {
const type = chatTypeSelect.value;
const csrftoken = document.querySelector('[name=csrfmiddlewaretoken]').value;
if(type === 'private') {
// Создание личного чата
const selected = chatPicker.getSelected();
if (selected.length === 0) {
alert('Выберите сотрудника для чата');
return;
}
const employeeId = selected[0].id;
window.location.href = "{% url 'communications:start_private_chat' 0 %}".replace('/0/', `/${employeeId}/`);
} else {
// Создание group/channel/announcement через API
const nameInput = document.getElementById('chatNameInput');
const descInput = document.getElementById('chatDescInput');
const name = nameInput?.value.trim() || '';
const description = descInput?.value.trim() || '';
if(!name) {
alert('Введите название чата');
return;
}
// Собираем participant_ids для группового чата
let participant_ids = [];
if(type === 'group') {
const selected = chatPicker.getSelected();
participant_ids = selected.map(s => s.id);
}
const res = await fetch('/communications/api/chat/create/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrftoken
},
body: JSON.stringify({
type: type,
name: name,
description: description,
participant_ids: participant_ids
})
});
if(res.ok) {
const data = await res.json();
window.location.href = `/communications/chat/${data.chat_id}/`;
} else {
const err = await res.json();
alert('Ошибка: ' + (err.error || 'Неизвестная ошибка'));
}
}
});
initSearchFilter({
// Инициализация поиска и фильтрации
const filterApi = initSearchFilter({
inputId: 'chatSearch',
clearSelector: '.ios-search-clear',
sectionSelector: '.chat-section',
rowSelector: '.chat-row'
});
// Обработка селекта фильтра типа чата
const chatTypeFilter = document.getElementById('chatTypeFilter');
const applyBtn = document.getElementById('applyChatFilter');
const resetBtn = document.getElementById('resetChatFilter');
function applyTypeFilter() {
const selectedType = chatTypeFilter?.value || 'all';
const sections = document.querySelectorAll('.chat-section');
sections.forEach(section => {
const sectionType = section.dataset.sec;
if (selectedType === 'all' || selectedType === sectionType) {
section.style.display = '';
} else {
section.style.display = 'none';
}
});
// Переприменяем поиск после изменения видимости секций
if (filterApi && filterApi.reapply) {
filterApi.reapply();
}
}
function resetFilter() {
if(chatTypeFilter) chatTypeFilter.value = 'all';
applyTypeFilter();
}
applyBtn?.addEventListener('click', applyTypeFilter);
resetBtn?.addEventListener('click', resetFilter);
// Применяем фильтр при загрузке
applyTypeFilter();
</script>
{# Подключаем chat-list-enhanced.js для WebSocket и контекстного меню #}
<script src="{% static 'js/chat-list-enhanced.js' %}"></script>
{% endblock %}

View File

@ -0,0 +1,26 @@
{# Фильтры для страницы документов #}
<form method="get" class="row g-2 align-items-end">
<div class="col-12 col-sm-4">
<label class="form-label mb-1">Область</label>
<select name="scope" class="form-select">
<option value="mine" {% if scope == 'mine' %}selected{% endif %}>Мои</option>
<option value="all" {% if scope == 'all' %}selected{% endif %}>Все</option>
</select>
</div>
<div class="col-12 col-sm-4">
<label class="form-label mb-1">Статус ознакомления</label>
<select name="ack_status" class="form-select">
<option value="">— Любой —</option>
<option value="acked" {% if filters.ack_status == 'acked' %}selected{% endif %}>Ознакомился</option>
<option value="not_acked" {% if filters.ack_status == 'not_acked' %}selected{% endif %}>Не ознакомился</option>
</select>
</div>
<div class="col-12 col-sm-4 d-flex justify-content-end gap-2">
<button class="btn btn-outline-primary d-flex align-items-center justify-content-center gap-1" type="submit">
<i class="bi-funnel"></i><span>Применить</span>
</button>
<a class="btn btn-outline-secondary d-flex align-items-center justify-content-center" href="?" title="Сбросить фильтры">
<i class="bi-x-lg"></i>
</a>
</div>
</form>

View File

@ -16,9 +16,9 @@
{# ===== НОВАЯ ШАПКА С КОМПОНЕНТОМ ===== #}
{% if perms.documents.add_document %}
{% include "includes/components/page_header.html" with icon="bi-file-earmark-text" title="Документы" no_search=True scope_urls=scope_urls scope=scope create_modal="docCreateModal" create_text="Создать документ" create_icon="bi-plus-lg" %}
{% include "includes/components/page_header.html" with icon="bi-file-earmark-text" title="Документы" no_search=True has_filters=True filters_target="docFilters" filters_template="documents/_filters.html" create_as_card=True create_modal="docCreateModal" create_text="Создать документ" %}
{% else %}
{% include "includes/components/page_header.html" with icon="bi-file-earmark-text" title="Документы" no_search=True scope_urls=scope_urls scope=scope create_modal='' create_text="Создать документ" create_icon="bi-plus-lg" %}
{% include "includes/components/page_header.html" with icon="bi-file-earmark-text" title="Документы" no_search=True has_filters=True filters_target="docFilters" filters_template="documents/_filters.html" create_as_card=True create_modal='' create_text="Создать документ" %}
{% endif %}
{# ===== Карточка со списком ===== #}

View File

@ -23,10 +23,11 @@
{% include "includes/components/page_header.html" with icon="bi-diagram-3" title="Отделы" count=departments|length count_label="Всего:" search_id="deptFilter" search_placeholder="Поиск по названию или руководителю" %}
{% endif %}
<div id="deptList" class="list-view feed-stream">
{# поддержка обоих форматов: dict(results) или list #}
{% with rows=departments.results|default:departments %}
{% for dept in rows %}
<div class="feed-card mb-3">
<div id="deptList" class="list-view feed-stream">
{# поддержка обоих форматов: dict(results) или list #}
{% with rows=departments.results|default:departments %}
{% for dept in rows %}
{% with h=dept.head emp=dept.employees_count|default:0 %}
{% url 'employees:department_detail' dept.id as dept_url %}
<article class="dept-row list-row"
@ -52,7 +53,6 @@
</div>
</div>
{% if dept_url %}
<i class="bi-chevron-right text-secondary"></i>
{% endif %}
</header>
</article>
@ -62,6 +62,26 @@
{% endfor %}
{% endwith %}
</div>
</div>
{# Пагинация #}
{% if next_url or prev_url %}
<div class="d-flex justify-content-between align-items-center mt-3">
<a class="btn btn-outline-secondary {% if not prev_url %}disabled{% endif %}"
href="{{ prev_url|default:'#' }}"
{% if not prev_url %}aria-disabled="true"{% endif %}>
<i class="bi-arrow-left"></i> Назад
</a>
{% if count %}
<span class="text-body-secondary small">Всего: {{ count }}</span>
{% endif %}
<a class="btn btn-outline-secondary {% if not next_url %}disabled{% endif %}"
href="{{ next_url|default:'#' }}"
{% if not next_url %}aria-disabled="true"{% endif %}>
Вперёд <i class="bi-arrow-right"></i>
</a>
</div>
{% endif %}
<div class="mt-3 text-secondary small">
Всего сотрудников: <span id="empTotal">0</span>

View File

@ -21,9 +21,10 @@
{# ===== НОВАЯ ШАПКА С КОМПОНЕНТОМ ===== #}
{% include "includes/components/page_header.html" with icon="bi-people" title="Сотрудники" count=employees|length count_label=count_label search_id="empFilter" search_placeholder="Поиск по ФИО, должности или отделу" has_filters=True filters_target="empFilters" filters_template="employees/_filters.html" create_as_card=True create_modal="createEmployeeModal" create_text="Создать профиль" %}
<div id="empList" class="list-view feed-stream">
{% if employees %}
{% for emp in employees %}
<div class="feed-card mb-3">
<div id="empList" class="list-view feed-stream">
{% if employees %}
{% for emp in employees %}
<article class="emp-row list-row"
data-name="{{ emp.last_name }} {{ emp.first_name }} {{ emp.patronymic }} {% firstof emp.position.name emp.position_label '' %}"
data-depts="{% if emp.departments %}{% for d in emp.departments %}{% if d.name %}{{ d.name }}{% if not forloop.last %}, {% endif %}{% endif %}{% endfor %}{% elif is_filtered_by_department and current_department %}{{ current_department.name }}{% endif %}">
@ -97,19 +98,26 @@
{% else %}
<div class="alert alert-info shadow-sm">Нет сотрудников.</div>
{% endif %}
</div>
</div>
{% if next_page or prev_page %}
<nav class="mt-3">
<ul class="pagination">
<li class="page-item {% if not prev_page %}disabled{% endif %}">
<a class="page-link" href="?page={{ prev_page }}{% if q %}&q={{ q|urlencode }}{% endif %}">Назад</a>
</li>
<li class="page-item {% if not next_page %}disabled{% endif %}">
<a class="page-link" href="?page={{ next_page }}{% if q %}&q={{ q|urlencode }}{% endif %}">Вперёд</a>
</li>
</ul>
</nav>
{# Пагинация #}
{% if next_url or prev_url %}
<div class="d-flex justify-content-between align-items-center mt-3">
<a class="btn btn-outline-secondary {% if not prev_url %}disabled{% endif %}"
href="{{ prev_url|default:'#' }}"
{% if not prev_url %}aria-disabled="true"{% endif %}>
<i class="bi-arrow-left"></i> Назад
</a>
{% if count %}
<span class="text-body-secondary small">Всего: {{ count }}</span>
{% endif %}
<a class="btn btn-outline-secondary {% if not next_url %}disabled{% endif %}"
href="{{ next_url|default:'#' }}"
{% if not next_url %}aria-disabled="true"{% endif %}>
Вперёд <i class="bi-arrow-right"></i>
</a>
</div>
{% endif %}
</div>

View File

@ -124,7 +124,6 @@
</div>
{% if emp.id %}
<i class="bi-chevron-right text-secondary ms-2"></i>
{% endif %}
</header>
</article>

View File

@ -79,9 +79,7 @@
<div class="feed-hd">
</div>
<div id="feedList">
{% if page %}
{% include "feed/_feed_cards.html" with items=page.object_list %}
{% elif posts %}
{% if posts %}
{% include "feed/_feed_cards.html" with items=posts %}
{% else %}
<div class="alert alert-info shadow-sm mt-2">Пока нет новостей.</div>
@ -89,9 +87,22 @@
</div>
</section>
{% if page %}
<div class="mt-4">
{% bootstrap_pagination page %}
{# Пагинация #}
{% if next_url or prev_url %}
<div class="d-flex justify-content-between align-items-center mt-3">
<a class="btn btn-outline-secondary {% if not prev_url %}disabled{% endif %}"
href="{{ prev_url|default:'#' }}"
{% if not prev_url %}aria-disabled="true"{% endif %}>
<i class="bi-arrow-left"></i> Назад
</a>
{% if count %}
<span class="text-body-secondary small">Всего: {{ count }}</span>
{% endif %}
<a class="btn btn-outline-secondary {% if not next_url %}disabled{% endif %}"
href="{{ next_url|default:'#' }}"
{% if not next_url %}aria-disabled="true"{% endif %}>
Вперёд <i class="bi-arrow-right"></i>
</a>
</div>
{% endif %}

View File

@ -20,20 +20,6 @@
{# Правая часть - кнопки и поиск #}
<div class="ms-auto d-flex align-items-center gap-2">
{# Переключатель области (Мои/Все) #}
{% if scope_urls %}
<div class="btn-group page-scope-switch" role="group" aria-label="Область">
<a href="{{ scope_urls.mine }}"
class="btn btn-outline-secondary btn-sm {% if scope == 'mine' %}active{% endif %}">
Мои
</a>
<a href="{{ scope_urls.all }}"
class="btn btn-outline-secondary btn-sm {% if scope == 'all' %}active{% endif %}">
Все
</a>
</div>
{% endif %}
{# Дополнительные кнопки (slot) #}
{% if extra_buttons %}
{{ extra_buttons }}

View File

@ -1,12 +1,13 @@
{% load static %}
<link rel="stylesheet" href="{% static 'css/components/navbar.css' %}">
<script src="{% static 'js/navbar-mobile-search.js' %}" defer></script>
<nav class="navbar navbar-expand bg-primary app-navbar" data-bs-theme="dark">
<div class="container-fluid align-items-center">
{% if user.is_authenticated %}
<!-- Моб. меню (иконка без подложки) -->
<button class="menu-toggle d-lg-none me-2" type="button"
<button class="menu-toggle d-lg-none me-2 navbar-icon" type="button"
data-bs-toggle="offcanvas" data-bs-target="#sidebar" aria-controls="sidebar"
aria-label="Открыть меню">
<i class="bi-list fs-4"></i>
@ -14,7 +15,7 @@
{% endif %}
{% load static %}
<a class="navbar-brand d-flex align-items-center gap-2 me-3" href="/">
<a class="navbar-brand d-flex align-items-center gap-2 me-3 navbar-brand-item" href="/">
<img src="{% static BRAND_LOGO %}"
alt="{{ BRAND_NAME }}"
width="28" height="28"
@ -25,67 +26,53 @@
{% if user.is_authenticated %}
<!-- Группа: поиск + колокол -->
<div class="d-flex align-items-center ms-4">
<!-- Поиск компактный рядом с логотипом -->
<form class="d-flex align-items-center me-3" role="search"
<div class="d-flex align-items-center ms-4 gap-3">
<!-- Поиск: иконка на мобильном, поле на десктопе -->
<form class="d-flex align-items-center mobile-search-form" role="search"
action="{% url 'search:search' %}" method="get" autocomplete="off">
<div class="position-relative ios-search" style="max-width: clamp(160px, 28vw, 300px);">
<!-- Мобильная версия: иконка или поле -->
<button type="button" class="btn btn-link text-white p-0 d-md-none mobile-search-toggle" aria-label="Поиск">
<i class="bi-search fs-5"></i>
</button>
<div class="mobile-search-input d-none align-items-center gap-2">
<input
class="form-control form-control-sm rounded-pill bg-white bg-opacity-10 border-0 text-white shadow-sm"
type="search" name="q" placeholder="Поиск…" aria-label="Поиск" id="mobileSearchInput"
style="width: 200px; height: 36px; padding-left: 12px;">
<button type="submit" class="btn btn-link text-white p-0" aria-label="Найти">
<i class="bi-arrow-right-circle fs-5"></i>
</button>
</div>
<!-- Десктопная версия: поле -->
<div class="position-relative ios-search d-none d-md-block" style="max-width: clamp(160px, 28vw, 300px);">
<i class="bi-search"></i>
<input
class="form-control form-control-sm rounded-pill ios-search-input bg-white bg-opacity-10 border-0 text-white shadow-sm"
type="search" name="q" placeholder="Поиск…" aria-label="Поиск"
type="search" name="q" placeholder="Поиск…" aria-label="Поиск" id="desktopSearchInput"
style="width: 100%; height: 36px;">
</div>
</form>
<!-- Уведомления: только иконка + плотный бейдж -->
<div class="dropdown">
<button class="btn btn-link text-white p-0" data-bs-toggle="dropdown"
aria-expanded="false" title="Уведомления" aria-label="Уведомления">
<div class="position-relative navbar-icon" id="notificationBell">
<button class="btn btn-link text-white p-0"
id="notificationToggle"
type="button"
title="Уведомления"
aria-label="Уведомления">
<span class="bell-wrapper">
<i class="bi-bell fs-5"></i>
{% if not user.telegram or not user.whatsapp or not user.wechat %}
<span class="bell-dot">!</span>
{% endif %}
<span class="bell-dot d-none" id="notificationBadge">0</span>
</span>
</button>
<ul class="dropdown-menu dropdown-menu-end rounded-3 shadow-sm" style="min-width:260px;">
<li class="small text-muted px-3 py-2">Мессенджеры</li>
<li class="px-3 py-1">
{% if user.telegram %}
<span class="text-success"><i class="bi-telegram"></i> Telegram привязан</span>
{% else %}
<a href="{{ tg_link|default:'#' }}" class="text-danger d-block" rel="noopener">
<i class="bi-telegram"></i> Привязать Telegram
</a>
{% endif %}
</li>
<li class="px-3 py-1">
{% if user.whatsapp %}
<span class="text-success"><i class="bi-whatsapp"></i> WhatsApp привязан</span>
{% else %}
<a href="https://wa.me/?text=Привязка+WhatsApp" class="text-danger d-block" rel="noopener">
<i class="bi-whatsapp"></i> Привязать WhatsApp
</a>
{% endif %}
</li>
<li class="px-3 py-1">
{% if user.wechat %}
<span class="text-success"><i class="bi-wechat"></i> WeChat привязан</span>
{% else %}
<span class="text-danger d-block"><i class="bi-wechat"></i> Привязать WeChat (вручную)</span>
{% endif %}
</li>
<li><hr class="dropdown-divider"></li>
<li class="small text-muted px-3 py-2">Уведомления (скоро)</li>
</ul>
</div>
</div>
{% endif %}
<div class="ms-auto d-flex align-items-center">
<div class="ms-auto d-flex align-items-center navbar-right">
{% if user.is_authenticated %}
<div class="dropdown">
<div class="dropdown navbar-icon">
<a href="#" class="d-flex align-items-center text-white text-decoration-none dropdown-toggle no-caret"
id="userDropdown" data-bs-toggle="dropdown" aria-expanded="false" style="min-width:40px;">
{% if user.avatar %}
@ -121,3 +108,28 @@
</div>
</nav>
<!-- Dropdown меню уведомлений (вне navbar для правильного z-index) -->
{% if user.is_authenticated %}
<div class="notification-dropdown-menu" id="notificationDropdown" style="display: none;">
<div class="notification-dropdown-header">
<span class="fw-semibold">Уведомления</span>
<a href="{% url 'notifications:list' %}" class="btn btn-sm btn-link">Все</a>
</div>
<div class="notification-dropdown-body" id="notificationList">
<div class="text-center py-3 text-muted">
<i class="bi-bell-slash"></i>
<div class="small">Нет новых уведомлений</div>
</div>
</div>
<div class="notification-dropdown-divider"></div>
<div class="px-3 py-2">
<a href="{% url 'notifications:settings' %}" class="btn btn-sm btn-outline-primary w-100">
<i class="bi-gear me-1"></i>Настройки уведомлений
</a>
</div>
</div>
{% endif %}

View File

@ -0,0 +1,76 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Уведомления{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="row">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<h4 class="mb-0"><i class="bi-bell me-2"></i>Уведомления</h4>
<button class="btn btn-sm btn-light" id="markAllRead">
<i class="bi-check-all me-1"></i>Отметить все прочитанными
</button>
</div>
<div class="card-body">
<div class="d-flex gap-2 mb-3 flex-wrap">
<button class="btn btn-sm btn-outline-secondary category-filter active" data-category="all">
Все
</button>
<button class="btn btn-sm btn-outline-primary category-filter" data-category="communications">
<i class="bi-chat-dots me-1"></i>Коммуникации
</button>
<button class="btn btn-sm btn-outline-info category-filter" data-category="documents">
<i class="bi-file-earmark-text me-1"></i>Документы
</button>
<button class="btn btn-sm btn-outline-success category-filter" data-category="requests">
<i class="bi-clipboard-check me-1"></i>Заявления
</button>
<button class="btn btn-sm btn-outline-warning category-filter" data-category="calendar">
<i class="bi-calendar-event me-1"></i>Календарь
</button>
</div>
<div id="notificationsList">
<div class="text-center py-5 text-muted">
<i class="bi-bell-slash fs-1"></i>
<p class="mt-2">Уведомлений пока нет</p>
</div>
</div>
<div id="loadingSpinner" class="text-center py-3 d-none">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
// Базовая демонстрационная страница
// Полный функционал будет реализован в следующих этапах
document.addEventListener('DOMContentLoaded', function() {
console.log('Notification page initialized');
console.log('WebSocket URL: ws://' + window.location.host + '/ws/notifications/');
const categoryFilters = document.querySelectorAll('.category-filter');
categoryFilters.forEach(btn => {
btn.addEventListener('click', function() {
categoryFilters.forEach(b => b.classList.remove('active'));
this.classList.add('active');
console.log('Filter changed to:', this.dataset.category);
});
});
document.getElementById('markAllRead').addEventListener('click', function() {
console.log('Mark all as read clicked');
alert('Функция "Отметить все прочитанными" будет реализована в следующем этапе');
});
});
</script>
{% endblock %}

View File

@ -0,0 +1,83 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Уведомления{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/notifications/notifications.css' %}">
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi-bell"></i> Уведомления</h2>
<div class="btn-group">
<button id="markAllReadBtn" class="btn btn-outline-primary">
<i class="bi-check-all"></i> Отметить все прочитанными
</button>
<a href="{% url 'notifications:settings' %}" class="btn btn-outline-secondary">
<i class="bi-gear"></i> Настройки
</a>
</div>
</div>
<!-- Фильтры и поиск -->
<div class="card mb-4">
<div class="card-body">
<div class="row g-3">
<div class="col-md-3">
<label for="categoryFilter" class="form-label">Категория</label>
<select id="categoryFilter" class="form-select">
<option value="">Все категории</option>
<option value="communications">💬 Коммуникации</option>
<option value="documents">📄 Документы</option>
<option value="requests">📋 Заявления</option>
<option value="calendar">📅 Календарь</option>
<option value="department">👥 Отдел</option>
<option value="profile">👤 Профиль</option>
<option value="feed">📰 Новости</option>
<option value="system">⚙️ Система</option>
</select>
</div>
<div class="col-md-3">
<label for="statusFilter" class="form-label">Статус</label>
<select id="statusFilter" class="form-select">
<option value="">Все</option>
<option value="false">Непрочитанные</option>
<option value="true">Прочитанные</option>
</select>
</div>
<div class="col-md-6">
<label for="notificationSearch" class="form-label">Поиск</label>
<div class="input-group">
<input type="text" id="notificationSearch" class="form-control"
placeholder="Поиск по заголовку или тексту...">
<button id="searchBtn" class="btn btn-primary">
<i class="bi-search"></i> Найти
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Список уведомлений -->
<div id="notificationListContainer">
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
</div>
</div>
</div>
</div>
</div>
{% csrf_token %}
{% endblock %}
{% block extra_js %}
<script src="{% static 'js/notifications/notification-list.js' %}"></script>
{% endblock %}

View File

@ -0,0 +1,94 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Настройки уведомлений{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="row">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h4 class="mb-0"><i class="bi-gear me-2"></i>Настройки уведомлений</h4>
</div>
<div class="card-body">
<p class="text-muted">
Настройте, какие уведомления вы хотите получать и по каким каналам.
</p>
<div class="alert alert-info">
<i class="bi-info-circle me-2"></i>
<strong>Этап 1 завершен!</strong> Интерфейс настроек будет реализован в этапе 4.
</div>
<h5 class="mt-4">Доступные категории уведомлений:</h5>
<ul class="list-group">
<li class="list-group-item d-flex align-items-center">
<i class="bi-chat-dots text-primary me-3 fs-4"></i>
<div class="flex-grow-1">
<strong>Коммуникации</strong>
<div class="small text-muted">Сообщения в чатах, упоминания, ответы</div>
</div>
</li>
<li class="list-group-item d-flex align-items-center">
<i class="bi-file-earmark-text text-info me-3 fs-4"></i>
<div class="flex-grow-1">
<strong>Документы</strong>
<div class="small text-muted">Документы на ознакомление, комментарии</div>
</div>
</li>
<li class="list-group-item d-flex align-items-center">
<i class="bi-clipboard-check text-success me-3 fs-4"></i>
<div class="flex-grow-1">
<strong>Заявления</strong>
<div class="small text-muted">Новые заявки, изменения статусов</div>
</div>
</li>
<li class="list-group-item d-flex align-items-center">
<i class="bi-calendar-event text-warning me-3 fs-4"></i>
<div class="flex-grow-1">
<strong>Календарь</strong>
<div class="small text-muted">Новые события, напоминания</div>
</div>
</li>
<li class="list-group-item d-flex align-items-center">
<i class="bi-people text-secondary me-3 fs-4"></i>
<div class="flex-grow-1">
<strong>Отдел</strong>
<div class="small text-muted">Изменения в отделе, объявления</div>
</div>
</li>
<li class="list-group-item d-flex align-items-center">
<i class="bi-person-circle text-dark me-3 fs-4"></i>
<div class="flex-grow-1">
<strong>Профиль</strong>
<div class="small text-muted">Изменения профиля, безопасность</div>
</div>
</li>
<li class="list-group-item d-flex align-items-center">
<i class="bi-newspaper text-danger me-3 fs-4"></i>
<div class="flex-grow-1">
<strong>Новости</strong>
<div class="small text-muted">Новые посты, комментарии, реакции</div>
</div>
</li>
<li class="list-group-item d-flex align-items-center">
<i class="bi-gear text-muted me-3 fs-4"></i>
<div class="flex-grow-1">
<strong>Система</strong>
<div class="small text-muted">Технические работы, важные объявления</div>
</div>
</li>
</ul>
<div class="mt-4">
<a href="{% url 'notifications:list' %}" class="btn btn-secondary">
<i class="bi-arrow-left me-1"></i>Назад к уведомлениям
</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,108 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Настройки уведомлений{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/notifications/notifications.css' %}">
<style>
.settings-icon {
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(135deg, var(--bs-primary) 0%, var(--bs-info) 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.5rem;
}
.form-check-input:disabled ~ .form-check-label {
opacity: 0.5;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi-gear"></i> Настройки уведомлений</h2>
<a href="{% url 'notifications:list' %}" class="btn btn-outline-secondary">
<i class="bi-arrow-left"></i> К уведомлениям
</a>
</div>
<!-- Общие настройки -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi-sliders"></i> Общие настройки</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="soundToggle" checked>
<label class="form-check-label" for="soundToggle">
<i class="bi-volume-up"></i> <strong>Звуковые уведомления</strong>
<small class="d-block text-muted">Воспроизводить звук при получении уведомления</small>
</label>
</div>
</div>
<div class="col-md-6">
<div class="alert alert-info mb-0">
<i class="bi-info-circle"></i>
<strong>Браузерные уведомления</strong> уже включены для вашего браузера.
Разрешите уведомления в настройках браузера, если они не работают.
</div>
</div>
</div>
</div>
</div>
<!-- Настройки по категориям -->
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi-list-check"></i> Настройки по категориям</h5>
</div>
<div class="card-body">
<div id="settingsContainer">
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
</div>
</div>
</div>
</div>
<!-- Информация -->
<div class="card mt-4">
<div class="card-body">
<h6 class="mb-3"><i class="bi-question-circle"></i> Справка</h6>
<dl class="row mb-0">
<dt class="col-sm-3">Веб-уведомления</dt>
<dd class="col-sm-9">Отображаются в интерфейсе системы, в колокольчике и списке уведомлений</dd>
<dt class="col-sm-3">Email</dt>
<dd class="col-sm-9">Отправка уведомлений на вашу почту (будет доступно в следующих версиях)</dd>
<dt class="col-sm-3">Telegram</dt>
<dd class="col-sm-9">Отправка в Telegram бот (будет доступно в следующих версиях)</dd>
<dt class="col-sm-3">Браузерные уведомления</dt>
<dd class="col-sm-9 mb-0">Всплывающие уведомления операционной системы. Управляются в настройках вашего браузера.</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
{% csrf_token %}
{% endblock %}
{% block extra_js %}
<script src="{% static 'js/notifications/notification-settings.js' %}"></script>
{% endblock %}

View File

@ -1,6 +1,13 @@
{# Фильтры для страницы заявлений #}
<form method="get" class="row g-2 align-items-end">
<div class="col-12 col-sm-4">
<div class="col-12 col-sm-3">
<label class="form-label mb-1">Область</label>
<select name="view" class="form-select">
<option value="mine" {% if scope == 'mine' %}selected{% endif %}>Мои</option>
<option value="all" {% if scope == 'all' %}selected{% endif %}>Все</option>
</select>
</div>
<div class="col-12 col-sm-3">
<label class="form-label mb-1">Тип</label>
<select name="type" class="form-select">
<option value="">— Любой —</option>
@ -11,7 +18,7 @@
<option value="other" {% if filters.type == 'other' %}selected{% endif %}>Другое</option>
</select>
</div>
<div class="col-12 col-sm-4">
<div class="col-12 col-sm-3">
<label class="form-label mb-1">Статус</label>
<select name="status" class="form-select">
<option value="">— Любой —</option>
@ -22,12 +29,11 @@
<option value="cancelled" {% if filters.status == 'cancelled' %}selected{% endif %}>Отменено</option>
</select>
</div>
<div class="col-12 col-sm-4 d-flex justify-content-end gap-2">
<input type="hidden" name="view" value="{{ scope }}">
<button class="btn btn-outline-primary d-flex align-items-center justify-content-center" type="submit">
<i class="bi-funnel"></i> Применить
<div class="col-12 col-sm-3 d-flex justify-content-end gap-2">
<button class="btn btn-outline-primary d-flex align-items-center justify-content-center gap-1" type="submit">
<i class="bi-funnel"></i><span>Применить</span>
</button>
<a class="btn btn-outline-secondary d-flex align-items-center justify-content-center" href="?view={{ scope }}">
<a class="btn btn-outline-secondary d-flex align-items-center justify-content-center" href="?" title="Сбросить фильтры">
<i class="bi-x-lg"></i>
</a>
</div>

View File

@ -15,32 +15,20 @@
{# ===== НОВАЯ ШАПКА С КОМПОНЕНТОМ ===== #}
{% if can_process or user.is_staff or user.is_superuser %}
{% include "includes/components/page_header.html" with icon="bi-clipboard-check" title="Заявления" no_search=True scope=scope has_filters=True filters_target="reqFilters" filters_template="requests_app/_filters.html" create_modal="reqCreateModal" create_text="Новое заявление" create_icon="bi-plus-lg" %}
{# Переключатель scope отдельно (вставляется через extra_buttons не работает) #}
<div class="mb-3 d-flex justify-content-end">
<div class="btn-group" role="group" aria-label="Область">
<a href="?view=mine{% if filters.type %}&type={{ filters.type }}{% endif %}{% if filters.status %}&status={{ filters.status }}{% endif %}"
class="btn btn-outline-secondary btn-sm {% if scope == 'mine' %}active{% endif %}">
Мои
</a>
<a href="?view=all{% if filters.type %}&type={{ filters.type }}{% endif %}{% if filters.status %}&status={{ filters.status }}{% endif %}"
class="btn btn-outline-secondary btn-sm {% if scope == 'all' %}active{% endif %}">
Все
</a>
</div>
</div>
{% url 'requests:request_list' as base_url %}
{% include "includes/components/page_header.html" with icon="bi-clipboard-check" title="Заявления" no_search=True scope=scope has_filters=True filters_target="reqFilters" filters_template="requests_app/_filters.html" create_as_card=True create_modal="reqCreateModal" create_text="Создать заявление" %}
{% else %}
{% include "includes/components/page_header.html" with icon="bi-clipboard-check" title="Заявления" no_search=True has_filters=True filters_target="reqFilters" filters_template="requests_app/_filters.html" create_modal="reqCreateModal" create_text="Новое заявление" create_icon="bi-plus-lg" %}
{% include "includes/components/page_header.html" with icon="bi-clipboard-check" title="Заявления" no_search=True has_filters=True filters_target="reqFilters" filters_template="requests_app/_filters.html" create_as_card=True create_modal="reqCreateModal" create_text="Создать заявление" %}
{% endif %}
{# ===== Список ===== #}
{# DEBUG: Фильтры - type: {{ filters.type }}, status: {{ filters.status }}, count: {{ requests|length }} #}
<div id="reqList" class="feed-card mb-3">
{% if requests %}
{% for obj in requests %}
<article class="req-row" id="req-{{ obj.id }}">
<header class="feed-hd">
<div class="feed-ico"><i class="bi-journal-text"></i></div>
<div ></div>
<div class="feed-meta d-flex flex-column">
<div class="d-flex align-items-center flex-wrap gap-2">
<strong>{{ obj.title|default:"Без заголовка" }}</strong>

View File

@ -0,0 +1,24 @@
{# Фильтры для страницы поиска #}
<form method="get" class="row g-2 align-items-end">
<div class="col-12 col-sm-9">
<label class="form-label mb-1">Тип результата</label>
<select name="type" class="form-select" id="searchTypeFilter">
<option value="all">Все ({{ total }})</option>
<option value="post">Публикации ({{ counts.post }})</option>
<option value="employee">Сотрудники ({{ counts.employee }})</option>
<option value="department">Отделы ({{ counts.department }})</option>
<option value="request">Заявления ({{ counts.request }})</option>
<option value="chat">Чаты ({{ counts.chat }})</option>
<option value="message">Сообщения ({{ counts.message }})</option>
<option value="event">События ({{ counts.event }})</option>
</select>
</div>
<div class="col-12 col-sm-3 d-flex justify-content-end gap-2">
<button class="btn btn-outline-primary d-flex align-items-center justify-content-center gap-1" type="button" id="applySearchFilter">
<i class="bi-funnel"></i><span>Применить</span>
</button>
<button type="button" class="btn btn-outline-secondary d-flex align-items-center justify-content-center" id="resetSearchFilter" title="Сбросить фильтры">
<i class="bi-x-lg"></i>
</button>
</div>
</form>

View File

@ -4,6 +4,7 @@
{% block title %}Поиск{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/components/page-header.css' %}">
<link rel="stylesheet" href="{% static 'css/components/ios-search.css' %}">
<link rel="stylesheet" href="{% static 'css/components/feed-cards.css' %}">
{% endblock %}
@ -13,48 +14,8 @@
<div class="row gx-0">
<div class="col-12">
<!-- Заголовок + iOS-поиск -->
<div class="feed-header d-flex align-items-center">
<i class="bi-search fs-2 text-primary"></i>
<h2 class="title mb-0">Поиск</h2>
<div class="ms-auto ios-search">
<i class="bi-search"></i>
<input type="search" id="globalSearch" class="ios-search-input" placeholder="Фильтр по заголовкам" autocomplete="off">
<button type="button" class="ios-search-clear" aria-label="Очистить"><i class="bi-x-lg"></i></button>
</div>
</div>
<!-- Сегментированный фильтр типов -->
<div class="feed-new mb-3">
<div class="d-flex align-items-center gap-2 flex-wrap">
<div class="btn-group" role="group" aria-label="Фильтр результатов">
<input type="radio" class="btn-check" name="srchFilter" id="fltAll" autocomplete="off" checked>
<label class="btn btn-ghost" for="fltAll" data-filter="all">Все ({{ total }})</label>
<input type="radio" class="btn-check" name="srchFilter" id="fltPosts" autocomplete="off">
<label class="btn btn-ghost" for="fltPosts" data-filter="post">Публикации ({{ counts.post }})</label>
<input type="radio" class="btn-check" name="srchFilter" id="fltEmployees" autocomplete="off">
<label class="btn btn-ghost" for="fltEmployees" data-filter="employee">Сотрудники ({{ counts.employee }})</label>
<input type="radio" class="btn-check" name="srchFilter" id="fltDepartments" autocomplete="off">
<label class="btn btn-ghost" for="fltDepartments" data-filter="department">Отделы ({{ counts.department }})</label>
<input type="radio" class="btn-check" name="srchFilter" id="fltRequests" autocomplete="off">
<label class="btn btn-ghost" for="fltRequests" data-filter="request">Заявления ({{ counts.request }})</label>
<input type="radio" class="btn-check" name="srchFilter" id="fltChats" autocomplete="off">
<label class="btn btn-ghost" for="fltChats" data-filter="chat">Чаты ({{ counts.chat }})</label>
<input type="radio" class="btn-check" name="srchFilter" id="fltMessages" autocomplete="off">
<label class="btn btn-ghost" for="fltMessages" data-filter="message">Сообщения ({{ counts.message }})</label>
<input type="radio" class="btn-check" name="srchFilter" id="fltEvents" autocomplete="off">
<label class="btn btn-ghost" for="fltEvents" data-filter="event">События ({{ counts.event }})</label>
</div>
</div>
</div>
<!-- Шапка страницы -->
{% include "includes/components/page_header.html" with icon="bi-search" title="Поиск" count=total count_label="Найдено:" search_id="globalSearch" search_placeholder="Фильтр по заголовкам" no_create=True has_filters=True filters_target="searchFilters" filters_template="search/_filters.html" %}
{% if total > 0 %}
@ -76,7 +37,6 @@
<div class="feed-sub mt-1 text-secondary">{{ post.created_at|date:"d.m.Y H:i" }}</div>
{% if post.body %}<div class="small mt-1 text-body">{{ post.body|truncatewords:35 }}</div>{% endif %}
</div>
<i class="bi-chevron-right text-secondary ms-2 mt-1"></i>
</div>
{% endwith %}
{% endif %}
@ -109,7 +69,6 @@
{{ emp.email }}{% if emp.phone_number %} · {{ emp.phone_number }}{% endif %}
</div>
</div>
<i class="bi-chevron-right text-secondary ms-2 mt-1"></i>
</div>
{% endwith %}
{% endif %}
@ -138,7 +97,6 @@
</div>
{% if dept.description %}<div class="small mt-1 text-body">{{ dept.description|truncatewords:35 }}</div>{% endif %}
</div>
<i class="bi-chevron-right text-secondary ms-2 mt-1"></i>
</div>
{% endwith %}
{% endif %}
@ -161,9 +119,9 @@
<div class="result-row d-flex align-items-start gap-2 py-2 px-2" data-type="request" data-haystack="{% firstof req.title req.get_type_display '' %}">
<div class="feed-ava" style="width:44px;height:44px;"><i class="bi-file-earmark-text"></i></div>
<div class="flex-grow-1 position-relative">
<a href="{% url 'requests_app:request_detail' req.pk %}" class="feed-author text-decoration-none stretched-link">
<div class="feed-author">
{% firstof req.title req.get_type_display "Заявление" %}
</a>
</div>
<div class="feed-sub mt-1 text-secondary">
Автор: {{ req.employee }}{% if req.department %} · Отдел: {{ req.department }}{% endif %} · {{ req.created_at|date:"d.m.Y H:i" }}
</div>
@ -212,7 +170,6 @@
{% endif %}
</a>
</div>
<i class="bi-chevron-right text-secondary ms-2 mt-1"></i>
</div>
{% endwith %}
{% endif %}
@ -294,12 +251,49 @@
<script type="module">
import { initSearchFilter } from "{% static 'js/components/searchFilterHandler.js' %}";
// Инициализация поиска по заголовкам
initSearchFilter({
inputId: 'globalSearch',
clearSelector: '.ios-search-clear',
filterRadioName: 'srchFilter',
sectionSelector: '.section',
rowSelector: '.result-row'
});
// Фильтрация по типу через селект
const typeFilter = document.getElementById('searchTypeFilter');
const applyBtn = document.getElementById('applySearchFilter');
const resetBtn = document.getElementById('resetSearchFilter');
const sections = document.querySelectorAll('.section');
function applyTypeFilter() {
const selectedType = typeFilter.value;
sections.forEach(section => {
const sectionType = section.dataset.sec;
if (selectedType === 'all' || sectionType === selectedType) {
section.style.display = '';
} else {
section.style.display = 'none';
}
});
}
function resetFilter() {
typeFilter.value = 'all';
applyTypeFilter();
}
if (applyBtn) {
applyBtn.addEventListener('click', applyTypeFilter);
}
if (resetBtn) {
resetBtn.addEventListener('click', resetFilter);
}
// Применение при изменении селекта
if (typeFilter) {
typeFilter.addEventListener('change', applyTypeFilter);
}
</script>
{% endblock %}

285
backend/test_all_signals.py Normal file
View File

@ -0,0 +1,285 @@
#!/usr/bin/env python
"""
Полный тест всех signal integrations (без Unicode символов).
"""
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
django.setup()
from django.contrib.auth import get_user_model
from django.utils import timezone
from communications.models import Chat, Message
from requests_app.models import Request, RequestComment
from documents.models import Document, DocumentAcknowledgement
from calendar_app.models import CalendarEvent
from feed.models import Post, Comment
from notifications.models import Notification
Employee = get_user_model()
print("\n" + "="*70)
print(" ПОЛНЫЙ ТЕСТ ВСЕХ NOTIFICATION SIGNALS")
print("="*70 + "\n")
# Создаем пользователей
user1, _ = Employee.objects.get_or_create(
email='fulltest1@test.com',
defaults={
'first_name': 'Иван',
'last_name': 'Иванов',
'phone_number': '+79881111111',
'is_active': True,
'is_staff': True,
'is_superuser': True
}
)
user2, _ = Employee.objects.get_or_create(
email='fulltest2@test.com',
defaults={
'first_name': 'Петр',
'last_name': 'Петров',
'phone_number': '+79882222222',
'is_active': True
}
)
print(f"[INFO] Тестовые пользователи: {user1.email}, {user2.email}\n")
# Очищаем уведомления
Notification.objects.filter(recipient__in=[user1, user2]).delete()
# ============ ТЕСТ 1: COMMUNICATIONS ============
print("="*70)
print(" [1/5] COMMUNICATIONS: Сообщения и упоминания")
print("="*70)
chat = Chat.objects.create(type='private')
chat.participants.add(user1, user2)
print(f"[OK] Чат #{chat.id}")
Message.objects.create(chat=chat, author=user1, content='Тест')
count = Notification.objects.filter(
recipient=user2, notification_type__code='chat_new_message'
).count()
print(f" new_message: {count} уведомление(ий)")
Message.objects.create(chat=chat, author=user1, content=f'@{user2.email} ping')
count = Notification.objects.filter(
recipient=user2, notification_type__code='chat_mention'
).count()
print(f" mention: {count} уведомление(ий)")
msg = Message.objects.create(chat=chat, author=user2, content='Ответ')
Message.objects.create(chat=chat, author=user1, content='Ок', reply_to=msg)
count = Notification.objects.filter(
recipient=user2, notification_type__code='chat_reply'
).count()
print(f" reply: {count} уведомление(ий)")
print("[OK] Communications: ВСЕ ТЕСТЫ ПРОЙДЕНЫ\n")
# ============ ТЕСТ 2: REQUESTS ============
print("="*70)
print(" [2/5] REQUESTS: Заявки и согласования")
print("="*70)
req = Request.objects.create(
employee=user2,
type='vacation',
date_from=timezone.now().date(),
date_to=(timezone.now() + timezone.timedelta(days=7)).date(),
status='pending'
)
print(f"[OK] Заявка #{req.id}")
count = Notification.objects.filter(
notification_type__code='request_new'
).count()
print(f" new_request: {count} уведомление(ий)")
RequestComment.objects.create(
request=req,
author=user1,
text='Комментарий к заявке'
)
count = Notification.objects.filter(
recipient=user2, notification_type__code='request_comment'
).count()
print(f" comment: {count} уведомление(ий)")
req.status = 'approved'
req.approver = user1
req.decided_at = timezone.now()
req.save()
count = Notification.objects.filter(
recipient=user2, notification_type__code='request_approved'
).count()
print(f" approved: {count} уведомление(ий)")
req2 = Request.objects.create(
employee=user2,
type='sick',
date_from=timezone.now().date(),
date_to=timezone.now().date(),
status='rejected',
approver=user1,
decided_at=timezone.now()
)
count = Notification.objects.filter(
recipient=user2, notification_type__code='request_rejected'
).count()
print(f" rejected: {count} уведомление(ий)")
print("[OK] Requests: ВСЕ ТЕСТЫ ПРОЙДЕНЫ\n")
# ============ ТЕСТ 3: DOCUMENTS ============
print("="*70)
print(" [3/5] DOCUMENTS: Документы и подтверждения")
print("="*70)
doc = Document.objects.create(
title='Тестовый документ',
uploaded_by=user1,
sent_to_all=False
)
doc.recipients.add(user2)
print(f"[OK] Документ #{doc.id}")
count = Notification.objects.filter(
recipient=user2, notification_type__code='document_ready'
).count()
print(f" document_ready: {count} уведомление(ий)")
DocumentAcknowledgement.objects.create(
document=doc,
user=user2
)
count = Notification.objects.filter(
recipient=user1, notification_type__code='document_signed_all'
).count()
print(f" document_signed_all: {count} уведомление(ий)")
print("[OK] Documents: ВСЕ ТЕСТЫ ПРОЙДЕНЫ\n")
# ============ ТЕСТ 4: CALENDAR ============
print("="*70)
print(" [4/5] CALENDAR: События и изменения")
print("="*70)
event = CalendarEvent.objects.create(
title='Собрание',
start_date=timezone.now().date(),
end_date=timezone.now().date(),
start_time=timezone.now().time(),
end_time=(timezone.now() + timezone.timedelta(hours=1)).time(),
all_day=False,
created_by=user1
)
print(f"[OK] Событие #{event.id}")
count = Notification.objects.filter(
notification_type__code='event_created'
).count()
print(f" event_created: {count} уведомление(ий)")
event.title = 'Собрание ИЗМЕНЕНО'
event.save()
count = Notification.objects.filter(
notification_type__code='event_changed'
).count()
print(f" event_changed: {count} уведомление(ий)")
event2 = CalendarEvent.objects.create(
title='Отменяемое событие',
start_date=timezone.now().date(),
end_date=timezone.now().date(),
start_time=timezone.now().time(),
end_time=(timezone.now() + timezone.timedelta(hours=1)).time(),
all_day=False,
created_by=user1
)
event2.delete()
count = Notification.objects.filter(
notification_type__code='event_cancelled'
).count()
print(f" event_cancelled: {count} уведомление(ий)")
print("[OK] Calendar: ВСЕ ТЕСТЫ ПРОЙДЕНЫ\n")
# ============ ТЕСТ 5: FEED ============
print("="*70)
print(" [5/5] FEED: Посты и комментарии")
print("="*70)
post = Post.objects.create(
author=user1,
title='Тестовый пост',
body='Тестовый пост для всей компании',
type='company'
)
print(f"[OK] Пост #{post.id}")
count = Notification.objects.filter(
notification_type__code='feed_new_post'
).count()
print(f" feed_new_post: {count} уведомление(ий)")
comment = Comment.objects.create(
post=post,
author=user2,
text='Комментарий к посту'
)
count = Notification.objects.filter(
recipient=user1, notification_type__code='feed_post_comment'
).count()
print(f" feed_post_comment: {count} уведомление(ий)")
# Тест реакции (ручной вызов)
from feed.notification_signals import notify_post_reaction
notify_post_reaction(post, user2)
count = Notification.objects.filter(
recipient=user1, notification_type__code='feed_post_reaction'
).count()
print(f" feed_post_reaction: {count} уведомление(ий)")
print("[OK] Feed: ВСЕ ТЕСТЫ ПРОЙДЕНЫ\n")
# ============ ИТОГОВАЯ СТАТИСТИКА ============
print("="*70)
print(" ИТОГОВАЯ СТАТИСТИКА")
print("="*70)
total = Notification.objects.filter(recipient__in=[user1, user2]).count()
print(f"\nВсего создано уведомлений: {total}")
print("\nРаспределение по типам:")
from django.db.models import Count
stats = Notification.objects.filter(
recipient__in=[user1, user2]
).values('notification_type__code').annotate(
count=Count('id')
).order_by('-count')
for stat in stats:
code = stat['notification_type__code']
count = stat['count']
print(f" {code}: {count}")
print("\n" + "="*70)
print(" ВСЕ 5 МОДУЛЕЙ ПРОТЕСТИРОВАНЫ УСПЕШНО!")
print("="*70 + "\n")

View File

@ -0,0 +1,43 @@
#!/usr/bin/env python
"""
Скрипт для создания тестового уведомления
"""
import os
import sys
import django
# Настройка Django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
django.setup()
from employees.models import Employee
from notifications.services import NotificationService
def main():
"""Создать тестовое уведомление"""
# Получаем первого пользователя
user = Employee.objects.first()
if not user:
print("❌ Нет пользователей в системе!")
sys.exit(1)
# Создаём тестовое уведомление
notification = NotificationService.create_notification(
recipient=user,
notification_type_code='system_announcement',
title='🎉 Тестовое уведомление',
message='Проверка real-time уведомлений через WebSocket. Вы должны увидеть toast справа вверху!',
action_url='/',
)
print(f'✅ Создано уведомление ID={notification.id}')
print(f'👤 Получатель: {user.last_name} {user.first_name}')
print(f'📧 Отправлено на веб: {notification.sent_web}')
print(f'📝 Приоритет: {notification.notification_type.priority}')
print(f'🔔 Категория: {notification.notification_type.category.name}')
print()
print('Проверьте браузер - должен появиться toast и обновиться badge!')
if __name__ == '__main__':
main()

View File

@ -0,0 +1,95 @@
#!/usr/bin/env python
"""
Скрипт для отладки уведомлений
"""
import os
import sys
import django
import logging
# Включаем подробное логирование
logging.basicConfig(level=logging.DEBUG)
# Настройка Django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
django.setup()
from employees.models import Employee
from notifications.services import NotificationService
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync
def main():
"""Создать тестовое уведомление с отладкой"""
print("=" * 60)
print("ТЕСТ СИСТЕМЫ УВЕДОМЛЕНИЙ")
print("=" * 60)
# Проверка Channel Layer
channel_layer = get_channel_layer()
print(f"\n1. Channel Layer: {type(channel_layer).__name__}")
# Получаем пользователя
user = Employee.objects.first()
if not user:
print("❌ Нет пользователей в системе!")
sys.exit(1)
print(f"\n2. Получатель: {user.last_name} {user.first_name} (ID={user.id})")
print(f" Email: {user.email}")
# Создаём уведомление
print("\n3. Создание уведомления...")
notification = NotificationService.create_notification(
recipient=user,
notification_type_code='system_announcement',
title='🔔 Тестовое уведомление',
message='Это тестовое сообщение для проверки WebSocket. Если вы видите это в браузере - система работает!',
action_url='/',
)
if not notification:
print("❌ Не удалось создать уведомление!")
sys.exit(1)
print(f"✅ Уведомление создано: ID={notification.id}")
print(f" Заголовок: {notification.title}")
print(f" Отправлено веб: {notification.sent_web}")
print(f" Дата отправки: {notification.sent_at}")
# Проверка отправки через WebSocket
print(f"\n4. WebSocket группа: notifications_{user.id}")
# Попробуем отправить напрямую
print("\n5. Отправка тестового сообщения через Channel Layer...")
try:
async_to_sync(channel_layer.group_send)(
f'notifications_{user.id}',
{
'type': 'notification_new',
'notification': {
'id': notification.id,
'title': '🧪 Прямое тестовое сообщение',
'message': 'Это сообщение отправлено напрямую через Channel Layer',
'category': 'system',
'icon': 'bi-gear',
'color': 'light',
'priority': 'high',
}
}
)
print("✅ Сообщение отправлено в Channel Layer")
except Exception as e:
print(f"❌ Ошибка отправки: {e}")
print("\n" + "=" * 60)
print("ПРОВЕРЬТЕ БРАУЗЕР!")
print("=" * 60)
print("Вы должны увидеть:")
print(" 1. Toast уведомление справа вверху")
print(" 2. Badge с цифрой в navbar")
print(" 3. В консоли: [Notifications] Received: ...")
print("=" * 60)
if __name__ == '__main__':
main()

View File

@ -0,0 +1,428 @@
#!/usr/bin/env python
"""
Тестовый скрипт для проверки работы notification signals.
Тестирует все 5 модулей:
1. Communications - сообщения, упоминания, ответы
2. Requests - заявки, статусы, комментарии
3. Documents - документы, ознакомления
4. Calendar - события, изменения
5. Feed - публикации, комментарии
Запуск:
python test_notification_signals.py
"""
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
django.setup()
from django.contrib.auth import get_user_model
from communications.models import Chat, Message
from requests_app.models import Request, RequestComment
from documents.models import Document, DocumentAcknowledgement
from calendar_app.models import CalendarEvent
from feed.models import Post, Comment
from notifications.models import Notification
from employees.models import Department
Employee = get_user_model()
# ANSI цвета для красивого вывода
class Colors:
HEADER = '\033[95m'
OKBLUE = '\033[94m'
OKCYAN = '\033[96m'
OKGREEN = '\033[92m'
WARNING = '\033[93m'
FAIL = '\033[91m'
ENDC = '\033[0m'
BOLD = '\033[1m'
UNDERLINE = '\033[4m'
def print_header(text):
print(f"\n{Colors.HEADER}{Colors.BOLD}{'='*60}{Colors.ENDC}")
print(f"{Colors.HEADER}{Colors.BOLD}{text:^60}{Colors.ENDC}")
print(f"{Colors.HEADER}{Colors.BOLD}{'='*60}{Colors.ENDC}\n")
def print_success(text):
print(f"{Colors.OKGREEN}[OK] {text}{Colors.ENDC}")
def print_error(text):
print(f"{Colors.FAIL}[ERROR] {text}{Colors.ENDC}")
def print_info(text):
print(f"{Colors.OKCYAN}[INFO] {text}{Colors.ENDC}")
def get_or_create_test_users():
"""Создает или получает тестовых пользователей."""
print_info("Подготовка тестовых пользователей...")
user1, created = Employee.objects.get_or_create(
email='test_user1@test.com',
defaults={
'first_name': 'Иван',
'last_name': 'Иванов',
'is_active': True,
'phone_number': '+79001111111'
}
)
if created:
user1.set_password('testpass123')
user1.save()
user2, created = Employee.objects.get_or_create(
email='test_user2@test.com',
defaults={
'first_name': 'Петр',
'last_name': 'Петров',
'is_active': True,
'phone_number': '+79002222222'
}
)
if created:
user2.set_password('testpass123')
user2.save()
print_success(f"Пользователи готовы: {user1.email}, {user2.email}")
return user1, user2
def test_communications(user1, user2):
"""Тестирует signals модуля Communications."""
print_header("ТЕСТ 1: Communications")
# Очистка предыдущих уведомлений
Notification.objects.filter(recipient__in=[user1, user2]).delete()
# 1. Создаем приватный чат
chat = Chat.objects.create(type='private')
chat.participants.add(user1, user2)
print_success(f"Создан чат #{chat.id}")
# Проверяем уведомление о добавлении в чат
notif_count = Notification.objects.filter(
notification_type__code='chat_added_to_chat'
).count()
print_info(f"Уведомлений о добавлении в чат: {notif_count}")
# 2. Простое сообщение
msg1 = Message.objects.create(
chat=chat,
author=user1,
content='Привет! Как дела?'
)
print_success(f"Создано сообщение #{msg1.id}")
notif = Notification.objects.filter(
recipient=user2,
notification_type__code='chat_new_message'
).first()
if notif:
print_success(f"[+] Уведомление получено: {notif.title}")
else:
print_error("Уведомление о новом сообщении НЕ создано!")
# 3. Сообщение с упоминанием
msg2 = Message.objects.create(
chat=chat,
author=user1,
content=f'Привет @{user2.email}, нужна твоя помощь!'
)
print_success(f"Создано сообщение с упоминанием #{msg2.id}")
mention_notif = Notification.objects.filter(
recipient=user2,
notification_type__code='chat_mention'
).first()
if mention_notif:
print_success(f"[+] Уведомление об упоминании: {mention_notif.title}")
else:
print_error("Уведомление об упоминании НЕ создано!")
# 4. Ответ на сообщение
msg3 = Message.objects.create(
chat=chat,
author=user2,
content='Конечно, помогу!',
reply_to=msg1
)
print_success(f"Создан ответ на сообщение #{msg3.id}")
reply_notif = Notification.objects.filter(
recipient=user1,
notification_type__code='chat_reply'
).first()
if reply_notif:
print_success(f"[+] Уведомление об ответе: {reply_notif.title}")
else:
print_error("Уведомление об ответе НЕ создано!")
total = Notification.objects.filter(recipient__in=[user1, user2]).count()
print_info(f"Всего создано уведомлений: {total}")
def test_requests(user1, user2):
"""Тестирует signals модуля Requests."""
print_header("ТЕСТ 2: Requests")
Notification.objects.filter(recipient__in=[user1, user2]).delete()
# 1. Создаем заявку
request = Request.objects.create(
employee=user1,
type='vacation',
title='Отпуск',
comment='Прошу предоставить отпуск',
status='pending',
approver=user2
)
print_success(f"Создана заявка #{request.id}")
new_request_notif = Notification.objects.filter(
notification_type__code='request_new'
).first()
if new_request_notif:
print_success(f"✓ Уведомление о новой заявке: {new_request_notif.title}")
else:
print_error("Уведомление о новой заявке НЕ создано!")
# 2. Одобряем заявку
from django.utils import timezone
request.status = 'approved'
request.decided_at = timezone.now()
request.save()
print_success(f"Заявка #{request.id} одобрена")
approved_notif = Notification.objects.filter(
recipient=user1,
notification_type__code='request_approved'
).first()
if approved_notif:
print_success(f"✓ Уведомление об одобрении: {approved_notif.title}")
else:
print_error("Уведомление об одобрении НЕ создано!")
# 3. Добавляем комментарий
comment = RequestComment.objects.create(
request=request,
author=user2,
text='Одобрено, хорошего отдыха!'
)
print_success(f"Добавлен комментарий #{comment.id}")
comment_notif = Notification.objects.filter(
recipient=user1,
notification_type__code='request_comment'
).first()
if comment_notif:
print_success(f"✓ Уведомление о комментарии: {comment_notif.title}")
else:
print_error("Уведомление о комментарии НЕ создано!")
total = Notification.objects.filter(recipient__in=[user1, user2]).count()
print_info(f"Всего создано уведомлений: {total}")
def test_documents(user1, user2):
"""Тестирует signals модуля Documents."""
print_header("ТЕСТ 3: Documents")
Notification.objects.filter(recipient__in=[user1, user2]).delete()
# 1. Создаем документ для конкретных получателей
doc = Document.objects.create(
title='Важный документ',
description='Требует ознакомления',
uploaded_by=user1
)
doc.recipients.add(user2)
print_success(f"Создан документ #{doc.id}")
doc_notif = Notification.objects.filter(
recipient=user2,
notification_type__code='document_ready'
).first()
if doc_notif:
print_success(f"✓ Уведомление о документе: {doc_notif.title}")
else:
print_error("Уведомление о документе НЕ создано!")
# 2. Ознакомление с документом
ack = DocumentAcknowledgement.objects.create(
document=doc,
user=user2
)
print_success(f"Пользователь {user2.username} ознакомился")
# Проверяем уведомление о полном ознакомлении
all_ack_notif = Notification.objects.filter(
recipient=user1,
notification_type__code='document_signed_all'
).first()
if all_ack_notif:
print_success(f"✓ Уведомление о полном ознакомлении: {all_ack_notif.title}")
else:
print_error("Уведомление о полном ознакомлении НЕ создано!")
total = Notification.objects.filter(recipient__in=[user1, user2]).count()
print_info(f"Всего создано уведомлений: {total}")
def test_calendar(user1, user2):
"""Тестирует signals модуля Calendar."""
print_header("ТЕСТ 4: Calendar")
Notification.objects.filter(recipient__in=[user1, user2]).delete()
from datetime import date, timedelta
# 1. Создаем событие компании
event = CalendarEvent.objects.create(
title='Общее собрание',
description='Ежеквартальное собрание',
start_date=date.today() + timedelta(days=7),
created_by=user1
)
print_success(f"Создано событие #{event.id}")
event_notif = Notification.objects.filter(
notification_type__code='event_created'
).first()
if event_notif:
print_success(f"✓ Уведомление о событии: {event_notif.title}")
print_info(f" Получателей: {Notification.objects.filter(notification_type__code='event_created').count()}")
else:
print_error("Уведомление о событии НЕ создано!")
# 2. Изменяем событие
Notification.objects.all().delete() # Очищаем для чистоты теста
event.title = 'ВАЖНОЕ Общее собрание'
event.save()
print_success(f"Событие #{event.id} изменено")
changed_notif = Notification.objects.filter(
notification_type__code='event_changed'
).first()
if changed_notif:
print_success(f"✓ Уведомление об изменении: {changed_notif.title}")
else:
print_error("Уведомление об изменении НЕ создано!")
# 3. Удаляем событие
Notification.objects.all().delete()
event.delete()
print_success("Событие удалено")
cancelled_notif = Notification.objects.filter(
notification_type__code='event_cancelled'
).first()
if cancelled_notif:
print_success(f"✓ Уведомление об отмене: {cancelled_notif.title}")
else:
print_error("Уведомление об отмене НЕ создано!")
def test_feed(user1, user2):
"""Тестирует signals модуля Feed."""
print_header("ТЕСТ 5: Feed")
Notification.objects.filter(recipient__in=[user1, user2]).delete()
# 1. Создаем публикацию компании
post = Post.objects.create(
author=user1,
title='Важная новость',
body='Текст важной новости для всех сотрудников',
type='company'
)
print_success(f"Создана публикация #{post.id}")
post_notif = Notification.objects.filter(
notification_type__code='feed_new_post'
).first()
if post_notif:
print_success(f"✓ Уведомление о публикации: {post_notif.title}")
print_info(f" Получателей: {Notification.objects.filter(notification_type__code='feed_new_post').count()}")
else:
print_error("Уведомление о публикации НЕ создано!")
# 2. Добавляем комментарий
comment = Comment.objects.create(
post=post,
author=user2,
text='Отличная новость!'
)
print_success(f"Добавлен комментарий #{comment.id}")
comment_notif = Notification.objects.filter(
recipient=user1,
notification_type__code='feed_post_comment'
).first()
if comment_notif:
print_success(f"✓ Уведомление о комментарии: {comment_notif.title}")
else:
print_error("Уведомление о комментарии НЕ создано!")
# 3. Тестируем функцию реакции
from feed.notification_signals import notify_post_reaction
notify_post_reaction(post, user2)
print_success("Вызвана функция реакции")
reaction_notif = Notification.objects.filter(
recipient=user1,
notification_type__code='feed_post_reaction'
).first()
if reaction_notif:
print_success(f"✓ Уведомление о реакции: {reaction_notif.title}")
else:
print_error("Уведомление о реакции НЕ создано!")
def main():
"""Основная функция запуска всех тестов."""
print(f"{Colors.BOLD}{Colors.OKBLUE}")
print("="*60)
print(" ТЕСТИРОВАНИЕ СИСТЕМЫ УВЕДОМЛЕНИЙ - ЭТАП 3")
print(" Интеграция с модулями через signals")
print("="*60)
print(f"{Colors.ENDC}")
try:
# Подготовка
user1, user2 = get_or_create_test_users()
# Запуск тестов
test_communications(user1, user2)
test_requests(user1, user2)
test_documents(user1, user2)
test_calendar(user1, user2)
test_feed(user1, user2)
# Итоговая статистика
print_header("ИТОГИ")
total_notifications = Notification.objects.count()
total_types_used = Notification.objects.values(
'notification_type__code'
).distinct().count()
print_success(f"Всего уведомлений в БД: {total_notifications}")
print_success(f"Использовано типов: {total_types_used}")
print(f"\n{Colors.OKGREEN}{Colors.BOLD}✓ ВСЕ ТЕСТЫ ЗАВЕРШЕНЫ!{Colors.ENDC}\n")
except Exception as e:
print_error(f"ОШИБКА: {e}")
import traceback
traceback.print_exc()
if __name__ == '__main__':
main()

Some files were not shown because too many files have changed in this diff Show More